Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 58 additions & 58 deletions dist/lite/markedit-preview.js

Large diffs are not rendered by default.

730 changes: 365 additions & 365 deletions dist/markedit-preview.js

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions src/hiddenSyntax/components/footnote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { WidgetType } from '@codemirror/view';

export class FootnoteDefinitionSuffix extends WidgetType {
constructor(private readonly text: string) {
super();
}

eq(other: FootnoteDefinitionSuffix) {
return other.text === this.text;
}

toDOM() {
const suffix = document.createElement('span');
suffix.textContent = this.text;
return suffix;
}

ignoreEvent() {
return false;
}
}
26 changes: 20 additions & 6 deletions src/hiddenSyntax/components/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@ import { highlightingFor } from '@codemirror/language';
import { WidgetType } from '@codemirror/view';
import type { EditorView } from '@codemirror/view';
import type { Tag } from '@lezer/highlight';
import { followLinkAnchor, openLinkDestination } from '../navigation';
import { followFootnote, followLinkAnchor, openLinkDestination } from '../navigation';
import { playSystemBeep } from '../../shared/utils';
import { localized } from '../../shared/strings';

const icons = {
link: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>',
image: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.09-3.09a2 2 0 0 0-2.82 0L6 21"/></svg>',
footnoteBack: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 14-5-5 5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-2"/></svg>',
};

export class LinkIconWidget extends WidgetType {
private readonly highlightClasses: string;

constructor(
private readonly kind: keyof typeof icons,
private readonly kind: keyof typeof icons | 'footnote',
state: EditorState,
private readonly destination: string,
private readonly label: string,
Expand All @@ -29,16 +32,27 @@ export class LinkIconWidget extends WidgetType {
icon.type = 'button';
icon.className = ['cm-md-syntaxHiddenLinkButton', this.highlightClasses].filter(Boolean).join(' ');
icon.dataset.kind = this.kind;
icon.title = this.destination;
icon.innerHTML = icons[this.kind];
icon.setAttribute('aria-label', this.destination || this.label);

icon.title = this.kind === 'footnote' || this.kind === 'footnoteBack'
? localized(this.kind === 'footnote' ? 'goToFootnoteDefinition' : 'backToFootnoteReference')
.replace('%s', () => this.destination.slice(1))
: this.destination;

icon.innerHTML = icons[this.kind === 'footnote' ? 'link' : this.kind];
icon.setAttribute('aria-label', icon.title || this.label);

icon.addEventListener('click', event => {
event.stopPropagation();
if (this.destination.startsWith('#')) {
if (this.kind === 'footnote') {
followFootnote(view, this.destination);
} else if (this.kind === 'footnoteBack') {
followFootnote(view, this.destination, 'reference');
} else if (this.destination.startsWith('#')) {
followLinkAnchor(view, this.destination);
} else if (this.destination !== '') {
openLinkDestination(this.destination);
} else {
playSystemBeep();
}
});

Expand Down
37 changes: 35 additions & 2 deletions src/hiddenSyntax/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@ import { blockquoteBars } from './components/bar';
import { unorderedListBullets } from './components/bullet';
import { taskCheckboxes } from './components/task';
import { LinkIconWidget } from './components/icon';
import { FootnoteDefinitionSuffix } from './components/footnote';
import { InlineImageWidget } from './components/image';
import { renderedBlockDecorations } from './block';
import { atxHeadingSyntaxRange, setextHeadingSyntaxLine } from './heading';
import { horizontalRuleDecoration } from './horizontalRule';
import { inlineSyntaxDecorations } from './inline';
import { linkSyntax, referenceDestinationResolver } from './link';
import { footnoteReferences, footnoteDefinitionSyntax, linkSyntax, referenceDestinationResolver } from './link';
import { hiddenSyntaxTheme } from './theme';
import { unorderedListSyntax } from './unorderedList';
import { correctedLineUp, stablePointerSelection } from './selection';
import { correctedLineUp, selectionReveals, stablePointerSelection } from './selection';
import { inlineImages } from '../support/settings';

const hiddenSyntax = Decoration.mark({ class: 'cm-md-syntaxHiddenSource' });
Expand Down Expand Up @@ -122,6 +123,38 @@ function hiddenSyntaxDecorations(view: EditorView) {
}
}

// [^footnote]
const footnoteRefs = footnoteReferences(node, view.state);
for (const reference of footnoteRefs) {
if (selectionReveals(view.state, reference.from, reference.to)) {
continue;
}

ranges.push(hiddenSyntax.range(reference.from + 1, reference.from + 2));
ranges.push(hiddenLinkLabel.range(reference.from, reference.to));
ranges.push(Decoration.widget({
widget: new LinkIconWidget('footnote', view.state, reference.label, reference.label, reference.highlightTags),
side: -1,
}).range(reference.to));
}

// [^footnote]: Definition
const footnoteDef = footnoteDefinitionSyntax(node, view.state);
if (footnoteDef !== undefined) {
footnoteDef.hidden.forEach(range => ranges.push(hiddenSyntax.range(range.from, range.to)));
ranges.push(Decoration.widget({
widget: new LinkIconWidget('footnoteBack', view.state, footnoteDef.label, footnoteDef.label, footnoteDef.highlightTags),
side: -1,
}).range(footnoteDef.suffixPosition));

if (footnoteDef.suffix !== '') {
ranges.push(Decoration.widget({
widget: new FootnoteDefinitionSuffix(footnoteDef.suffix),
side: 1,
}).range(footnoteDef.suffixPosition));
}
}

// Inline syntax (emphasis, strong emphasis, strikethrough, inline code)
const inlineDecorations = inlineSyntaxDecorations(node, view.state);
ranges.push(...inlineDecorations);
Expand Down
45 changes: 45 additions & 0 deletions src/hiddenSyntax/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getStyleTags, type Tag } from '@lezer/highlight';
import { selectionReveals } from './selection';

type ReferenceDestinationResolver = (label: string) => string;
const footnotePattern = /^\[\^[^\][\s]+\]$/;
const referenceDestinationCache = new WeakMap<Tree, {
doc: EditorState['doc'];
destinations: Map<string, string>;
Expand Down Expand Up @@ -43,6 +44,10 @@ export function linkSyntax(
return;
}

if (footnoteReferences(node, state).length > 0) {
return;
}

const referenceLabel = node.node.getChild('LinkLabel');
if (marks.length === 2 && (referenceLabel === null || referenceLabel.to - referenceLabel.from === 2)) {
return;
Expand Down Expand Up @@ -83,6 +88,46 @@ export function linkSyntax(
};
}

export function footnoteReferences(node: SyntaxNodeRef, state: EditorState) {
if (node.name !== 'Link') {
return [];
}

const source = state.sliceDoc(node.from, node.to);
const pattern = /\[\^[^\][\s]+\]/g;
const matches: RegExpExecArray[] = [];
for (let match = pattern.exec(source); match !== null; match = pattern.exec(source)) {
matches.push(match);
}

if (matches.map(match => match[0]).join('') !== source) {
return [];
}

return matches.map(match => ({
from: node.from + match.index,
to: node.from + match.index + match[0].length,
label: match[0].slice(1, -1),
highlightTags: inheritedHighlightTags(node.node),
}));
}

export function footnoteDefinitionSyntax(node: SyntaxNodeRef, state: EditorState) {
if (node.name !== 'LinkDefinition' || selectionReveals(state, node.from, node.to + 1)
|| !footnotePattern.test(state.sliceDoc(node.from, node.to))
|| state.sliceDoc(node.to, node.to + 1) !== ':') {
return;
}

return {
hidden: [{ from: node.from + 1, to: node.from + 2 }],
label: state.sliceDoc(node.from + 1, node.to - 1),
highlightTags: inheritedHighlightTags(node.node),
suffixPosition: node.to + 1,
suffix: /^[ \t]/.test(state.sliceDoc(node.to + 1, node.to + 2)) ? '' : ' ',
};
}

function inheritedHighlightTags(node: SyntaxNode) {
const path: SyntaxNode[] = [];
for (let current: SyntaxNode | null = node; current !== null; current = current.parent) {
Expand Down
51 changes: 46 additions & 5 deletions src/hiddenSyntax/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { EditorSelection } from '@codemirror/state';
import { EditorSelection, type SelectionRange } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { ensureSyntaxTree } from '@codemirror/language';
import { footnoteReferences } from './link';
import { headingLineForAnchor } from '../render';
import { playSystemBeep } from '../shared/utils';

const unsafeProtocol = /^(?:vbscript|javascript|file|data):/;
const safeDataImage = /^data:image\/(?:gif|png|jpeg|webp);/;
Expand All @@ -15,6 +18,40 @@ export function openLinkDestination(destination: string) {
return true;
}

export async function followFootnote(view: EditorView, label: string, direction: 'definition' | 'reference' = 'definition') {
const state = view.state;
const tree = ensureSyntaxTree(state, state.doc.length, 5000);
if (tree === null) {
return false;
}

let target: SelectionRange | undefined;
tree.iterate({
enter: node => {
if (target !== undefined) {
return false;
}

if (direction === 'reference') {
const reference = footnoteReferences(node, state).find(candidate => candidate.label === label);
if (reference !== undefined) {
target = EditorSelection.range(reference.from, reference.to);
}
} else if (node.name === 'LinkDefinition' && state.sliceDoc(node.from, node.to) === `[${label}]`) {
target = EditorSelection.range(node.from, node.to);
}
},
});

if (target === undefined) {
playSystemBeep();
return false;
}

revealLinkTarget(view, target);
return true;
}

export async function followLinkAnchor(view: EditorView, destination: string) {
const doc = view.state.doc;
const source = doc.toString();
Expand All @@ -24,18 +61,22 @@ export async function followLinkAnchor(view: EditorView, destination: string) {
}

const target = view.state.doc.line(line + 1).from;
revealLinkTarget(view, EditorSelection.cursor(target));
return true;
}

function revealLinkTarget(view: EditorView, target: SelectionRange) {
const doc = view.state.doc;
const currentOffset = view.scrollDOM.scrollTop;
const scroll = (y: 'start' | 'center') => view.dispatch({
effects: EditorView.scrollIntoView(target, { y, yMargin: 5 }),
effects: EditorView.scrollIntoView(target.from, { y, yMargin: 5 }),
});

view.dispatch({ selection: EditorSelection.cursor(target) });
view.dispatch({ selection: target });
Comment thread
cyanzhong marked this conversation as resolved.
scroll('start');
setTimeout(() => {
if (view.state.doc === doc && Math.abs(view.scrollDOM.scrollTop - currentOffset) < 0.001) {
scroll('center');
}
}, 50);

return true;
}
8 changes: 8 additions & 0 deletions src/shared/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ type LocalizableKeys = {
version: string;
source: string;
preview: string;
goToFootnoteDefinition: string;
backToFootnoteReference: string;
};

const strings: Record<Locale, Localizable> = {
Expand All @@ -35,6 +37,8 @@ const strings: Record<Locale, Localizable> = {
version: 'Version',
source: 'Source',
preview: 'Preview',
goToFootnoteDefinition: 'Go to definition [%s]',
backToFootnoteReference: 'Back to reference [%s]',
},
'zh-CN': {
viewMode: '视图模式',
Expand All @@ -53,6 +57,8 @@ const strings: Record<Locale, Localizable> = {
version: '版本',
source: '源码',
preview: '预览',
goToFootnoteDefinition: '跳转到定义 [%s]',
backToFootnoteReference: '返回引用 [%s]',
},
'zh-TW': {
viewMode: '視圖模式',
Expand All @@ -71,6 +77,8 @@ const strings: Record<Locale, Localizable> = {
version: '版本',
source: '原始碼',
preview: '預覽',
goToFootnoteDefinition: '前往定義 [%s]',
backToFootnoteReference: '返回引用 [%s]',
},
};

Expand Down
6 changes: 6 additions & 0 deletions src/shared/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { MarkEdit } from 'markedit-api';

export function playSystemBeep() {
if (typeof MarkEdit.playSystemBeep === 'function') {
MarkEdit.playSystemBeep();
}
}

export function macOSTahoe() {
const match = navigator.userAgent.match(/macOS\/(\d+)/);
return match === null ? false : parseInt(match[1]) >= 26;
Expand Down
Loading