mossmd 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/LICENSE +21 -0
- package/README.md +336 -0
- package/dist/collab/index.d.ts +14 -0
- package/dist/collab/index.d.ts.map +1 -0
- package/dist/collab/index.js +6 -0
- package/dist/collab/index.js.map +1 -0
- package/dist/core/code-languages.d.ts +3 -0
- package/dist/core/code-languages.d.ts.map +1 -0
- package/dist/core/code-languages.js +130 -0
- package/dist/core/code-languages.js.map +1 -0
- package/dist/core/decoration-utils.d.ts +4 -0
- package/dist/core/decoration-utils.d.ts.map +1 -0
- package/dist/core/decoration-utils.js +25 -0
- package/dist/core/decoration-utils.js.map +1 -0
- package/dist/core/edit-helpers.d.ts +13 -0
- package/dist/core/edit-helpers.d.ts.map +1 -0
- package/dist/core/edit-helpers.js +318 -0
- package/dist/core/edit-helpers.js.map +1 -0
- package/dist/core/icons.d.ts +16 -0
- package/dist/core/icons.d.ts.map +1 -0
- package/dist/core/icons.js +86 -0
- package/dist/core/icons.js.map +1 -0
- package/dist/core/inline-preview.d.ts +32 -0
- package/dist/core/inline-preview.d.ts.map +1 -0
- package/dist/core/inline-preview.js +925 -0
- package/dist/core/inline-preview.js.map +1 -0
- package/dist/core/list-editing.d.ts +10 -0
- package/dist/core/list-editing.d.ts.map +1 -0
- package/dist/core/list-editing.js +244 -0
- package/dist/core/list-editing.js.map +1 -0
- package/dist/core/list-model.d.ts +40 -0
- package/dist/core/list-model.d.ts.map +1 -0
- package/dist/core/list-model.js +114 -0
- package/dist/core/list-model.js.map +1 -0
- package/dist/core/list-navigation.d.ts +3 -0
- package/dist/core/list-navigation.d.ts.map +1 -0
- package/dist/core/list-navigation.js +234 -0
- package/dist/core/list-navigation.js.map +1 -0
- package/dist/core/markdown-context.d.ts +3 -0
- package/dist/core/markdown-context.d.ts.map +1 -0
- package/dist/core/markdown-context.js +10 -0
- package/dist/core/markdown-context.js.map +1 -0
- package/dist/core/preview-activity.d.ts +18 -0
- package/dist/core/preview-activity.d.ts.map +1 -0
- package/dist/core/preview-activity.js +199 -0
- package/dist/core/preview-activity.js.map +1 -0
- package/dist/core/preview-widgets.d.ts +65 -0
- package/dist/core/preview-widgets.d.ts.map +1 -0
- package/dist/core/preview-widgets.js +359 -0
- package/dist/core/preview-widgets.js.map +1 -0
- package/dist/core/read-only.d.ts +26 -0
- package/dist/core/read-only.d.ts.map +1 -0
- package/dist/core/read-only.js +49 -0
- package/dist/core/read-only.js.map +1 -0
- package/dist/core/tree-progress.d.ts +29 -0
- package/dist/core/tree-progress.d.ts.map +1 -0
- package/dist/core/tree-progress.js +143 -0
- package/dist/core/tree-progress.js.map +1 -0
- package/dist/editor.d.ts +219 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +551 -0
- package/dist/editor.js.map +1 -0
- package/dist/features/callout/index.d.ts +9 -0
- package/dist/features/callout/index.d.ts.map +1 -0
- package/dist/features/callout/index.js +223 -0
- package/dist/features/callout/index.js.map +1 -0
- package/dist/features/file-blocks/index.d.ts +12 -0
- package/dist/features/file-blocks/index.d.ts.map +1 -0
- package/dist/features/file-blocks/index.js +397 -0
- package/dist/features/file-blocks/index.js.map +1 -0
- package/dist/features/image/index.d.ts +28 -0
- package/dist/features/image/index.d.ts.map +1 -0
- package/dist/features/image/index.js +699 -0
- package/dist/features/image/index.js.map +1 -0
- package/dist/features/index.d.ts +17 -0
- package/dist/features/index.d.ts.map +1 -0
- package/dist/features/index.js +9 -0
- package/dist/features/index.js.map +1 -0
- package/dist/features/search/index.d.ts +4 -0
- package/dist/features/search/index.d.ts.map +1 -0
- package/dist/features/search/index.js +131 -0
- package/dist/features/search/index.js.map +1 -0
- package/dist/features/slash-commands/index.d.ts +52 -0
- package/dist/features/slash-commands/index.d.ts.map +1 -0
- package/dist/features/slash-commands/index.js +422 -0
- package/dist/features/slash-commands/index.js.map +1 -0
- package/dist/features/table/index.d.ts +44 -0
- package/dist/features/table/index.d.ts.map +1 -0
- package/dist/features/table/index.js +1262 -0
- package/dist/features/table/index.js.map +1 -0
- package/dist/features/upload/index.d.ts +80 -0
- package/dist/features/upload/index.d.ts.map +1 -0
- package/dist/features/upload/index.js +790 -0
- package/dist/features/upload/index.js.map +1 -0
- package/dist/features/wiki-links/index.d.ts +27 -0
- package/dist/features/wiki-links/index.d.ts.map +1 -0
- package/dist/features/wiki-links/index.js +577 -0
- package/dist/features/wiki-links/index.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/styles/content.css +247 -0
- package/dist/styles/inline-preview.css +2116 -0
- package/dist/styles/tokens.css +234 -0
- package/dist/syntax/highlight.d.ts +10 -0
- package/dist/syntax/highlight.d.ts.map +1 -0
- package/dist/syntax/highlight.js +75 -0
- package/dist/syntax/highlight.js.map +1 -0
- package/dist/syntax/index.d.ts +16 -0
- package/dist/syntax/index.d.ts.map +1 -0
- package/dist/syntax/index.js +29 -0
- package/dist/syntax/index.js.map +1 -0
- package/dist/theme/index.d.ts +6 -0
- package/dist/theme/index.d.ts.map +1 -0
- package/dist/theme/index.js +204 -0
- package/dist/theme/index.js.map +1 -0
- package/package.json +237 -0
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
|
|
2
|
+
import { Decoration, EditorView, ViewPlugin, WidgetType, } from '@codemirror/view';
|
|
3
|
+
import { treeGrowthEffect, treeProgressPlugin } from './tree-progress';
|
|
4
|
+
import { readOnlyFacet } from './read-only';
|
|
5
|
+
import { pushReplace } from './decoration-utils';
|
|
6
|
+
import { defaultOnLinkClick, getPreviewActivity, linkElementFromEvent, linkIconHitTarget, previewActivityExtension, previewFrozenField, refreshInlinePreview, shouldRevealTaskSource, } from './preview-activity';
|
|
7
|
+
import { BulletWidget, CodeCopyWidget, fencedCodeSource, parseListTaskMarker, taskCheckboxConfigFacet, TaskCheckboxWidget, shouldUseNativeTaskCheckbox, } from './preview-widgets';
|
|
8
|
+
import { listEditingExtension } from './list-editing';
|
|
9
|
+
import { listNavigationExtension } from './list-navigation';
|
|
10
|
+
import { isLineInsideMarkdownCode } from './markdown-context';
|
|
11
|
+
function parseExtendedHorizontalRule(text) {
|
|
12
|
+
const match = text.match(/^\s*(-{3,}|\*{3,}|_{3,})(.*?)\1\s*$/);
|
|
13
|
+
if (!match)
|
|
14
|
+
return null;
|
|
15
|
+
const glyph = match[2].trim();
|
|
16
|
+
if (!glyph)
|
|
17
|
+
return null;
|
|
18
|
+
return {
|
|
19
|
+
marker: match[1][0],
|
|
20
|
+
glyph,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function isPlainHorizontalRule(text) {
|
|
24
|
+
return /^\s*(\*|_|-)(?:[ \t]*\1){2,}[ \t]*$/.test(text);
|
|
25
|
+
}
|
|
26
|
+
class HorizontalRuleWidget extends WidgetType {
|
|
27
|
+
constructor(marker, glyph = null) {
|
|
28
|
+
super();
|
|
29
|
+
Object.defineProperty(this, "marker", {
|
|
30
|
+
enumerable: true,
|
|
31
|
+
configurable: true,
|
|
32
|
+
writable: true,
|
|
33
|
+
value: marker
|
|
34
|
+
});
|
|
35
|
+
Object.defineProperty(this, "glyph", {
|
|
36
|
+
enumerable: true,
|
|
37
|
+
configurable: true,
|
|
38
|
+
writable: true,
|
|
39
|
+
value: glyph
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
eq(other) {
|
|
43
|
+
return other.marker === this.marker && other.glyph === this.glyph;
|
|
44
|
+
}
|
|
45
|
+
toDOM() {
|
|
46
|
+
const wrap = document.createElement('span');
|
|
47
|
+
const classes = ['cm-moss-hr-widget'];
|
|
48
|
+
if (this.marker === '*')
|
|
49
|
+
classes.push('cm-moss-hr-widget-wavy');
|
|
50
|
+
if (this.glyph)
|
|
51
|
+
classes.push('cm-moss-hr-widget-glyph');
|
|
52
|
+
wrap.className = classes.join(' ');
|
|
53
|
+
wrap.setAttribute('aria-hidden', 'true');
|
|
54
|
+
const left = document.createElement('span');
|
|
55
|
+
left.className = 'cm-moss-hr-segment cm-moss-hr-segment-left';
|
|
56
|
+
if (!this.glyph) {
|
|
57
|
+
wrap.append(left);
|
|
58
|
+
return wrap;
|
|
59
|
+
}
|
|
60
|
+
const glyph = document.createElement('span');
|
|
61
|
+
glyph.className = 'cm-moss-hr-symbol';
|
|
62
|
+
glyph.textContent = this.glyph;
|
|
63
|
+
const right = document.createElement('span');
|
|
64
|
+
right.className = 'cm-moss-hr-segment cm-moss-hr-segment-right';
|
|
65
|
+
wrap.append(left, glyph, right);
|
|
66
|
+
return wrap;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function horizontalRuleClassNames(marker, glyph) {
|
|
70
|
+
const classNames = ['cm-moss-hr'];
|
|
71
|
+
if (marker === '*')
|
|
72
|
+
classNames.push('cm-moss-hr-wavy');
|
|
73
|
+
if (glyph)
|
|
74
|
+
classNames.push('cm-moss-hr-glyph');
|
|
75
|
+
return classNames.join(' ');
|
|
76
|
+
}
|
|
77
|
+
function pushHorizontalRuleWidget(ranges, doc, line, marker, glyph = null) {
|
|
78
|
+
pushReplace(ranges, doc, line.from, line.to, {
|
|
79
|
+
widget: new HorizontalRuleWidget(marker, glyph),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// decoration building
|
|
83
|
+
const LINE_CLASS_BY_BLOCK = {
|
|
84
|
+
ATXHeading1: 'cm-moss-h1',
|
|
85
|
+
ATXHeading2: 'cm-moss-h2',
|
|
86
|
+
ATXHeading3: 'cm-moss-h3',
|
|
87
|
+
ATXHeading4: 'cm-moss-h4',
|
|
88
|
+
ATXHeading5: 'cm-moss-h5',
|
|
89
|
+
ATXHeading6: 'cm-moss-h6',
|
|
90
|
+
SetextHeading1: 'cm-moss-h1',
|
|
91
|
+
SetextHeading2: 'cm-moss-h2',
|
|
92
|
+
Blockquote: 'cm-moss-blockquote',
|
|
93
|
+
FencedCode: 'cm-moss-fenced-code',
|
|
94
|
+
};
|
|
95
|
+
const HIDEABLE_SYNTAX = new Set([
|
|
96
|
+
'HeaderMark',
|
|
97
|
+
'EmphasisMark',
|
|
98
|
+
'CodeMark',
|
|
99
|
+
'CodeInfo',
|
|
100
|
+
'LinkMark',
|
|
101
|
+
'LinkTitle',
|
|
102
|
+
'StrikethroughMark',
|
|
103
|
+
'HighlightMark',
|
|
104
|
+
'QuoteMark',
|
|
105
|
+
]);
|
|
106
|
+
// Children of a Link node whose visibility follows the link-scoped
|
|
107
|
+
// rule (cursor-inside-link) instead of the default line-based rule.
|
|
108
|
+
// The same token names can appear under an Image node — those stay
|
|
109
|
+
// on the line-based rule because images are a different UX surface.
|
|
110
|
+
const LINK_CHILD_SYNTAX = new Set(['LinkMark', 'URL', 'LinkTitle']);
|
|
111
|
+
function isWikiLinkNode(node, doc) {
|
|
112
|
+
// Wiki links are parsed by the Markdown grammar as ordinary Link nodes
|
|
113
|
+
// whose range starts at the second `[` and ends at the first `]`. Their
|
|
114
|
+
// feature decoration owns both brackets and the cursor-scoped reveal.
|
|
115
|
+
// Letting this generic link pass also makes hidden wiki syntax participate
|
|
116
|
+
// in the DOM position mapping twice, which can map a click in following
|
|
117
|
+
// prose to the previous link's `]|]` boundary.
|
|
118
|
+
return (node.name === 'Link' &&
|
|
119
|
+
node.from > 0 &&
|
|
120
|
+
node.to < doc.length &&
|
|
121
|
+
doc.sliceString(node.from - 1, node.from + 1) === '[[' &&
|
|
122
|
+
doc.sliceString(node.to - 1, node.to + 1) === ']]');
|
|
123
|
+
}
|
|
124
|
+
function isCalloutMarkerNode(node, doc) {
|
|
125
|
+
if (node.name !== 'Link')
|
|
126
|
+
return false;
|
|
127
|
+
const line = doc.lineAt(node.from);
|
|
128
|
+
const match = line.text.match(/^(\s{0,3}>\s?)(\[![A-Za-z][\w-]*\])/);
|
|
129
|
+
return (match !== null &&
|
|
130
|
+
node.from === line.from + match[1].length &&
|
|
131
|
+
node.to === line.from + match[0].length);
|
|
132
|
+
}
|
|
133
|
+
const INLINE_MARK_CLASS = {
|
|
134
|
+
StrongEmphasis: 'cm-moss-strong',
|
|
135
|
+
Emphasis: 'cm-moss-em',
|
|
136
|
+
InlineCode: 'cm-moss-inline-code',
|
|
137
|
+
Strikethrough: 'cm-moss-strike',
|
|
138
|
+
Highlight: 'cm-moss-highlight',
|
|
139
|
+
Link: 'cm-moss-link',
|
|
140
|
+
};
|
|
141
|
+
// A Link can contain two URL nodes when its visible label is itself a
|
|
142
|
+
// URL: `[https://label](https://destination)`. Only the node after the
|
|
143
|
+
// closing `]` is the destination syntax that should collapse. Treating
|
|
144
|
+
// every URL under Link as a destination makes the visible label vanish.
|
|
145
|
+
function linkDestinationUrl(link, doc) {
|
|
146
|
+
const labelClose = link
|
|
147
|
+
.getChildren('LinkMark')
|
|
148
|
+
.find((mark) => doc.sliceString(mark.from, mark.to) === ']');
|
|
149
|
+
if (!labelClose)
|
|
150
|
+
return null;
|
|
151
|
+
return (link
|
|
152
|
+
.getChildren('URL')
|
|
153
|
+
.find((url) => url.from >= labelClose.to) ?? null);
|
|
154
|
+
}
|
|
155
|
+
const LIST_BASE_EM = 0.8;
|
|
156
|
+
const LIST_ALCOVE_EM = 1.2;
|
|
157
|
+
// Keep the visual step aligned with the existing list indentation contract.
|
|
158
|
+
const LIST_LEVEL_EM = 1;
|
|
159
|
+
function nearestListItem(node) {
|
|
160
|
+
for (let current = node; current; current = current.parent) {
|
|
161
|
+
if (current.name === 'ListItem')
|
|
162
|
+
return current;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
function listItemDepth(item) {
|
|
167
|
+
let depth = 0;
|
|
168
|
+
for (let parent = item.parent; parent; parent = parent.parent) {
|
|
169
|
+
if (parent.name === 'ListItem')
|
|
170
|
+
depth++;
|
|
171
|
+
}
|
|
172
|
+
return depth;
|
|
173
|
+
}
|
|
174
|
+
function sameListItem(a, b) {
|
|
175
|
+
return a?.name === 'ListItem' && a.from === b.from && a.to === b.to;
|
|
176
|
+
}
|
|
177
|
+
function buildInlineDecorations(view) {
|
|
178
|
+
const { state } = view;
|
|
179
|
+
const { doc } = state;
|
|
180
|
+
const ranges = [];
|
|
181
|
+
// In read-only mode no line is ever "active" — the whole doc stays
|
|
182
|
+
// rendered (no reveal). We skip the selection walk entirely rather
|
|
183
|
+
// than relying on `hasFocus` staying false, so a programmatic
|
|
184
|
+
// `.focus()` can't accidentally reveal source under reading mode.
|
|
185
|
+
const { activeLines: initialActiveLines } = getPreviewActivity(view);
|
|
186
|
+
const activeLines = new Set(initialActiveLines);
|
|
187
|
+
// Decorate the whole parsed tree — not the current viewport — so
|
|
188
|
+
// that scrolling never needs to rebuild the decoration set. Prior
|
|
189
|
+
// design walked viewport-only and rebuilt on every scroll, which
|
|
190
|
+
// on iOS caused scroll-up momentum halts whenever new decorations
|
|
191
|
+
// were applied to lines at the top of the viewport (anchor
|
|
192
|
+
// conflict with the scroll animation). Cost: a one-shot whole-doc
|
|
193
|
+
// walk on every doc / selection / focus change instead of a
|
|
194
|
+
// smaller walk on every scroll.
|
|
195
|
+
//
|
|
196
|
+
// `ensureSyntaxTree(..., doc.length, ...)` guarantees the tree
|
|
197
|
+
// actually covers the whole doc before we walk it. Without this,
|
|
198
|
+
// for moderately long atoms the incremental parser's initial
|
|
199
|
+
// pass falls short of the end, we'd walk only a prefix, and
|
|
200
|
+
// content past that point renders as raw `##`/`**` forever —
|
|
201
|
+
// decorations don't rebuild on scroll anymore. Subsequent calls
|
|
202
|
+
// are near-free because ensureSyntaxTree short-circuits once the
|
|
203
|
+
// tree reaches the target.
|
|
204
|
+
const tree = ensureSyntaxTree(state, state.doc.length, 200) ?? syntaxTree(state);
|
|
205
|
+
const taskConfig = state.facet(taskCheckboxConfigFacet);
|
|
206
|
+
const extendedHorizontalRuleLines = new Map();
|
|
207
|
+
const horizontalRuleSourceLines = new Set();
|
|
208
|
+
for (let number = 1; number <= doc.lines; number++) {
|
|
209
|
+
const line = doc.line(number);
|
|
210
|
+
if (line.length === 0) {
|
|
211
|
+
ranges.push(Decoration.line({ class: 'cm-moss-empty-line' }).range(line.from));
|
|
212
|
+
}
|
|
213
|
+
if (isLineInsideMarkdownCode(state, number))
|
|
214
|
+
continue;
|
|
215
|
+
const extendedHorizontalRule = parseExtendedHorizontalRule(line.text);
|
|
216
|
+
if (!extendedHorizontalRule) {
|
|
217
|
+
if (!isPlainHorizontalRule(line.text))
|
|
218
|
+
continue;
|
|
219
|
+
horizontalRuleSourceLines.add(number);
|
|
220
|
+
if (activeLines.has(number) && line.from < line.to) {
|
|
221
|
+
ranges.push(Decoration.mark({ class: 'cm-moss-hr-source' }).range(line.from, line.to));
|
|
222
|
+
}
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
extendedHorizontalRuleLines.set(number, extendedHorizontalRule);
|
|
226
|
+
horizontalRuleSourceLines.add(number);
|
|
227
|
+
if (activeLines.has(number)) {
|
|
228
|
+
if (line.from < line.to) {
|
|
229
|
+
ranges.push(Decoration.mark({ class: 'cm-moss-hr-source' }).range(line.from, line.to));
|
|
230
|
+
}
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
ranges.push(Decoration.line({
|
|
234
|
+
class: horizontalRuleClassNames(extendedHorizontalRule.marker, extendedHorizontalRule.glyph),
|
|
235
|
+
}).range(line.from));
|
|
236
|
+
pushHorizontalRuleWidget(ranges, doc, line, extendedHorizontalRule.marker, extendedHorizontalRule.glyph);
|
|
237
|
+
}
|
|
238
|
+
// `from` positions of Link nodes whose range overlaps a selection.
|
|
239
|
+
// Link children (LinkMark/URL/LinkTitle) hide unless their parent
|
|
240
|
+
// Link's `from` is in this set — i.e. the cursor has entered or is
|
|
241
|
+
// immediately beside the link, not merely landed on the same line.
|
|
242
|
+
// Images aren't included because their widget owns the source visibility.
|
|
243
|
+
const activeLinkStarts = new Set();
|
|
244
|
+
// Single pre-order walk. A tree walk visits a parent before its
|
|
245
|
+
// children, which lets us compute two pieces of look-ahead state on
|
|
246
|
+
// the way in — right before the children that depend on them:
|
|
247
|
+
// - Fenced-code active expansion: clicking any line of a fence
|
|
248
|
+
// activates the whole block. FencedCode is entered before its
|
|
249
|
+
// CodeMark/CodeInfo children, so expanding activeLines here means
|
|
250
|
+
// those children hide/reveal consistently with the block.
|
|
251
|
+
// - activeLinkStarts: a Link is entered before its LinkMark/URL/
|
|
252
|
+
// LinkTitle children, so recording it here makes the link-scoped
|
|
253
|
+
// reveal rule ready when those children are processed.
|
|
254
|
+
// (A previous version ran a separate pre-pass plus a taskMarkerByLine
|
|
255
|
+
// map. Folding both into this one walk halves the per-rebuild tree
|
|
256
|
+
// traversal — meaningful because this runs on every cursor move and
|
|
257
|
+
// its cost scales with document size.)
|
|
258
|
+
tree.iterate({
|
|
259
|
+
enter: (node) => {
|
|
260
|
+
if (node.name !== 'Document' &&
|
|
261
|
+
(extendedHorizontalRuleLines.has(doc.lineAt(node.from).number) ||
|
|
262
|
+
(node.name !== 'HorizontalRule' &&
|
|
263
|
+
horizontalRuleSourceLines.has(doc.lineAt(node.from).number)))) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
// GFM parses custom task markers such as `[!]` and `[/]` as
|
|
267
|
+
// shortcut links. They are task UI, not links: skip the synthetic
|
|
268
|
+
// Link node so it cannot add a link icon or hide its source using
|
|
269
|
+
// link-scoped reveal rules.
|
|
270
|
+
if (node.name === 'Link') {
|
|
271
|
+
if (isWikiLinkNode(node, doc))
|
|
272
|
+
return false;
|
|
273
|
+
// Callout owns the `[!TYPE]` marker on blockquote lines. The
|
|
274
|
+
// Markdown parser exposes it as a shortcut Link, but treating it as
|
|
275
|
+
// a normal link would hide its brackets and apply link styling.
|
|
276
|
+
if (isCalloutMarkerNode(node, doc))
|
|
277
|
+
return false;
|
|
278
|
+
const line = doc.lineAt(node.from);
|
|
279
|
+
const taskInfo = parseListTaskMarker(line.text, taskConfig);
|
|
280
|
+
if (taskInfo &&
|
|
281
|
+
!isLineInsideMarkdownCode(state, line.number) &&
|
|
282
|
+
node.from === line.from + taskInfo.markerFrom &&
|
|
283
|
+
node.to === line.from + taskInfo.markerTo) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (node.name === 'FencedCode') {
|
|
288
|
+
const firstLine = doc.lineAt(node.from).number;
|
|
289
|
+
const lastLine = doc.lineAt(node.to).number;
|
|
290
|
+
let anyActive = false;
|
|
291
|
+
for (let n = firstLine; n <= lastLine; n++) {
|
|
292
|
+
if (activeLines.has(n)) {
|
|
293
|
+
anyActive = true;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (anyActive) {
|
|
298
|
+
for (let n = firstLine; n <= lastLine; n++)
|
|
299
|
+
activeLines.add(n);
|
|
300
|
+
}
|
|
301
|
+
ranges.push(Decoration.widget({
|
|
302
|
+
widget: new CodeCopyWidget(fencedCodeSource(doc, node.from, node.to)),
|
|
303
|
+
}).range(doc.line(firstLine).from));
|
|
304
|
+
}
|
|
305
|
+
if (node.name === 'Link' && view.hasFocus) {
|
|
306
|
+
for (const range of state.selection.ranges) {
|
|
307
|
+
// Inclusive overlap: cursor sitting exactly on either
|
|
308
|
+
// boundary counts as inside, matching the UX where the
|
|
309
|
+
// next keystroke affects the link.
|
|
310
|
+
if (range.from <= node.to && range.to >= node.from) {
|
|
311
|
+
activeLinkStarts.add(node.from);
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (node.name === 'FencedCode') {
|
|
317
|
+
const firstLine = doc.lineAt(node.from);
|
|
318
|
+
const lastLine = doc.lineAt(node.to);
|
|
319
|
+
for (let n = firstLine.number; n <= lastLine.number; n++) {
|
|
320
|
+
const line = doc.line(n);
|
|
321
|
+
const classes = ['cm-moss-fenced-code'];
|
|
322
|
+
if (n === firstLine.number)
|
|
323
|
+
classes.push('cm-moss-fenced-code-start');
|
|
324
|
+
if (n === lastLine.number)
|
|
325
|
+
classes.push('cm-moss-fenced-code-end');
|
|
326
|
+
ranges.push(Decoration.line({ class: classes.join(' ') }).range(line.from));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
const lineClass = LINE_CLASS_BY_BLOCK[node.name];
|
|
331
|
+
if (lineClass) {
|
|
332
|
+
const firstLine = doc.lineAt(node.from);
|
|
333
|
+
const lastLine = doc.lineAt(node.to);
|
|
334
|
+
for (let n = firstLine.number; n <= lastLine.number; n++) {
|
|
335
|
+
const line = doc.line(n);
|
|
336
|
+
ranges.push(Decoration.line({ class: lineClass }).range(line.from));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const markClass = INLINE_MARK_CLASS[node.name];
|
|
341
|
+
if (markClass && node.from < node.to) {
|
|
342
|
+
ranges.push(Decoration.mark({ class: markClass }).range(node.from, node.to));
|
|
343
|
+
}
|
|
344
|
+
if (HIDEABLE_SYNTAX.has(node.name) && node.from < node.to) {
|
|
345
|
+
const lineNum = doc.lineAt(node.from).number;
|
|
346
|
+
// Link children use a link-scoped rule rather than the
|
|
347
|
+
// line-based rule. A LinkMark under an Image node falls
|
|
348
|
+
// through to line-based — images have their own widget UX.
|
|
349
|
+
let shouldHide;
|
|
350
|
+
if (LINK_CHILD_SYNTAX.has(node.name)) {
|
|
351
|
+
let parent = node.node.parent;
|
|
352
|
+
while (parent && parent.name !== 'Link' && parent.name !== 'Image') {
|
|
353
|
+
parent = parent.parent;
|
|
354
|
+
}
|
|
355
|
+
if (parent && parent.name === 'Link') {
|
|
356
|
+
shouldHide = !activeLinkStarts.has(parent.from);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
shouldHide = !activeLines.has(lineNum);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
shouldHide = !activeLines.has(lineNum);
|
|
364
|
+
}
|
|
365
|
+
if (shouldHide) {
|
|
366
|
+
let hideTo = node.to;
|
|
367
|
+
if (node.name === 'HeaderMark' || node.name === 'QuoteMark') {
|
|
368
|
+
while (hideTo < doc.length && doc.sliceString(hideTo, hideTo + 1) === ' ') {
|
|
369
|
+
hideTo++;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
pushReplace(ranges, doc, node.from, hideTo);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (node.name === 'URL' && node.from < node.to) {
|
|
376
|
+
const parent = node.node.parent;
|
|
377
|
+
if (parent?.name === 'Link') {
|
|
378
|
+
// A URL in the label is visible content. A URL after the
|
|
379
|
+
// closing `]` is destination syntax and follows the same
|
|
380
|
+
// cursor-inside-or-beside-this-link reveal rule.
|
|
381
|
+
const destination = linkDestinationUrl(parent, doc);
|
|
382
|
+
if (destination?.from === node.from &&
|
|
383
|
+
!activeLinkStarts.has(parent.from)) {
|
|
384
|
+
pushReplace(ranges, doc, node.from, node.to);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
// Bare GFM URLs and `<https://...>` autolinks are visible
|
|
389
|
+
// content, not syntax. Give them the same styling and icon
|
|
390
|
+
// hit target as explicit links while leaving their text in
|
|
391
|
+
// the document flow on inactive lines.
|
|
392
|
+
ranges.push(Decoration.mark({ class: 'cm-moss-link' }).range(node.from, node.to));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// Backslash escapes: `\.`, `\*`, `\(`, etc. RSS-to-markdown
|
|
396
|
+
// converters escape a lot of punctuation defensively, and the
|
|
397
|
+
// backslashes show through as literal chars without preview.
|
|
398
|
+
// Hide just the leading backslash on inactive lines so the
|
|
399
|
+
// escaped character remains visible — mirrors how Obsidian
|
|
400
|
+
// renders escapes. The Escape node spans both characters
|
|
401
|
+
// (`\` + escaped char), so we only replace the first position.
|
|
402
|
+
if (node.name === 'Escape' && node.to - node.from >= 2) {
|
|
403
|
+
const lineNum = doc.lineAt(node.from).number;
|
|
404
|
+
if (!activeLines.has(lineNum)) {
|
|
405
|
+
pushReplace(ranges, doc, node.from, node.from + 1);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (node.name === 'ListMark' && node.from < node.to) {
|
|
409
|
+
const line = doc.lineAt(node.from);
|
|
410
|
+
const lineActive = activeLines.has(line.number);
|
|
411
|
+
const markText = doc.sliceString(node.from, node.to);
|
|
412
|
+
const orderedMarker = /^\d{1,9}[.)]$/.test(markText);
|
|
413
|
+
const markEndInLine = node.to - line.from;
|
|
414
|
+
const hasListSeparator = markEndInLine < line.text.length &&
|
|
415
|
+
/\s/.test(line.text[markEndInLine] ?? '');
|
|
416
|
+
// Lezer accepts an empty `1.` as an OrderedList so it can parse
|
|
417
|
+
// CommonMark's optional empty item. In the editor, keep that raw
|
|
418
|
+
// punctuation as prose until the user types the separator space.
|
|
419
|
+
if (orderedMarker && !hasListSeparator)
|
|
420
|
+
return;
|
|
421
|
+
// Detect task status from the raw line because the Markdown parser
|
|
422
|
+
// only knows the standard `[ ]` / `[x]` pair. This also lets the
|
|
423
|
+
// widget replace custom statuses before their source reaches the
|
|
424
|
+
// normal inline decoration pass.
|
|
425
|
+
const taskInfo = parseListTaskMarker(line.text, taskConfig);
|
|
426
|
+
const standardTask = taskInfo != null && (taskInfo.key === ' ' || taskInfo.key === 'x');
|
|
427
|
+
const revealTaskSource = taskInfo != null && shouldRevealTaskSource(view, line.from, taskInfo);
|
|
428
|
+
// Hanging-indent every physical line owned by this list item.
|
|
429
|
+
// Ownership and depth come from the parsed tree, not raw source
|
|
430
|
+
// indentation: CommonMark allows up to three leading spaces on a
|
|
431
|
+
// top-level item, and ordered-list children commonly use a
|
|
432
|
+
// marker-width indent rather than two spaces.
|
|
433
|
+
//
|
|
434
|
+
// Layout:
|
|
435
|
+
//
|
|
436
|
+
// <--BASE--><--ALCOVE--> first-line text
|
|
437
|
+
// • wrapped lines land at the
|
|
438
|
+
// same column as the first-line
|
|
439
|
+
// text, not back under the marker
|
|
440
|
+
//
|
|
441
|
+
// LIST_ALCOVE_EM is fixed regardless of list kind.
|
|
442
|
+
// Every marker (bullet widget, checkbox widget, ordered
|
|
443
|
+
// number via mark decoration) is forced into an
|
|
444
|
+
// inline-block of exactly that width via CSS — so the
|
|
445
|
+
// alignment math doesn't depend on per-font marker
|
|
446
|
+
// widths. `padding-left` sets the content column;
|
|
447
|
+
// negative `text-indent` of the same magnitude pulls the
|
|
448
|
+
// first line back so the marker lands in the alcove. Structural
|
|
449
|
+
// leading spaces are replaced visually on every owned line;
|
|
450
|
+
// otherwise they would be added on top of the tree-derived
|
|
451
|
+
// padding and ordered/odd indentation would still drift.
|
|
452
|
+
const listItem = nearestListItem(node.node);
|
|
453
|
+
const listDepth = listItem ? listItemDepth(listItem) : 0;
|
|
454
|
+
if (listItem) {
|
|
455
|
+
const padding = LIST_BASE_EM + LIST_ALCOVE_EM + listDepth * LIST_LEVEL_EM;
|
|
456
|
+
const firstLine = doc.lineAt(listItem.from);
|
|
457
|
+
const lastLine = doc.lineAt(listItem.to);
|
|
458
|
+
for (let number = firstLine.number; number <= lastLine.number; number++) {
|
|
459
|
+
const ownedLine = doc.line(number);
|
|
460
|
+
const contentOffset = ownedLine.text.search(/\S/);
|
|
461
|
+
if (contentOffset < 0)
|
|
462
|
+
continue;
|
|
463
|
+
const contentFrom = ownedLine.from + contentOffset;
|
|
464
|
+
const owner = nearestListItem(tree.resolve(contentFrom, 1));
|
|
465
|
+
if (!sameListItem(owner, listItem))
|
|
466
|
+
continue;
|
|
467
|
+
const markerLine = ownedLine.number === line.number;
|
|
468
|
+
if (!markerLine && contentFrom === ownedLine.from)
|
|
469
|
+
continue;
|
|
470
|
+
ranges.push(Decoration.line({
|
|
471
|
+
attributes: {
|
|
472
|
+
style: `padding-left: ${padding}em; text-indent: ${markerLine ? `-${LIST_ALCOVE_EM}` : '0'}em`,
|
|
473
|
+
},
|
|
474
|
+
}).range(ownedLine.from));
|
|
475
|
+
if (contentFrom > ownedLine.from) {
|
|
476
|
+
pushReplace(ranges, doc, ownedLine.from, contentFrom);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (lineActive &&
|
|
481
|
+
(markText === '-' ||
|
|
482
|
+
markText === '*' ||
|
|
483
|
+
markText === '+' ||
|
|
484
|
+
orderedMarker) &&
|
|
485
|
+
// Task markers are rendered by the task-specific branches below.
|
|
486
|
+
// Only keep the raw list marker when the task source is actually
|
|
487
|
+
// being revealed; otherwise replacing just TaskMarker would leave
|
|
488
|
+
// the default `- ` visible while custom statuses replace the full
|
|
489
|
+
// list prefix and marker together.
|
|
490
|
+
(taskInfo == null || revealTaskSource)) {
|
|
491
|
+
ranges.push(Decoration.mark({
|
|
492
|
+
class: `cm-moss-list-marker ${lineActive ? 'cm-moss-list-marker-active ' : ''}${orderedMarker
|
|
493
|
+
? 'cm-moss-ordered-marker'
|
|
494
|
+
: 'cm-moss-unordered-marker'}`,
|
|
495
|
+
}).range(node.from, node.to));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
// Figure out how far past node.to the mark's trailing
|
|
499
|
+
// space lives. For standard tasks, the `- ` span runs from
|
|
500
|
+
// node.from to the task marker, which is handled by the
|
|
501
|
+
// TaskMarker node below. Custom statuses are handled here
|
|
502
|
+
// because they are not represented by a parser TaskMarker.
|
|
503
|
+
// For bullets / ordered, include a single trailing space
|
|
504
|
+
// if present so text flows from padding-left without a
|
|
505
|
+
// spurious leading space.
|
|
506
|
+
const hasTrailingSpace = doc.sliceString(node.to, node.to + 1) === ' ';
|
|
507
|
+
const markEnd = hasTrailingSpace ? node.to + 1 : node.to;
|
|
508
|
+
if (taskInfo != null && !standardTask) {
|
|
509
|
+
pushReplace(ranges, doc, node.from, line.from + taskInfo.markerTo, {
|
|
510
|
+
widget: new TaskCheckboxWidget(taskInfo.key, taskInfo.raw, taskInfo.status, line.from + taskInfo.markerFrom, false),
|
|
511
|
+
});
|
|
512
|
+
if (taskInfo.separator.length > 0) {
|
|
513
|
+
pushReplace(ranges, doc, line.from + taskInfo.markerTo, line.from + taskInfo.markerTo + taskInfo.separator.length);
|
|
514
|
+
}
|
|
515
|
+
if (taskInfo.status.completed) {
|
|
516
|
+
ranges.push(Decoration.line({ class: 'cm-moss-task-done' }).range(line.from));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
else if (taskInfo != null) {
|
|
520
|
+
// Hide `- ` (ListMark through the space before `[`).
|
|
521
|
+
pushReplace(ranges, doc, node.from, line.from + taskInfo.markerFrom);
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
if (markText === '-' || markText === '*' || markText === '+') {
|
|
525
|
+
// Bullet: substitute with the fixed-width marker
|
|
526
|
+
// widget, swallowing the trailing space so content
|
|
527
|
+
// starts precisely at padding-left.
|
|
528
|
+
pushReplace(ranges, doc, node.from, markEnd, {
|
|
529
|
+
widget: new BulletWidget(listDepth),
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
// Ordered list (or anything else with a non-standard
|
|
534
|
+
// mark text like `1.`, `42.`): keep both the marker and
|
|
535
|
+
// its source separator visible. The separator is part of
|
|
536
|
+
// the editable Markdown geometry and must not be replaced
|
|
537
|
+
// on inactive lines, otherwise activating the line changes
|
|
538
|
+
// the content column by a sub-pixel amount.
|
|
539
|
+
ranges.push(Decoration.mark({
|
|
540
|
+
class: `cm-moss-list-marker ${lineActive ? 'cm-moss-list-marker-active ' : ''}${orderedMarker
|
|
541
|
+
? 'cm-moss-ordered-marker'
|
|
542
|
+
: 'cm-moss-unordered-marker'}`,
|
|
543
|
+
}).range(node.from, node.to));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// Tables are rendered by the separate `mossTables()` block-widget
|
|
548
|
+
// table feature (./features/table) — the whole Table range is
|
|
549
|
+
// replaced with an interactive HTML `<table>`. Any inline
|
|
550
|
+
// decorations on TableHeader/TableRow/TableDelimiter would
|
|
551
|
+
// target ranges that are already hidden behind the replace
|
|
552
|
+
// widget, so they're intentionally absent from this builder.
|
|
553
|
+
if (node.name === 'HorizontalRule') {
|
|
554
|
+
// CommonMark HR: a line of `***`, `---`, or `___` (3+, any
|
|
555
|
+
// spacing between). On inactive lines we hide the characters
|
|
556
|
+
// and render a horizontal-rule widget. On active
|
|
557
|
+
// lines we leave the raw characters visible so the user can
|
|
558
|
+
// edit the marker without it vanishing.
|
|
559
|
+
const line = doc.lineAt(node.from);
|
|
560
|
+
if (!activeLines.has(line.number)) {
|
|
561
|
+
const marker = line.text.trimStart().slice(0, 1);
|
|
562
|
+
const hrMarker = marker === '*' || marker === '_' ? marker : '-';
|
|
563
|
+
ranges.push(Decoration.line({
|
|
564
|
+
class: horizontalRuleClassNames(hrMarker, null),
|
|
565
|
+
}).range(line.from));
|
|
566
|
+
pushHorizontalRuleWidget(ranges, doc, line, hrMarker);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (node.name === 'Image' && node.from < node.to) {
|
|
570
|
+
// Images stay widget-first even when the editor has focus. Their
|
|
571
|
+
// edit button and widget selection provide the editing affordance,
|
|
572
|
+
// while keeping the raw link hidden avoids a source row appearing
|
|
573
|
+
// above the rendered image.
|
|
574
|
+
//
|
|
575
|
+
// Keep the now-empty source `.cm-line` at its default line-height
|
|
576
|
+
// rather than collapsing it via `display: none`: on iOS Safari,
|
|
577
|
+
// toggling a line from its text-measured height to zero mid-scroll
|
|
578
|
+
// shifts every subsequent line and can halt kinetic momentum.
|
|
579
|
+
pushReplace(ranges, doc, node.from, node.to);
|
|
580
|
+
}
|
|
581
|
+
if (node.name === 'TaskMarker' && node.from < node.to) {
|
|
582
|
+
const lineNum = doc.lineAt(node.from).number;
|
|
583
|
+
const line = doc.line(lineNum);
|
|
584
|
+
const taskInfo = parseListTaskMarker(line.text, taskConfig);
|
|
585
|
+
if (!taskInfo)
|
|
586
|
+
return;
|
|
587
|
+
if (shouldRevealTaskSource(view, line.from, taskInfo))
|
|
588
|
+
return;
|
|
589
|
+
// Keep the marker and its separator as separate atomic ranges. If
|
|
590
|
+
// they are combined, ArrowLeft from the first body character skips
|
|
591
|
+
// to the start of the whole widget; after the source is revealed the
|
|
592
|
+
// caret then appears before `[x]` instead of after it. A separate
|
|
593
|
+
// hidden separator lets the caret stop at markerTo, which is the
|
|
594
|
+
// `- [x]| text` editing position, while preserving the fixed alcove
|
|
595
|
+
// width in preview mode.
|
|
596
|
+
const hasTrailingSpace = node.to < doc.length &&
|
|
597
|
+
doc.sliceString(node.to, node.to + 1) === ' ';
|
|
598
|
+
pushReplace(ranges, doc, node.from, node.to, {
|
|
599
|
+
widget: new TaskCheckboxWidget(taskInfo.key, taskInfo.raw, taskInfo.status, node.from, shouldUseNativeTaskCheckbox(taskInfo.key, taskConfig)),
|
|
600
|
+
});
|
|
601
|
+
if (hasTrailingSpace) {
|
|
602
|
+
pushReplace(ranges, doc, node.to, node.to + 1);
|
|
603
|
+
}
|
|
604
|
+
if (taskInfo.status.completed) {
|
|
605
|
+
ranges.push(Decoration.line({ class: 'cm-moss-task-done' }).range(line.from));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
// Supplemental inline marks for the line containing the cursor.
|
|
611
|
+
// CommonMark's flanking rules say that `**foo **` is not emphasis
|
|
612
|
+
// because the closing `**` is preceded by whitespace — lezer
|
|
613
|
+
// agrees and doesn't emit `StrongEmphasis`, so the walk above
|
|
614
|
+
// misses it. Result: while the user types a sentence inside
|
|
615
|
+
// `**...**`, the bold styling flicks on and off every time they
|
|
616
|
+
// hit the spacebar. We patch the UX by scanning the active line
|
|
617
|
+
// for matched delimiter pairs the cursor sits between and
|
|
618
|
+
// emitting the mark ourselves regardless of flanking. Once the
|
|
619
|
+
// cursor leaves, lezer's opinion wins and the visual reverts to
|
|
620
|
+
// what will actually persist when the line is serialized.
|
|
621
|
+
if (view.hasFocus) {
|
|
622
|
+
const head = state.selection.main.head;
|
|
623
|
+
const line = doc.lineAt(head);
|
|
624
|
+
if (activeLines.has(line.number) &&
|
|
625
|
+
!horizontalRuleSourceLines.has(line.number)) {
|
|
626
|
+
supplementMidTypingEmphasis(line.text, line.from, head - line.from, ranges);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return Decoration.set(ranges, true);
|
|
630
|
+
}
|
|
631
|
+
// Delimiters we emit supplemental marks for, longest first so `**`
|
|
632
|
+
// is matched before `*` and `__` before `_`. Backticks don't need
|
|
633
|
+
// this treatment — CommonMark inline code isn't subject to
|
|
634
|
+
// flanking rules. Each entry carries both the content class (what
|
|
635
|
+
// lezer would style via `t.strong` / `t.emphasis` / `t.strikethrough`)
|
|
636
|
+
// and the delimiter class (matches how the EmphasisMark token
|
|
637
|
+
// renders when lezer *does* parse: parent tag's weight / style /
|
|
638
|
+
// decoration plus `processingInstruction`'s faint color).
|
|
639
|
+
const MID_TYPING_DELIMITERS = [
|
|
640
|
+
{ delim: '**', contentCls: 'cm-moss-strong', delimCls: 'cm-moss-strong-mark' },
|
|
641
|
+
{ delim: '__', contentCls: 'cm-moss-strong', delimCls: 'cm-moss-strong-mark' },
|
|
642
|
+
{ delim: '~~', contentCls: 'cm-moss-strike', delimCls: 'cm-moss-strike-mark' },
|
|
643
|
+
{ delim: '==', contentCls: 'cm-moss-highlight', delimCls: 'cm-moss-highlight-mark' },
|
|
644
|
+
{ delim: '*', contentCls: 'cm-moss-em', delimCls: 'cm-moss-em-mark' },
|
|
645
|
+
{ delim: '_', contentCls: 'cm-moss-em', delimCls: 'cm-moss-em-mark' },
|
|
646
|
+
];
|
|
647
|
+
function supplementMidTypingEmphasis(text, lineFrom, localCursor, out) {
|
|
648
|
+
// Track which characters of the line are already "owned" by a
|
|
649
|
+
// matched delimiter pair so a single-char delimiter doesn't
|
|
650
|
+
// accidentally pair halves of two different double-delimiter
|
|
651
|
+
// spans.
|
|
652
|
+
const consumed = new Uint8Array(text.length);
|
|
653
|
+
for (const { delim, contentCls, delimCls } of MID_TYPING_DELIMITERS) {
|
|
654
|
+
const dLen = delim.length;
|
|
655
|
+
// Underscore emphasis (`_`, `__`) doesn't open intra-word under
|
|
656
|
+
// CommonMark's flanking rules — `snake_case_var` is not italic.
|
|
657
|
+
// Without this guard the supplement would flash false italic while
|
|
658
|
+
// the cursor sits between two intra-word underscores (exactly the
|
|
659
|
+
// flicker this feature exists to prevent, inverted). Asterisk
|
|
660
|
+
// delimiters have no such restriction, so only gate underscores.
|
|
661
|
+
const isUnderscore = delim === '_' || delim === '__';
|
|
662
|
+
let searchFrom = 0;
|
|
663
|
+
while (searchFrom < text.length) {
|
|
664
|
+
const open = indexOfUnconsumed(text, delim, searchFrom, consumed);
|
|
665
|
+
if (open < 0)
|
|
666
|
+
break;
|
|
667
|
+
if (isUnderscore && open > 0 && /\w/.test(text[open - 1])) {
|
|
668
|
+
searchFrom = open + dLen;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
const close = indexOfUnconsumed(text, delim, open + dLen, consumed);
|
|
672
|
+
if (close < 0)
|
|
673
|
+
break;
|
|
674
|
+
for (let i = open; i < close + dLen; i++)
|
|
675
|
+
consumed[i] = 1;
|
|
676
|
+
const contentFrom = open + dLen;
|
|
677
|
+
const contentTo = close;
|
|
678
|
+
if (contentFrom < contentTo &&
|
|
679
|
+
localCursor > open &&
|
|
680
|
+
localCursor < close + dLen) {
|
|
681
|
+
out.push(Decoration.mark({ class: contentCls }).range(lineFrom + contentFrom, lineFrom + contentTo));
|
|
682
|
+
// Style the delimiter characters to match how lezer's
|
|
683
|
+
// `EmphasisMark` tokens render when the pattern parses
|
|
684
|
+
// cleanly. Lezer tags `EmphasisMark` with both its parent
|
|
685
|
+
// (`strong` / `emphasis` / `strikethrough`) and
|
|
686
|
+
// `processingInstruction`, so the `**` characters get
|
|
687
|
+
// faint color AND the parent's weight / style / decoration
|
|
688
|
+
// — we mirror all of that here so the delimiters don't
|
|
689
|
+
// flip style / size / color when the cursor moves or a
|
|
690
|
+
// trailing space triggers / untriggers lezer's parse.
|
|
691
|
+
out.push(Decoration.mark({ class: delimCls }).range(lineFrom + open, lineFrom + contentFrom));
|
|
692
|
+
out.push(Decoration.mark({ class: delimCls }).range(lineFrom + contentTo, lineFrom + close + dLen));
|
|
693
|
+
}
|
|
694
|
+
searchFrom = close + dLen;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
function indexOfUnconsumed(text, needle, from, consumed) {
|
|
699
|
+
let i = from;
|
|
700
|
+
while (i <= text.length - needle.length) {
|
|
701
|
+
const found = text.indexOf(needle, i);
|
|
702
|
+
if (found < 0)
|
|
703
|
+
return -1;
|
|
704
|
+
let isConsumed = false;
|
|
705
|
+
for (let k = found; k < found + needle.length; k++) {
|
|
706
|
+
if (consumed[k]) {
|
|
707
|
+
isConsumed = true;
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (!isConsumed)
|
|
712
|
+
return found;
|
|
713
|
+
i = found + 1;
|
|
714
|
+
}
|
|
715
|
+
return -1;
|
|
716
|
+
}
|
|
717
|
+
const inlinePreviewPlugin = ViewPlugin.fromClass(class {
|
|
718
|
+
constructor(view) {
|
|
719
|
+
Object.defineProperty(this, "decorations", {
|
|
720
|
+
enumerable: true,
|
|
721
|
+
configurable: true,
|
|
722
|
+
writable: true,
|
|
723
|
+
value: void 0
|
|
724
|
+
});
|
|
725
|
+
this.decorations = buildInlineDecorations(view);
|
|
726
|
+
}
|
|
727
|
+
update(update) {
|
|
728
|
+
const prevFrozen = update.startState.field(previewFrozenField);
|
|
729
|
+
const nextFrozen = update.state.field(previewFrozenField);
|
|
730
|
+
const justUnfroze = prevFrozen && !nextFrozen;
|
|
731
|
+
const composingTransaction = update.transactions.some((tr) => tr.isUserEvent('input.type.compose'));
|
|
732
|
+
if (update.docChanged &&
|
|
733
|
+
(composingTransaction ||
|
|
734
|
+
update.view.composing)) {
|
|
735
|
+
this.decorations = this.decorations.map(update.changes);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
// A doc change is unambiguous edit intent, so rebuild even while
|
|
739
|
+
// frozen. Returning the stale (pre-edit) decoration set here would
|
|
740
|
+
// hand CM6 ranges whose positions no longer match the document: a
|
|
741
|
+
// hidden `## ` replace can end up spanning the newly-typed text's
|
|
742
|
+
// line break ("Decorations that replace line breaks may not be
|
|
743
|
+
// specified via plugins"), and the stale positions corrupt the
|
|
744
|
+
// heightmap ("No tile at position …" → broken scrollIntoView). The
|
|
745
|
+
// freeze only needs to suppress the *selection*-driven reveal that
|
|
746
|
+
// makes a click jitter; typing should reveal syntax as normal.
|
|
747
|
+
if (nextFrozen && !justUnfroze && !update.docChanged)
|
|
748
|
+
return;
|
|
749
|
+
// Tree-growth effect: background parser advanced past where
|
|
750
|
+
// we last walked. For docs large enough that the initial
|
|
751
|
+
// parse didn't reach the end, later blocks (headings, lists,
|
|
752
|
+
// etc.) render as raw `##`/`**` until this fires.
|
|
753
|
+
let treeGrew = false;
|
|
754
|
+
let forceRefresh = false;
|
|
755
|
+
for (const tr of update.transactions) {
|
|
756
|
+
for (const effect of tr.effects) {
|
|
757
|
+
if (effect.is(refreshInlinePreview)) {
|
|
758
|
+
forceRefresh = true;
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
if (effect.is(treeGrowthEffect)) {
|
|
762
|
+
treeGrew = true;
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
if (treeGrew)
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
// Note: `update.viewportChanged` is intentionally NOT in this
|
|
770
|
+
// list. Scrolling alone must not rebuild decorations — doing
|
|
771
|
+
// so on iOS halts momentum whenever the rebuild produces new
|
|
772
|
+
// decorations for lines at the top of a scroll-up viewport
|
|
773
|
+
// (CM6 anchor conflict with the scroll animation). Walking
|
|
774
|
+
// the whole parsed tree on the remaining triggers means
|
|
775
|
+
// scroll-time cost is zero; the tree walk itself is
|
|
776
|
+
// single-digit ms for typical atoms.
|
|
777
|
+
// A read-only toggle (compartment reconfigure) changes neither
|
|
778
|
+
// doc nor selection nor focus, so detect the facet flip directly
|
|
779
|
+
// — otherwise reading mode wouldn't repaint into / out of the
|
|
780
|
+
// fully-rendered state.
|
|
781
|
+
const readOnlyChanged = update.startState.facet(readOnlyFacet) !==
|
|
782
|
+
update.state.facet(readOnlyFacet);
|
|
783
|
+
if (forceRefresh ||
|
|
784
|
+
justUnfroze ||
|
|
785
|
+
update.docChanged ||
|
|
786
|
+
update.selectionSet ||
|
|
787
|
+
update.focusChanged ||
|
|
788
|
+
treeGrew ||
|
|
789
|
+
readOnlyChanged) {
|
|
790
|
+
this.decorations = buildInlineDecorations(update.view);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}, {
|
|
794
|
+
decorations: (v) => v.decorations,
|
|
795
|
+
eventHandlers: {
|
|
796
|
+
compositionstart(_event, view) {
|
|
797
|
+
view.dispatch({ effects: refreshInlinePreview.of() });
|
|
798
|
+
return false;
|
|
799
|
+
},
|
|
800
|
+
compositionend(_event, view) {
|
|
801
|
+
setTimeout(() => {
|
|
802
|
+
view.dispatch({ effects: refreshInlinePreview.of() });
|
|
803
|
+
}, 0);
|
|
804
|
+
},
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
// CM6's drawSelection layer intentionally sits behind `.cm-content`. That is
|
|
808
|
+
// normally ideal—the rectangle is behind the glyphs—but an opaque fenced-code
|
|
809
|
+
// line background also sits between the layer and the glyphs, hiding the
|
|
810
|
+
// selection completely. Mirror only the selected portions of FencedCode as
|
|
811
|
+
// inline marks so their background paints above the block and below its text.
|
|
812
|
+
// This plugin stays separate from inlinePreviewPlugin because mouse selection
|
|
813
|
+
// must repaint live even while preview decorations are frozen for click-jitter
|
|
814
|
+
// prevention.
|
|
815
|
+
function fencedCodeSelectionDecorations(view) {
|
|
816
|
+
const selections = view.state.selection.ranges.filter((range) => !range.empty);
|
|
817
|
+
if (selections.length === 0)
|
|
818
|
+
return Decoration.none;
|
|
819
|
+
const ranges = [];
|
|
820
|
+
const tree = syntaxTree(view.state);
|
|
821
|
+
for (const selection of selections) {
|
|
822
|
+
// Selection updates can arrive for every pointermove. Restrict the walk to
|
|
823
|
+
// the selected range so dragging within a short code sample stays O(range)
|
|
824
|
+
// instead of walking an entire long document on every event.
|
|
825
|
+
tree.iterate({
|
|
826
|
+
from: selection.from,
|
|
827
|
+
to: selection.to,
|
|
828
|
+
enter(node) {
|
|
829
|
+
if (node.name !== 'FencedCode')
|
|
830
|
+
return;
|
|
831
|
+
const from = Math.max(node.from, selection.from);
|
|
832
|
+
const to = Math.min(node.to, selection.to);
|
|
833
|
+
if (from < to) {
|
|
834
|
+
ranges.push(Decoration.mark({ class: 'cm-moss-fenced-selection' }).range(from, to));
|
|
835
|
+
}
|
|
836
|
+
return false;
|
|
837
|
+
},
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
return Decoration.set(ranges, true);
|
|
841
|
+
}
|
|
842
|
+
const fencedCodeSelectionPlugin = ViewPlugin.fromClass(class {
|
|
843
|
+
constructor(view) {
|
|
844
|
+
Object.defineProperty(this, "decorations", {
|
|
845
|
+
enumerable: true,
|
|
846
|
+
configurable: true,
|
|
847
|
+
writable: true,
|
|
848
|
+
value: void 0
|
|
849
|
+
});
|
|
850
|
+
this.decorations = fencedCodeSelectionDecorations(view);
|
|
851
|
+
}
|
|
852
|
+
update(update) {
|
|
853
|
+
if (update.docChanged || update.selectionSet) {
|
|
854
|
+
this.decorations = fencedCodeSelectionDecorations(update.view);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}, {
|
|
858
|
+
decorations: (plugin) => plugin.decorations,
|
|
859
|
+
});
|
|
860
|
+
function makeLinkClickHandler(onLinkClick) {
|
|
861
|
+
return EditorView.domEventHandlers({
|
|
862
|
+
click: (event, view) => {
|
|
863
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
864
|
+
return false;
|
|
865
|
+
if (event.button !== 0)
|
|
866
|
+
return false;
|
|
867
|
+
// In read-only mode there's no editable link text to protect, so
|
|
868
|
+
// a click anywhere on the link opens it. In edit mode the open
|
|
869
|
+
// affordance stays scoped to the trailing icon hit-zone so the
|
|
870
|
+
// text itself remains clickable-to-edit.
|
|
871
|
+
const linkEl = view.state.facet(readOnlyFacet)
|
|
872
|
+
? linkElementFromEvent(event, view.contentDOM)
|
|
873
|
+
: linkIconHitTarget(event, view.contentDOM);
|
|
874
|
+
if (!linkEl)
|
|
875
|
+
return false;
|
|
876
|
+
const pos = view.posAtDOM(linkEl);
|
|
877
|
+
if (pos < 0)
|
|
878
|
+
return false;
|
|
879
|
+
const tree = syntaxTree(view.state);
|
|
880
|
+
let node = tree.resolveInner(pos, 1);
|
|
881
|
+
let visibleUrl = null;
|
|
882
|
+
while (node && node.name !== 'Link') {
|
|
883
|
+
if (node.name === 'URL')
|
|
884
|
+
visibleUrl = node;
|
|
885
|
+
node = node.parent;
|
|
886
|
+
}
|
|
887
|
+
const urlNode = node
|
|
888
|
+
? linkDestinationUrl(node, view.state.doc)
|
|
889
|
+
: visibleUrl;
|
|
890
|
+
if (!urlNode)
|
|
891
|
+
return false;
|
|
892
|
+
const url = view.state.doc.sliceString(urlNode.from, urlNode.to);
|
|
893
|
+
if (!url)
|
|
894
|
+
return false;
|
|
895
|
+
event.preventDefault();
|
|
896
|
+
event.stopPropagation();
|
|
897
|
+
onLinkClick(url);
|
|
898
|
+
return true;
|
|
899
|
+
},
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Assemble the inline-preview extension set. Call once per editor and
|
|
904
|
+
* include the result in your EditorState `extensions` list. Accepts an
|
|
905
|
+
* `onLinkClick` callback so consumers can route link opens through
|
|
906
|
+
* their platform's external-URL mechanism (Tauri IPC, Capacitor
|
|
907
|
+
* browser, etc.) instead of the default `window.open`.
|
|
908
|
+
*/
|
|
909
|
+
export function inlinePreview(config = {}) {
|
|
910
|
+
const { onLinkClick = defaultOnLinkClick } = config;
|
|
911
|
+
return [
|
|
912
|
+
taskCheckboxConfigFacet.of(config.taskCheckboxes ?? {}),
|
|
913
|
+
previewActivityExtension(),
|
|
914
|
+
inlinePreviewPlugin,
|
|
915
|
+
fencedCodeSelectionPlugin,
|
|
916
|
+
listEditingExtension(),
|
|
917
|
+
listNavigationExtension(),
|
|
918
|
+
treeProgressPlugin,
|
|
919
|
+
makeLinkClickHandler(onLinkClick),
|
|
920
|
+
];
|
|
921
|
+
}
|
|
922
|
+
export const mossInlinePreview = inlinePreview;
|
|
923
|
+
export { dedentListItem, indentListItem, insertTightListItem, orderedListRenumberChanges, renumberOrderedLists, } from './list-editing';
|
|
924
|
+
export { defaultOnLinkClick, setFrozen } from './preview-activity';
|
|
925
|
+
//# sourceMappingURL=inline-preview.js.map
|