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.
Files changed (119) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/LICENSE +21 -0
  3. package/README.md +336 -0
  4. package/dist/collab/index.d.ts +14 -0
  5. package/dist/collab/index.d.ts.map +1 -0
  6. package/dist/collab/index.js +6 -0
  7. package/dist/collab/index.js.map +1 -0
  8. package/dist/core/code-languages.d.ts +3 -0
  9. package/dist/core/code-languages.d.ts.map +1 -0
  10. package/dist/core/code-languages.js +130 -0
  11. package/dist/core/code-languages.js.map +1 -0
  12. package/dist/core/decoration-utils.d.ts +4 -0
  13. package/dist/core/decoration-utils.d.ts.map +1 -0
  14. package/dist/core/decoration-utils.js +25 -0
  15. package/dist/core/decoration-utils.js.map +1 -0
  16. package/dist/core/edit-helpers.d.ts +13 -0
  17. package/dist/core/edit-helpers.d.ts.map +1 -0
  18. package/dist/core/edit-helpers.js +318 -0
  19. package/dist/core/edit-helpers.js.map +1 -0
  20. package/dist/core/icons.d.ts +16 -0
  21. package/dist/core/icons.d.ts.map +1 -0
  22. package/dist/core/icons.js +86 -0
  23. package/dist/core/icons.js.map +1 -0
  24. package/dist/core/inline-preview.d.ts +32 -0
  25. package/dist/core/inline-preview.d.ts.map +1 -0
  26. package/dist/core/inline-preview.js +925 -0
  27. package/dist/core/inline-preview.js.map +1 -0
  28. package/dist/core/list-editing.d.ts +10 -0
  29. package/dist/core/list-editing.d.ts.map +1 -0
  30. package/dist/core/list-editing.js +244 -0
  31. package/dist/core/list-editing.js.map +1 -0
  32. package/dist/core/list-model.d.ts +40 -0
  33. package/dist/core/list-model.d.ts.map +1 -0
  34. package/dist/core/list-model.js +114 -0
  35. package/dist/core/list-model.js.map +1 -0
  36. package/dist/core/list-navigation.d.ts +3 -0
  37. package/dist/core/list-navigation.d.ts.map +1 -0
  38. package/dist/core/list-navigation.js +234 -0
  39. package/dist/core/list-navigation.js.map +1 -0
  40. package/dist/core/markdown-context.d.ts +3 -0
  41. package/dist/core/markdown-context.d.ts.map +1 -0
  42. package/dist/core/markdown-context.js +10 -0
  43. package/dist/core/markdown-context.js.map +1 -0
  44. package/dist/core/preview-activity.d.ts +18 -0
  45. package/dist/core/preview-activity.d.ts.map +1 -0
  46. package/dist/core/preview-activity.js +199 -0
  47. package/dist/core/preview-activity.js.map +1 -0
  48. package/dist/core/preview-widgets.d.ts +65 -0
  49. package/dist/core/preview-widgets.d.ts.map +1 -0
  50. package/dist/core/preview-widgets.js +359 -0
  51. package/dist/core/preview-widgets.js.map +1 -0
  52. package/dist/core/read-only.d.ts +26 -0
  53. package/dist/core/read-only.d.ts.map +1 -0
  54. package/dist/core/read-only.js +49 -0
  55. package/dist/core/read-only.js.map +1 -0
  56. package/dist/core/tree-progress.d.ts +29 -0
  57. package/dist/core/tree-progress.d.ts.map +1 -0
  58. package/dist/core/tree-progress.js +143 -0
  59. package/dist/core/tree-progress.js.map +1 -0
  60. package/dist/editor.d.ts +219 -0
  61. package/dist/editor.d.ts.map +1 -0
  62. package/dist/editor.js +551 -0
  63. package/dist/editor.js.map +1 -0
  64. package/dist/features/callout/index.d.ts +9 -0
  65. package/dist/features/callout/index.d.ts.map +1 -0
  66. package/dist/features/callout/index.js +223 -0
  67. package/dist/features/callout/index.js.map +1 -0
  68. package/dist/features/file-blocks/index.d.ts +12 -0
  69. package/dist/features/file-blocks/index.d.ts.map +1 -0
  70. package/dist/features/file-blocks/index.js +397 -0
  71. package/dist/features/file-blocks/index.js.map +1 -0
  72. package/dist/features/image/index.d.ts +28 -0
  73. package/dist/features/image/index.d.ts.map +1 -0
  74. package/dist/features/image/index.js +699 -0
  75. package/dist/features/image/index.js.map +1 -0
  76. package/dist/features/index.d.ts +17 -0
  77. package/dist/features/index.d.ts.map +1 -0
  78. package/dist/features/index.js +9 -0
  79. package/dist/features/index.js.map +1 -0
  80. package/dist/features/search/index.d.ts +4 -0
  81. package/dist/features/search/index.d.ts.map +1 -0
  82. package/dist/features/search/index.js +131 -0
  83. package/dist/features/search/index.js.map +1 -0
  84. package/dist/features/slash-commands/index.d.ts +52 -0
  85. package/dist/features/slash-commands/index.d.ts.map +1 -0
  86. package/dist/features/slash-commands/index.js +422 -0
  87. package/dist/features/slash-commands/index.js.map +1 -0
  88. package/dist/features/table/index.d.ts +44 -0
  89. package/dist/features/table/index.d.ts.map +1 -0
  90. package/dist/features/table/index.js +1262 -0
  91. package/dist/features/table/index.js.map +1 -0
  92. package/dist/features/upload/index.d.ts +80 -0
  93. package/dist/features/upload/index.d.ts.map +1 -0
  94. package/dist/features/upload/index.js +790 -0
  95. package/dist/features/upload/index.js.map +1 -0
  96. package/dist/features/wiki-links/index.d.ts +27 -0
  97. package/dist/features/wiki-links/index.d.ts.map +1 -0
  98. package/dist/features/wiki-links/index.js +577 -0
  99. package/dist/features/wiki-links/index.js.map +1 -0
  100. package/dist/index.d.ts +17 -0
  101. package/dist/index.d.ts.map +1 -0
  102. package/dist/index.js +12 -0
  103. package/dist/index.js.map +1 -0
  104. package/dist/styles/content.css +247 -0
  105. package/dist/styles/inline-preview.css +2116 -0
  106. package/dist/styles/tokens.css +234 -0
  107. package/dist/syntax/highlight.d.ts +10 -0
  108. package/dist/syntax/highlight.d.ts.map +1 -0
  109. package/dist/syntax/highlight.js +75 -0
  110. package/dist/syntax/highlight.js.map +1 -0
  111. package/dist/syntax/index.d.ts +16 -0
  112. package/dist/syntax/index.d.ts.map +1 -0
  113. package/dist/syntax/index.js +29 -0
  114. package/dist/syntax/index.js.map +1 -0
  115. package/dist/theme/index.d.ts +6 -0
  116. package/dist/theme/index.d.ts.map +1 -0
  117. package/dist/theme/index.js +204 -0
  118. package/dist/theme/index.js.map +1 -0
  119. package/package.json +237 -0
@@ -0,0 +1,1262 @@
1
+ import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
2
+ import { EditorSelection, Facet, Prec, StateField, Transaction, } from '@codemirror/state';
3
+ import { Decoration, EditorView, WidgetType, keymap, } from '@codemirror/view';
4
+ import { ArrowDownFromLine, ArrowLeftFromLine, ArrowRightFromLine, ArrowUpFromLine, MoreHorizontal, Trash2, } from 'lucide-react';
5
+ import { appendMossIcon, mossLucideIcon } from '../../core/icons';
6
+ import { matchHighlight } from '../../syntax/highlight';
7
+ import { treeGrowthEffect, treeProgressPlugin } from '../../core/tree-progress';
8
+ import { readOnlyFacet } from '../../core/read-only';
9
+ const TABLE_MENU_ICON = mossLucideIcon(MoreHorizontal, { size: 16, strokeWidth: 2 });
10
+ const TABLE_MENU_ROW_ABOVE_ICON = mossLucideIcon(ArrowUpFromLine, {
11
+ size: 16,
12
+ strokeWidth: 1.9,
13
+ });
14
+ const TABLE_MENU_ROW_BELOW_ICON = mossLucideIcon(ArrowDownFromLine, {
15
+ size: 16,
16
+ strokeWidth: 1.9,
17
+ });
18
+ const TABLE_MENU_COLUMN_LEFT_ICON = mossLucideIcon(ArrowLeftFromLine, {
19
+ size: 16,
20
+ strokeWidth: 1.9,
21
+ });
22
+ const TABLE_MENU_COLUMN_RIGHT_ICON = mossLucideIcon(ArrowRightFromLine, {
23
+ size: 16,
24
+ strokeWidth: 1.9,
25
+ });
26
+ const TABLE_MENU_DELETE_ICON = mossLucideIcon(Trash2, { size: 16, strokeWidth: 1.9 });
27
+ function collectCells(state, rowNode) {
28
+ // Split the row's raw line on unescaped `|` rather than collecting
29
+ // lezer `TableCell` nodes. lezer emits NO `TableCell` for an empty
30
+ // cell, so a node-based count silently drops blank columns — which
31
+ // is exactly what "Insert column left/right" creates. Counting cells
32
+ // from the pipe-delimited text keeps blank columns (and their
33
+ // positions) intact through the parse → serialize round-trip.
34
+ return splitRowCells(state.doc.lineAt(rowNode.from).text);
35
+ }
36
+ export function splitRowCells(line) {
37
+ let s = line.trim();
38
+ // Strip the optional outer pipes so they don't yield phantom empty
39
+ // leading/trailing cells.
40
+ if (s.startsWith('|'))
41
+ s = s.slice(1);
42
+ if (s.endsWith('|'))
43
+ s = s.slice(0, -1);
44
+ const cells = [];
45
+ let buf = '';
46
+ for (let i = 0; i < s.length; i++) {
47
+ const ch = s[i];
48
+ // A backslash escapes the next char (e.g. `\|` is a literal pipe in
49
+ // a GFM cell) — keep both and don't treat the pipe as a separator.
50
+ if (ch === '\\' && i + 1 < s.length) {
51
+ buf += ch + s[i + 1];
52
+ i++;
53
+ continue;
54
+ }
55
+ if (ch === '|') {
56
+ cells.push(buf.trim());
57
+ buf = '';
58
+ continue;
59
+ }
60
+ buf += ch;
61
+ }
62
+ cells.push(buf.trim());
63
+ return cells;
64
+ }
65
+ function parseTable(state, tableNode) {
66
+ const header = [];
67
+ const rows = [];
68
+ const cursor = tableNode.cursor();
69
+ if (!cursor.firstChild())
70
+ return null;
71
+ do {
72
+ if (cursor.name === 'TableHeader') {
73
+ header.push(...collectCells(state, cursor.node));
74
+ }
75
+ else if (cursor.name === 'TableRow') {
76
+ rows.push(collectCells(state, cursor.node));
77
+ }
78
+ // TableDelimiter (per-row `|` and whole-line `|---|---|`) is ignored.
79
+ } while (cursor.nextSibling());
80
+ if (header.length === 0)
81
+ return null;
82
+ return { header, rows };
83
+ }
84
+ // Escape cell content so it can't break the row's GFM structure: an
85
+ // unescaped `|` would split the cell into two columns, and a stray
86
+ // newline would terminate the table. A pipe that's already escaped
87
+ // (`\|` — e.g. round-tripping content the parser handed us) is left
88
+ // alone so serialize is idempotent.
89
+ export function escapeCell(text) {
90
+ return text.replace(/\r?\n/g, ' ').replace(/(?<!\\)\|/g, '\\|');
91
+ }
92
+ export function serializeTable(model) {
93
+ const columnCount = model.header.length;
94
+ const lines = [];
95
+ lines.push('| ' + model.header.map(escapeCell).join(' | ') + ' |');
96
+ lines.push('| ' + model.header.map(() => '---').join(' | ') + ' |');
97
+ for (const row of model.rows) {
98
+ const padded = [];
99
+ for (let c = 0; c < columnCount; c++)
100
+ padded.push(escapeCell(row[c] ?? ''));
101
+ lines.push('| ' + padded.join(' | ') + ' |');
102
+ }
103
+ return lines.join('\n');
104
+ }
105
+ function readModelFromDom(wrap) {
106
+ const header = Array.from(wrap.querySelectorAll('thead th')).map(readCellSource);
107
+ const rows = Array.from(wrap.querySelectorAll('tbody tr')).map((tr) => Array.from(tr.querySelectorAll('td')).map(readCellSource));
108
+ return { header, rows };
109
+ }
110
+ // A cell's raw markdown lives in `dataset.raw` — the source of truth
111
+ // that `readModelFromDom` reads when serializing the table back to
112
+ // markdown. The inner `.cm-moss-table-cell-source` element displays
113
+ // an escape-stripped view of that raw text so RSS-ingested cells
114
+ // don't show `\.` / `\(` / `\-` style literal backslashes in the
115
+ // reader; the input handler pulls innerText back to dataset.raw on
116
+ // every keystroke (any escapes the user types get preserved there,
117
+ // but won't round-trip back through stripEscapes on re-render —
118
+ // acceptable tradeoff because the escapes are typically ingestion
119
+ // artifacts users don't want to preserve anyway).
120
+ function readCellSource(cell) {
121
+ return (cell.dataset.raw ?? '').trim();
122
+ }
123
+ function getCellSource(cell) {
124
+ return cell.querySelector('.cm-moss-table-cell-source');
125
+ }
126
+ export function parseCellInline(raw) {
127
+ const tokens = [];
128
+ let textBuf = '';
129
+ let i = 0;
130
+ const flushText = () => {
131
+ if (textBuf.length) {
132
+ tokens.push({ type: 'text', text: textBuf });
133
+ textBuf = '';
134
+ }
135
+ };
136
+ while (i < raw.length) {
137
+ // CommonMark backslash escape — the following char is emitted
138
+ // literally and can't open/close a mark. Pair is consumed.
139
+ if (raw[i] === '\\' && i + 1 < raw.length && /[!-/:-@[-`{-~]/.test(raw[i + 1])) {
140
+ textBuf += raw[i + 1];
141
+ i += 2;
142
+ continue;
143
+ }
144
+ const match = matchCellMarkAt(raw, i);
145
+ if (match) {
146
+ flushText();
147
+ tokens.push(match.token);
148
+ i = match.end;
149
+ continue;
150
+ }
151
+ textBuf += raw[i];
152
+ i++;
153
+ }
154
+ flushText();
155
+ return tokens;
156
+ }
157
+ function matchCellMarkAt(raw, from) {
158
+ const rest = raw.slice(from);
159
+ // Bold with `**` or `__` — greedy on the outside, lazy on the
160
+ // content so we catch the nearest closer.
161
+ let m = rest.match(/^\*\*([\s\S]+?)\*\*/);
162
+ if (m) {
163
+ return {
164
+ token: { type: 'strong', delim: '**', children: parseCellInline(m[1]) },
165
+ end: from + m[0].length,
166
+ };
167
+ }
168
+ m = rest.match(/^__([\s\S]+?)__/);
169
+ if (m) {
170
+ return {
171
+ token: { type: 'strong', delim: '__', children: parseCellInline(m[1]) },
172
+ end: from + m[0].length,
173
+ };
174
+ }
175
+ // Strikethrough.
176
+ m = rest.match(/^~~([\s\S]+?)~~/);
177
+ if (m) {
178
+ return {
179
+ token: { type: 'strike', children: parseCellInline(m[1]) },
180
+ end: from + m[0].length,
181
+ };
182
+ }
183
+ // Highlight. Keep exact-delimiter and whitespace rules aligned with
184
+ // the main Lezer extension.
185
+ const highlight = matchHighlight(raw, from);
186
+ if (highlight) {
187
+ return {
188
+ token: {
189
+ type: 'highlight',
190
+ children: parseCellInline(raw.slice(highlight.contentFrom, highlight.contentTo)),
191
+ },
192
+ end: highlight.end,
193
+ };
194
+ }
195
+ // Link `[text](url)`. Reject empty text / url via `+` quantifiers.
196
+ // `]` and `)` can't appear unescaped inside their respective fields.
197
+ m = rest.match(/^\[([^\]\n]+)\]\(([^\s)"'\n]+)\)/);
198
+ if (m) {
199
+ return {
200
+ token: {
201
+ type: 'link',
202
+ textChildren: parseCellInline(m[1]),
203
+ url: m[2],
204
+ },
205
+ end: from + m[0].length,
206
+ };
207
+ }
208
+ // Italic with `*`. Reject a leading `*` (that would have matched
209
+ // the bold regex above; this guards against pathological inputs
210
+ // like `***` that slip through).
211
+ m = rest.match(/^\*([^*\n]+?)\*/);
212
+ if (m) {
213
+ return {
214
+ token: { type: 'em', delim: '*', children: parseCellInline(m[1]) },
215
+ end: from + m[0].length,
216
+ };
217
+ }
218
+ // Italic with `_`. Avoid triggering inside words like `snake_case`
219
+ // by requiring the char before `_` to not be a word character.
220
+ // (Fallback to true when `_` is at start-of-input.)
221
+ const prev = from > 0 ? raw[from - 1] : '';
222
+ if (!/\w/.test(prev)) {
223
+ m = rest.match(/^_([^_\n]+?)_/);
224
+ if (m) {
225
+ return {
226
+ token: { type: 'em', delim: '_', children: parseCellInline(m[1]) },
227
+ end: from + m[0].length,
228
+ };
229
+ }
230
+ }
231
+ return null;
232
+ }
233
+ // Build the decorated DOM for a cell's source. The parser strips
234
+ // CommonMark backslash escapes inline (so `\*` emits a literal `*`
235
+ // text node); the fragment's `textContent` equals the escape-stripped
236
+ // raw. The cell's input handler reads `textContent` to update
237
+ // `dataset.raw` — round-trip is one-way for escapes (same as the
238
+ // pre-markdown-in-cells behavior), but fully preserves every inline
239
+ // mark delimiter because those live in `display: none` spans inside
240
+ // the DOM rather than being derived on serialize.
241
+ function buildCellSourceDom(raw) {
242
+ const frag = document.createDocumentFragment();
243
+ const tokens = parseCellInline(raw);
244
+ for (const tok of tokens)
245
+ frag.appendChild(renderCellToken(tok));
246
+ return frag;
247
+ }
248
+ function renderCellToken(tok) {
249
+ if (tok.type === 'text') {
250
+ return document.createTextNode(tok.text);
251
+ }
252
+ if (tok.type === 'strong') {
253
+ const wrap = document.createElement('span');
254
+ wrap.className = 'cm-moss-strong-wrap';
255
+ wrap.appendChild(makeCellMark(tok.delim));
256
+ const inner = document.createElement('span');
257
+ inner.className = 'cm-moss-strong';
258
+ inner.appendChild(renderTokensTo(tok.children));
259
+ wrap.appendChild(inner);
260
+ wrap.appendChild(makeCellMark(tok.delim));
261
+ return wrap;
262
+ }
263
+ if (tok.type === 'em') {
264
+ const wrap = document.createElement('span');
265
+ wrap.className = 'cm-moss-em-wrap';
266
+ wrap.appendChild(makeCellMark(tok.delim));
267
+ const inner = document.createElement('span');
268
+ inner.className = 'cm-moss-em';
269
+ inner.appendChild(renderTokensTo(tok.children));
270
+ wrap.appendChild(inner);
271
+ wrap.appendChild(makeCellMark(tok.delim));
272
+ return wrap;
273
+ }
274
+ if (tok.type === 'strike') {
275
+ const wrap = document.createElement('span');
276
+ wrap.className = 'cm-moss-strike-wrap';
277
+ wrap.appendChild(makeCellMark('~~'));
278
+ const inner = document.createElement('span');
279
+ inner.className = 'cm-moss-strike';
280
+ inner.appendChild(renderTokensTo(tok.children));
281
+ wrap.appendChild(inner);
282
+ wrap.appendChild(makeCellMark('~~'));
283
+ return wrap;
284
+ }
285
+ if (tok.type === 'highlight') {
286
+ const wrap = document.createElement('span');
287
+ wrap.className = 'cm-moss-highlight-wrap';
288
+ wrap.appendChild(makeCellMark('=='));
289
+ const inner = document.createElement('span');
290
+ inner.className = 'cm-moss-highlight';
291
+ inner.appendChild(renderTokensTo(tok.children));
292
+ wrap.appendChild(inner);
293
+ wrap.appendChild(makeCellMark('=='));
294
+ return wrap;
295
+ }
296
+ // Link. Shape mirrors the outer-editor markup: `.cm-moss-link` on
297
+ // the visible text (picks up link color + external-link icon via
298
+ // `::after`), faint marks for `[`, `]`, `(`, URL, `)`, and highlight
299
+ // uses the same decorated wrapper pattern as the main editor.
300
+ // `data-url`
301
+ // lets the cell-source click handler open the right URL without
302
+ // re-parsing.
303
+ const wrap = document.createElement('span');
304
+ wrap.className = 'cm-moss-link-wrap';
305
+ wrap.dataset.url = tok.url;
306
+ wrap.appendChild(makeCellMark('['));
307
+ const inner = document.createElement('span');
308
+ inner.className = 'cm-moss-link';
309
+ inner.appendChild(renderTokensTo(tok.textChildren));
310
+ wrap.appendChild(inner);
311
+ wrap.appendChild(makeCellMark(']'));
312
+ wrap.appendChild(makeCellMark('('));
313
+ const urlMark = makeCellMark(tok.url);
314
+ urlMark.classList.add('cm-moss-link-url');
315
+ wrap.appendChild(urlMark);
316
+ wrap.appendChild(makeCellMark(')'));
317
+ // Real, clickable external-link icon. A CSS `::after` pseudo can't
318
+ // receive a click (no event target), so the icon is its own
319
+ // non-editable element; the source's delegated click handler opens
320
+ // the URL. `contenteditable=false` keeps it out of caret navigation
321
+ // and out of the cell's serialized text.
322
+ const icon = document.createElement('span');
323
+ icon.className = 'cm-moss-link-icon';
324
+ icon.contentEditable = 'false';
325
+ icon.setAttribute('aria-hidden', 'true');
326
+ wrap.appendChild(icon);
327
+ return wrap;
328
+ }
329
+ function renderTokensTo(tokens) {
330
+ const frag = document.createDocumentFragment();
331
+ for (const tok of tokens)
332
+ frag.appendChild(renderCellToken(tok));
333
+ return frag;
334
+ }
335
+ function makeCellMark(text) {
336
+ const el = document.createElement('span');
337
+ el.className = 'cm-moss-mark';
338
+ el.textContent = text;
339
+ return el;
340
+ }
341
+ // Render a cell source element in its decorated form. Safe to call
342
+ // multiple times — overwrites whatever was there.
343
+ //
344
+ // Marks start collapsed: all `.cm-moss-mark` descendants (delimiters
345
+ // like `**`, `_`, `~~`, `[`, `]`, `(`, `)`, and URL text) are hidden
346
+ // via CSS by default. When the caret enters a mark wrap, JS adds an
347
+ // `active` class that reveals that wrap's delimiters — mirroring the
348
+ // outer editor's cursor-inside-link unfold for every inline mark.
349
+ function renderCellSourceDecorated(source) {
350
+ const raw = source.parentElement?.dataset.raw ?? '';
351
+ source.replaceChildren(buildCellSourceDom(raw));
352
+ }
353
+ // Caret utilities — encode positions as character offsets within the
354
+ // element's textContent so we can survive the full-DOM re-render that
355
+ // follows every keystroke (new marks need to decorate immediately;
356
+ // the whole tree rebuilds from scratch).
357
+ function getCaretCharOffset(container) {
358
+ const selection = container.ownerDocument?.defaultView?.getSelection();
359
+ if (!selection || selection.rangeCount === 0)
360
+ return null;
361
+ const range = selection.getRangeAt(0);
362
+ if (!container.contains(range.startContainer))
363
+ return null;
364
+ const pre = range.cloneRange();
365
+ pre.selectNodeContents(container);
366
+ pre.setEnd(range.startContainer, range.startOffset);
367
+ return pre.toString().length;
368
+ }
369
+ function setCaretCharOffset(container, offset) {
370
+ const doc = container.ownerDocument;
371
+ if (!doc)
372
+ return;
373
+ const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT);
374
+ let remaining = offset;
375
+ let target = null;
376
+ let targetOffset = 0;
377
+ let node = walker.nextNode();
378
+ while (node) {
379
+ const len = node.data.length;
380
+ if (remaining <= len) {
381
+ target = node;
382
+ targetOffset = remaining;
383
+ break;
384
+ }
385
+ remaining -= len;
386
+ node = walker.nextNode();
387
+ }
388
+ const selection = doc.defaultView?.getSelection();
389
+ if (!selection)
390
+ return;
391
+ const range = doc.createRange();
392
+ if (target) {
393
+ range.setStart(target, targetOffset);
394
+ }
395
+ else {
396
+ // Offset past the end — place caret at the container's end.
397
+ range.selectNodeContents(container);
398
+ range.collapse(false);
399
+ }
400
+ range.collapse(true);
401
+ selection.removeAllRanges();
402
+ selection.addRange(range);
403
+ }
404
+ const MARK_WRAP_CLASSES = [
405
+ 'cm-moss-strong-wrap',
406
+ 'cm-moss-em-wrap',
407
+ 'cm-moss-strike-wrap',
408
+ 'cm-moss-highlight-wrap',
409
+ 'cm-moss-link-wrap',
410
+ ];
411
+ function isMarkWrap(el) {
412
+ for (const c of MARK_WRAP_CLASSES)
413
+ if (el.classList.contains(c))
414
+ return true;
415
+ return false;
416
+ }
417
+ // Reveal the delimiters of whatever mark wrap(s) contain the caret,
418
+ // and collapse every other wrap in this cell. Walks from the caret
419
+ // anchor up to the source element, flagging every ancestor mark wrap
420
+ // so nested marks (bold-containing-italic) all reveal together — the
421
+ // user sees the full structure around their caret.
422
+ function updateActiveMarkForSource(source) {
423
+ // Clear existing `active` classes within this cell only — other
424
+ // cells track their own state via their own focus lifecycle.
425
+ for (const el of source.querySelectorAll('.active')) {
426
+ el.classList.remove('active');
427
+ }
428
+ const doc = source.ownerDocument;
429
+ if (!doc)
430
+ return;
431
+ const selection = doc.defaultView?.getSelection();
432
+ if (!selection || selection.rangeCount === 0)
433
+ return;
434
+ const anchor = selection.anchorNode;
435
+ if (!anchor || !source.contains(anchor))
436
+ return;
437
+ let node = anchor;
438
+ while (node && node !== source) {
439
+ if (node instanceof Element && isMarkWrap(node)) {
440
+ node.classList.add('active');
441
+ }
442
+ node = node.parentNode;
443
+ }
444
+ }
445
+ function clearActiveMarksInSource(source) {
446
+ for (const el of source.querySelectorAll('.active')) {
447
+ el.classList.remove('active');
448
+ }
449
+ }
450
+ // Scan raw markdown for `![alt](url)` occurrences. The regex bans `]`
451
+ // inside the alt and whitespace inside the URL so we fail closed on
452
+ // malformed sources rather than embedding a broken preview.
453
+ function extractCellImages(text) {
454
+ const imgs = [];
455
+ const re = /!\[([^\]]*)\]\(([^\s)"']+)(?:\s+["'][^)]*["'])?\)/g;
456
+ for (const match of text.matchAll(re)) {
457
+ imgs.push({ alt: match[1] || '', src: match[2] });
458
+ }
459
+ return imgs;
460
+ }
461
+ // Refresh (or remove) the image-preview strip that sits below the
462
+ // source line. Mirrors how images render outside tables: the
463
+ // `![alt](url)` markdown is the source of truth, but on an inactive
464
+ // cell (no focus inside) the raw source hides and only the rendered
465
+ // image remains visible. `data-has-image` flips on for that CSS hook.
466
+ function refreshCellPreview(cell) {
467
+ const existing = cell.querySelector('.cm-moss-table-cell-preview');
468
+ if (existing)
469
+ existing.remove();
470
+ const text = cell.dataset.raw ?? '';
471
+ const imgs = extractCellImages(text);
472
+ if (imgs.length === 0) {
473
+ delete cell.dataset.hasImage;
474
+ return;
475
+ }
476
+ cell.dataset.hasImage = 'true';
477
+ const preview = document.createElement('div');
478
+ preview.className = 'cm-moss-table-cell-preview';
479
+ // Preview is visual only — no caret, no contenteditable scope.
480
+ // Keeping it out of contenteditable also means clicking the image
481
+ // won't create a phantom caret position at the preview boundary.
482
+ preview.contentEditable = 'false';
483
+ for (const { src, alt } of imgs) {
484
+ const img = document.createElement('img');
485
+ img.src = src;
486
+ img.alt = alt;
487
+ img.loading = 'lazy';
488
+ img.className = 'cm-moss-table-cell-image';
489
+ // Clicking the image puts the caret in the source text so the
490
+ // user can edit the underlying markdown — same affordance as
491
+ // clicking a block-level image outside a table.
492
+ img.addEventListener('pointerdown', (event) => {
493
+ event.preventDefault();
494
+ event.stopPropagation();
495
+ const source = getCellSource(cell);
496
+ if (!source)
497
+ return;
498
+ source.focus();
499
+ placeCaretAtEnd(source);
500
+ });
501
+ preview.appendChild(img);
502
+ }
503
+ cell.appendChild(preview);
504
+ }
505
+ // ---- position resolution --------------------------------------------
506
+ // posAtDOM on a block-replace widget returns the start of the replaced
507
+ // range. Walk the tree from there to find the enclosing Table node so
508
+ // our dispatch targets the current range (positions shift as the user
509
+ // types — we can't rely on the from/to captured at widget creation).
510
+ function findCurrentTableRange(view, dom) {
511
+ const pos = view.posAtDOM(dom);
512
+ if (pos < 0)
513
+ return null;
514
+ const tree = syntaxTree(view.state);
515
+ let node = tree.resolveInner(pos, 1);
516
+ while (node && node.name !== 'Table')
517
+ node = node.parent;
518
+ if (node)
519
+ return { from: node.from, to: node.to };
520
+ // Fallback: scan for the nearest Table node containing or starting
521
+ // at pos. Rare — resolveInner + parent walk handles almost every
522
+ // case — but guards against parser edge cases.
523
+ let found = null;
524
+ tree.iterate({
525
+ enter: (n) => {
526
+ if (n.name !== 'Table')
527
+ return;
528
+ if (n.from <= pos && n.to >= pos) {
529
+ found = n.node;
530
+ return false;
531
+ }
532
+ },
533
+ });
534
+ if (found)
535
+ return { from: found.from, to: found.to };
536
+ return null;
537
+ }
538
+ // ---- DOM helpers ----------------------------------------------------
539
+ function placeCaretAtEnd(el) {
540
+ const range = document.createRange();
541
+ range.selectNodeContents(el);
542
+ range.collapse(false);
543
+ const sel = window.getSelection();
544
+ if (!sel)
545
+ return;
546
+ sel.removeAllRanges();
547
+ sel.addRange(range);
548
+ }
549
+ function getAllCells(wrap) {
550
+ return Array.from(wrap.querySelectorAll('th, td'));
551
+ }
552
+ // ---- widget ---------------------------------------------------------
553
+ class TableWidget extends WidgetType {
554
+ constructor(model, readOnly) {
555
+ super();
556
+ Object.defineProperty(this, "model", {
557
+ enumerable: true,
558
+ configurable: true,
559
+ writable: true,
560
+ value: model
561
+ });
562
+ Object.defineProperty(this, "readOnly", {
563
+ enumerable: true,
564
+ configurable: true,
565
+ writable: true,
566
+ value: readOnly
567
+ });
568
+ }
569
+ // Structure-only equality. Typing in a cell produces a new
570
+ // TableWidget with the same dimensions but different cell contents.
571
+ // Returning true here means CM6 keeps the existing DOM instead of
572
+ // calling `toDOM` again — which is what lets the caret survive
573
+ // across the per-keystroke dispatch cycle.
574
+ //
575
+ // `readOnly` is part of the identity: cells are built editable or
576
+ // inert at `toDOM` time, so a reading-mode toggle must force a fresh
577
+ // DOM rather than reusing the stale (editable) one.
578
+ eq(other) {
579
+ if (other.readOnly !== this.readOnly)
580
+ return false;
581
+ if (other.model.header.length !== this.model.header.length)
582
+ return false;
583
+ if (other.model.rows.length !== this.model.rows.length)
584
+ return false;
585
+ return true;
586
+ }
587
+ toDOM(view) {
588
+ const wrap = document.createElement('div');
589
+ wrap.className = 'cm-moss-table';
590
+ const table = document.createElement('table');
591
+ wrap.appendChild(table);
592
+ const thead = document.createElement('thead');
593
+ const headerRow = document.createElement('tr');
594
+ for (const text of this.model.header) {
595
+ headerRow.appendChild(makeCell('th', text, view));
596
+ }
597
+ thead.appendChild(headerRow);
598
+ table.appendChild(thead);
599
+ const tbody = document.createElement('tbody');
600
+ const colCount = this.model.header.length;
601
+ for (const row of this.model.rows) {
602
+ const tr = document.createElement('tr');
603
+ for (let c = 0; c < colCount; c++) {
604
+ tr.appendChild(makeCell('td', row[c] ?? '', view));
605
+ }
606
+ tbody.appendChild(tr);
607
+ }
608
+ table.appendChild(tbody);
609
+ return wrap;
610
+ }
611
+ // All cell interactions are handled by the listeners we attach in
612
+ // `makeCell`; tell CM6 to stay out of events within the widget so
613
+ // its own selection/click logic doesn't compete with contenteditable.
614
+ ignoreEvent() {
615
+ return true;
616
+ }
617
+ }
618
+ function makeCell(tag, text, view) {
619
+ const cell = document.createElement(tag);
620
+ cell.dataset.raw = text;
621
+ // The cell itself is not contenteditable — only the inner source
622
+ // element is. This keeps the image preview strictly visual (no
623
+ // phantom caret positions around images) while the source text
624
+ // stays in a dedicated editable box above it.
625
+ const readOnly = view.state.facet(readOnlyFacet);
626
+ const source = document.createElement('div');
627
+ source.className = 'cm-moss-table-cell-source';
628
+ // Read-only cells still show their decorated source but accept no
629
+ // edits — the editing listeners below are skipped entirely.
630
+ source.setAttribute('contenteditable', readOnly ? 'false' : 'true');
631
+ source.spellcheck = !readOnly;
632
+ // Decorated DOM on mount. Delimiters (`.cm-moss-mark`) are
633
+ // `display: none` by default — the caret can't navigate into them,
634
+ // the reader sees a clean rendered view. When the caret enters a
635
+ // mark wrap, JS adds `.active` to reveal that wrap's delimiters —
636
+ // matching the outer-editor cursor-inside-link unfold, applied
637
+ // uniformly to every inline mark inside cells.
638
+ cell.appendChild(source);
639
+ renderCellSourceDecorated(source);
640
+ // All write paths (typing, paste, IME, Tab/Enter navigation, the
641
+ // cell actions menu, focus-routing) live in `attachCellEditing` and are
642
+ // wired only when the cell is editable. Read-only cells keep just the
643
+ // link-open handlers below.
644
+ if (!readOnly)
645
+ attachCellEditing(view, cell, source);
646
+ // Link open. The external-link icon is rendered as a real
647
+ // `.cm-moss-link-icon` element (see `renderCellToken`), not a CSS
648
+ // `::after` pseudo — a pseudo-element has no event target, so clicking
649
+ // its painted region dispatched no pointer event and the link never
650
+ // opened. We open on `click` (a proper popup-activation gesture, so
651
+ // `window.open` isn't blocked) and block the caret on `pointerdown`.
652
+ //
653
+ // In read-only mode there's no editable link text to protect, so the
654
+ // whole link (`.cm-moss-link-wrap`) is the open target — matching
655
+ // the outer editor. In edit mode the open stays scoped to the
656
+ // trailing icon so the text itself remains clickable-to-edit.
657
+ const openTargetFromEvent = (event) => {
658
+ const target = event.target;
659
+ if (!(target instanceof Element))
660
+ return null;
661
+ const selector = readOnly ? '.cm-moss-link-wrap' : '.cm-moss-link-icon';
662
+ return target.closest(selector);
663
+ };
664
+ source.addEventListener('pointerdown', (event) => {
665
+ if (event.button !== 0)
666
+ return;
667
+ // Block focus / caret placement when pressing the open target; the
668
+ // open happens on the following `click`.
669
+ if (openTargetFromEvent(event))
670
+ event.preventDefault();
671
+ });
672
+ source.addEventListener('click', (event) => {
673
+ const hit = openTargetFromEvent(event);
674
+ if (!hit)
675
+ return;
676
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
677
+ return;
678
+ const url = hit.closest('.cm-moss-link-wrap')?.dataset.url;
679
+ if (!url)
680
+ return;
681
+ event.preventDefault();
682
+ event.stopPropagation();
683
+ view.state.facet(tableLinkClickFacet)(url);
684
+ });
685
+ refreshCellPreview(cell);
686
+ if (!readOnly) {
687
+ const menuTrigger = document.createElement('button');
688
+ menuTrigger.type = 'button';
689
+ menuTrigger.className = 'cm-moss-table-menu-trigger';
690
+ appendMossIcon(menuTrigger, TABLE_MENU_ICON);
691
+ menuTrigger.setAttribute('aria-label', 'Table actions');
692
+ menuTrigger.title = 'Table actions';
693
+ menuTrigger.addEventListener('pointerdown', (event) => {
694
+ event.preventDefault();
695
+ event.stopPropagation();
696
+ });
697
+ menuTrigger.addEventListener('click', (event) => {
698
+ event.preventDefault();
699
+ event.stopPropagation();
700
+ const rect = menuTrigger.getBoundingClientRect();
701
+ openCellMenu(view, cell, rect.right, rect.bottom + 4);
702
+ });
703
+ cell.appendChild(menuTrigger);
704
+ }
705
+ return cell;
706
+ }
707
+ // Wire every write path for an editable cell: typing / paste / IME
708
+ // commits, caret-driven mark reveal, Tab/Enter cell navigation, the
709
+ // cell actions menu, and focus-routing for clicks that land
710
+ // outside the inner source element. Skipped wholesale for read-only
711
+ // cells, which keep only the link-open handlers in `makeCell`.
712
+ function attachCellEditing(view, cell, source) {
713
+ // Commit the cell's current DOM text to `dataset.raw`, re-render its
714
+ // decorated form (so marks the user just typed — e.g. a new `**` pair
715
+ // — decorate immediately), restore the caret across that rebuild, and
716
+ // push the change into the document.
717
+ const commit = () => {
718
+ // textContent (not innerText) so `display: none` delimiters inside
719
+ // mark wraps are still captured — otherwise a cell containing
720
+ // `**bold**` would serialize to just `bold` on every keystroke.
721
+ const raw = (source.textContent ?? '').replace(/\s+/g, ' ').trim();
722
+ cell.dataset.raw = raw;
723
+ const offset = getCaretCharOffset(source);
724
+ renderCellSourceDecorated(source);
725
+ if (offset != null)
726
+ setCaretCharOffset(source, offset);
727
+ updateActiveMarkForSource(source);
728
+ refreshCellPreview(cell);
729
+ dispatchModelFromDom(view, cell);
730
+ };
731
+ // IME / dead-key composition. `commit` rebuilds the contenteditable
732
+ // DOM, and doing that mid-composition cancels the composition session
733
+ // — dropping CJK input, accented characters, and dictation. Suppress
734
+ // every update while composing and run one commit when it ends.
735
+ let composing = false;
736
+ source.addEventListener('compositionstart', () => {
737
+ composing = true;
738
+ });
739
+ source.addEventListener('compositionend', () => {
740
+ composing = false;
741
+ commit();
742
+ });
743
+ source.addEventListener('input', (event) => {
744
+ if (composing || event.isComposing)
745
+ return;
746
+ commit();
747
+ });
748
+ // Paste: drop clipboard content in as a single line of plain text.
749
+ // Without this, pasted rich HTML, newlines, or pipes land in the cell
750
+ // verbatim; newlines and `|` corrupt the row. We flatten whitespace
751
+ // and strip markup here, and `escapeCell` neutralizes any literal `|`
752
+ // on serialize.
753
+ source.addEventListener('paste', (event) => {
754
+ event.preventDefault();
755
+ const text = (event.clipboardData?.getData('text/plain') ?? '')
756
+ .replace(/\s+/g, ' ')
757
+ .trim();
758
+ const sel = source.ownerDocument.defaultView?.getSelection();
759
+ if (!sel || sel.rangeCount === 0)
760
+ return;
761
+ const range = sel.getRangeAt(0);
762
+ range.deleteContents();
763
+ range.insertNode(document.createTextNode(text));
764
+ range.collapse(false);
765
+ sel.removeAllRanges();
766
+ sel.addRange(range);
767
+ commit();
768
+ });
769
+ // Caret-position listeners. `focus` / `mouseup` / `keyup` cover the
770
+ // three ways the caret can land in a new mark without firing an
771
+ // input event (click-to-place, arrow-key nav, tab-into-cell). The
772
+ // update is idempotent — redundant calls cost nothing.
773
+ source.addEventListener('focus', () => updateActiveMarkForSource(source));
774
+ source.addEventListener('mouseup', () => updateActiveMarkForSource(source));
775
+ source.addEventListener('keyup', () => updateActiveMarkForSource(source));
776
+ // Blur: collapse every active wrap so the reader-resting state
777
+ // hides all delimiters.
778
+ source.addEventListener('blur', () => clearActiveMarksInSource(source));
779
+ source.addEventListener('keydown', (event) => {
780
+ // Enter mirrors Tab — advance to the next cell (appending a row past
781
+ // the last one) instead of inserting a line break a single-line cell
782
+ // can't represent. Shift reverses direction for both.
783
+ if (event.key === 'Tab' || event.key === 'Enter') {
784
+ event.preventDefault();
785
+ event.stopPropagation();
786
+ moveCellFocus(view, cell, event.shiftKey ? -1 : 1);
787
+ }
788
+ });
789
+ // When the cell has an image and the source is visually hidden,
790
+ // clicks land on the cell/image/empty space but not on the source
791
+ // itself. Route every pointerdown inside the cell to a focus on
792
+ // the source so the user can edit regardless of where they tapped.
793
+ // The image's own pointerdown handler already does this, but
794
+ // covers only image hits — this covers empty padding and the
795
+ // space between/around images.
796
+ cell.addEventListener('pointerdown', (event) => {
797
+ // A click on the editable source — including its inner mark spans
798
+ // and text — must keep the browser's native caret placement. Forcing
799
+ // focus-at-end here would yank the caret to the end of the cell
800
+ // whenever the user clicks a styled run (bold/italic/link). Only
801
+ // intercept clicks that land OUTSIDE the source (cell padding, the
802
+ // image preview, the cell box itself) to route focus into it.
803
+ const target = event.target;
804
+ if (target instanceof Element &&
805
+ target.closest('.cm-moss-table-menu-trigger')) {
806
+ return;
807
+ }
808
+ if (target instanceof Node && source.contains(target))
809
+ return;
810
+ event.preventDefault();
811
+ source.focus();
812
+ placeCaretAtEnd(source);
813
+ });
814
+ }
815
+ // ---- table actions menu -------------------------------------------
816
+ function cellRowIndex(cell) {
817
+ // Rows are indexed within tbody (header isn't a "row" we can
818
+ // insert above; header actions are column-only).
819
+ const tr = cell.closest('tr');
820
+ const tbody = tr?.closest('tbody');
821
+ if (!tr || !tbody)
822
+ return -1;
823
+ return Array.from(tbody.querySelectorAll('tr')).indexOf(tr);
824
+ }
825
+ function cellColIndex(cell) {
826
+ const tr = cell.closest('tr');
827
+ if (!tr)
828
+ return -1;
829
+ return Array.from(tr.querySelectorAll('th, td')).indexOf(cell);
830
+ }
831
+ function dispatchModel(view, wrap, nextModel) {
832
+ // A menu can outlive the editable widget that created it when the
833
+ // host toggles reading mode. Guard before touching its now-detached
834
+ // DOM so a stale menu action cannot mutate a read-only document (or
835
+ // make posAtDOM throw on the detached table wrapper).
836
+ if (view.state.facet(readOnlyFacet))
837
+ return;
838
+ const range = findCurrentTableRange(view, wrap);
839
+ if (!range)
840
+ return;
841
+ const next = serializeTable(nextModel);
842
+ view.dispatch({
843
+ changes: { from: range.from, to: range.to, insert: next },
844
+ });
845
+ }
846
+ function openCellMenu(view, cell, x, y) {
847
+ const wrap = cell.closest('.cm-moss-table');
848
+ if (!wrap)
849
+ return;
850
+ const isHeader = cell.tagName === 'TH';
851
+ const row = cellRowIndex(cell);
852
+ const col = cellColIndex(cell);
853
+ const menu = document.createElement('div');
854
+ menu.className = 'cm-moss-table-menu';
855
+ menu.style.left = `${x}px`;
856
+ menu.style.top = `${y}px`;
857
+ const items = [];
858
+ if (!isHeader) {
859
+ items.push({
860
+ label: 'Insert row above',
861
+ icon: TABLE_MENU_ROW_ABOVE_ICON,
862
+ action: () => {
863
+ const m = readModelFromDom(wrap);
864
+ m.rows.splice(row, 0, m.header.map(() => ''));
865
+ dispatchModel(view, wrap, m);
866
+ },
867
+ });
868
+ items.push({
869
+ label: 'Insert row below',
870
+ icon: TABLE_MENU_ROW_BELOW_ICON,
871
+ action: () => {
872
+ const m = readModelFromDom(wrap);
873
+ m.rows.splice(row + 1, 0, m.header.map(() => ''));
874
+ dispatchModel(view, wrap, m);
875
+ },
876
+ });
877
+ items.push('separator');
878
+ }
879
+ items.push({
880
+ label: 'Insert column left',
881
+ icon: TABLE_MENU_COLUMN_LEFT_ICON,
882
+ action: () => {
883
+ const m = readModelFromDom(wrap);
884
+ m.header.splice(col, 0, '');
885
+ for (const r of m.rows)
886
+ r.splice(col, 0, '');
887
+ dispatchModel(view, wrap, m);
888
+ },
889
+ });
890
+ items.push({
891
+ label: 'Insert column right',
892
+ icon: TABLE_MENU_COLUMN_RIGHT_ICON,
893
+ action: () => {
894
+ const m = readModelFromDom(wrap);
895
+ m.header.splice(col + 1, 0, '');
896
+ for (const r of m.rows)
897
+ r.splice(col + 1, 0, '');
898
+ dispatchModel(view, wrap, m);
899
+ },
900
+ });
901
+ items.push('separator');
902
+ if (!isHeader) {
903
+ items.push({
904
+ label: 'Delete row',
905
+ icon: TABLE_MENU_DELETE_ICON,
906
+ destructive: true,
907
+ action: () => {
908
+ const m = readModelFromDom(wrap);
909
+ if (row >= 0 && row < m.rows.length)
910
+ m.rows.splice(row, 1);
911
+ dispatchModel(view, wrap, m);
912
+ },
913
+ });
914
+ }
915
+ items.push({
916
+ label: 'Delete column',
917
+ icon: TABLE_MENU_DELETE_ICON,
918
+ destructive: true,
919
+ action: () => {
920
+ const m = readModelFromDom(wrap);
921
+ // Guard: don't leave the table with zero columns — lezer
922
+ // wouldn't re-parse that as a Table and the widget would
923
+ // vanish mid-edit. Keeping the last column as the floor.
924
+ if (m.header.length <= 1 || col < 0)
925
+ return;
926
+ m.header.splice(col, 1);
927
+ for (const r of m.rows)
928
+ r.splice(col, 1);
929
+ dispatchModel(view, wrap, m);
930
+ },
931
+ });
932
+ let dismissed = false;
933
+ const dismiss = () => {
934
+ if (dismissed)
935
+ return;
936
+ dismissed = true;
937
+ menu.remove();
938
+ document.removeEventListener('mousedown', onDocDown, true);
939
+ document.removeEventListener('keydown', onDocKey, true);
940
+ };
941
+ const onDocDown = (event) => {
942
+ if (event.target instanceof Node && menu.contains(event.target))
943
+ return;
944
+ dismiss();
945
+ };
946
+ const onDocKey = (event) => {
947
+ if (event.key === 'Escape')
948
+ dismiss();
949
+ };
950
+ for (const item of items) {
951
+ if (item === 'separator') {
952
+ const sep = document.createElement('div');
953
+ sep.className = 'cm-moss-table-menu-sep';
954
+ menu.appendChild(sep);
955
+ continue;
956
+ }
957
+ const btn = document.createElement('button');
958
+ btn.type = 'button';
959
+ btn.className = 'cm-moss-table-menu-item';
960
+ if (item.destructive)
961
+ btn.classList.add('is-destructive');
962
+ const icon = document.createElement('span');
963
+ icon.className = 'cm-moss-table-menu-item-icon';
964
+ appendMossIcon(icon, item.icon);
965
+ icon.setAttribute('aria-hidden', 'true');
966
+ const label = document.createElement('span');
967
+ label.className = 'cm-moss-table-menu-item-label';
968
+ label.textContent = item.label;
969
+ btn.append(icon, label);
970
+ btn.addEventListener('click', () => {
971
+ item.action();
972
+ dismiss();
973
+ });
974
+ menu.appendChild(btn);
975
+ }
976
+ document.body.appendChild(menu);
977
+ // Clip the menu inside the viewport if it overflows.
978
+ const rect = menu.getBoundingClientRect();
979
+ if (rect.right > window.innerWidth) {
980
+ menu.style.left = `${Math.max(4, window.innerWidth - rect.width - 4)}px`;
981
+ }
982
+ if (rect.bottom > window.innerHeight) {
983
+ menu.style.top = `${Math.max(4, window.innerHeight - rect.height - 4)}px`;
984
+ }
985
+ // Deferred listener attach so the trigger click's document mousedown
986
+ // cycle doesn't immediately dismiss the new menu.
987
+ setTimeout(() => {
988
+ if (dismissed)
989
+ return;
990
+ document.addEventListener('mousedown', onDocDown, true);
991
+ document.addEventListener('keydown', onDocKey, true);
992
+ }, 0);
993
+ }
994
+ function dispatchModelFromDom(view, cell) {
995
+ if (view.state.facet(readOnlyFacet))
996
+ return;
997
+ const wrap = cell.closest('.cm-moss-table');
998
+ if (!wrap)
999
+ return;
1000
+ const range = findCurrentTableRange(view, wrap);
1001
+ if (!range)
1002
+ return;
1003
+ const model = readModelFromDom(wrap);
1004
+ const next = serializeTable(model);
1005
+ // Guard against no-op dispatches.
1006
+ if (view.state.sliceDoc(range.from, range.to) === next)
1007
+ return;
1008
+ view.dispatch({
1009
+ changes: { from: range.from, to: range.to, insert: next },
1010
+ // Tag as typing so CM6's history coalesces consecutive cell edits
1011
+ // into one undo group instead of one step per keystroke (each of
1012
+ // which rewrites the whole table range).
1013
+ annotations: Transaction.userEvent.of('input.type'),
1014
+ });
1015
+ }
1016
+ function moveCellFocus(view, cell, dir) {
1017
+ const wrap = cell.closest('.cm-moss-table');
1018
+ if (!wrap)
1019
+ return;
1020
+ const cells = getAllCells(wrap);
1021
+ const idx = cells.indexOf(cell);
1022
+ if (idx < 0)
1023
+ return;
1024
+ const next = idx + dir;
1025
+ if (next < 0) {
1026
+ // Shift-Tab from the first cell — blur the source; let the
1027
+ // browser decide where focus goes next (probably the previous
1028
+ // focusable element on the page). CM6 keeps its own selection
1029
+ // where it was.
1030
+ getCellSource(cell)?.blur();
1031
+ return;
1032
+ }
1033
+ if (next >= cells.length) {
1034
+ // Tab past the last cell — append a new empty row and focus its
1035
+ // first cell. We dispatch through the same path as a cell edit,
1036
+ // then grab the new first cell after the DOM reconciles.
1037
+ appendRow(view, wrap);
1038
+ return;
1039
+ }
1040
+ const source = getCellSource(cells[next]);
1041
+ if (!source)
1042
+ return;
1043
+ source.focus();
1044
+ placeCaretAtEnd(source);
1045
+ }
1046
+ function appendRow(view, wrap) {
1047
+ if (view.state.facet(readOnlyFacet))
1048
+ return;
1049
+ const range = findCurrentTableRange(view, wrap);
1050
+ if (!range)
1051
+ return;
1052
+ const model = readModelFromDom(wrap);
1053
+ model.rows.push(model.header.map(() => ''));
1054
+ const next = serializeTable(model);
1055
+ view.dispatch({
1056
+ changes: { from: range.from, to: range.to, insert: next },
1057
+ });
1058
+ // Adding a row changes the widget's row count, so `eq` returns
1059
+ // false and CM6 rebuilds the widget DOM. The old `wrap` reference
1060
+ // is now detached. Wait for the paint that attaches the new DOM,
1061
+ // then look up the fresh widget by position and focus its new
1062
+ // last-row cell. Double-rAF because the first rAF only guarantees
1063
+ // CM6 has processed the dispatch; the second ensures the layout
1064
+ // has painted so focus commands don't get lost.
1065
+ const { from } = range;
1066
+ requestAnimationFrame(() => {
1067
+ requestAnimationFrame(() => {
1068
+ if (view.state.facet(readOnlyFacet))
1069
+ return;
1070
+ const tables = Array.from(view.dom.querySelectorAll('.cm-moss-table'));
1071
+ let target = null;
1072
+ for (const el of tables) {
1073
+ try {
1074
+ if (view.posAtDOM(el) === from) {
1075
+ target = el;
1076
+ break;
1077
+ }
1078
+ }
1079
+ catch {
1080
+ // posAtDOM can throw on detached/transitional DOM nodes
1081
+ // — skip and keep looking.
1082
+ }
1083
+ }
1084
+ if (!target)
1085
+ return;
1086
+ const rows = target.querySelectorAll('tbody tr');
1087
+ const newRow = rows[rows.length - 1];
1088
+ const firstCell = newRow?.querySelector('td');
1089
+ const firstSource = firstCell ? getCellSource(firstCell) : null;
1090
+ if (!firstSource)
1091
+ return;
1092
+ firstSource.focus();
1093
+ placeCaretAtEnd(firstSource);
1094
+ });
1095
+ });
1096
+ }
1097
+ // Backspace at the line immediately after a table normally deletes
1098
+ // the `\n` separator and merges the line-below into the table's last
1099
+ // source line. Lezer then re-parses the merged content as part of
1100
+ // the table (or mangles it), producing the "swallow" behavior where
1101
+ // content below the table looks like it's been absorbed as new rows.
1102
+ //
1103
+ // Instead, when the caret sits right after a Table and the user hits
1104
+ // backspace, select the whole Table range — same pattern Obsidian
1105
+ // uses for treating the table as a word unit for deletion. The
1106
+ // caller can press backspace again to actually delete the selected
1107
+ // table.
1108
+ function backspaceAtTableBoundary(view) {
1109
+ const { state } = view;
1110
+ if (state.facet(readOnlyFacet))
1111
+ return false;
1112
+ const sel = state.selection.main;
1113
+ if (!sel.empty)
1114
+ return false;
1115
+ const pos = sel.head;
1116
+ if (pos === 0)
1117
+ return false;
1118
+ const tree = syntaxTree(state);
1119
+ let tableBefore = null;
1120
+ // Scan a few positions back for a Table whose end is adjacent to
1121
+ // the caret. `table.to` is the position just after the table's
1122
+ // last character — if the caret sits on the next line, `pos` will
1123
+ // be one past `table.to` (the \n separator at `table.to` + start
1124
+ // of the line after). Accept both.
1125
+ tree.iterate({
1126
+ from: Math.max(0, pos - 2),
1127
+ to: pos,
1128
+ enter: (n) => {
1129
+ if (n.name !== 'Table')
1130
+ return;
1131
+ if (n.to === pos || n.to + 1 === pos) {
1132
+ tableBefore = n.node;
1133
+ }
1134
+ },
1135
+ });
1136
+ if (!tableBefore)
1137
+ return false;
1138
+ const range = tableBefore;
1139
+ view.dispatch({
1140
+ selection: EditorSelection.range(range.from, range.to),
1141
+ });
1142
+ return true;
1143
+ }
1144
+ // ---- state field ----------------------------------------------------
1145
+ function buildTableWidgets(state) {
1146
+ const ranges = [];
1147
+ const readOnly = state.facet(readOnlyFacet);
1148
+ // Force full-doc parse so tables past the initial parsed region
1149
+ // also get the widget treatment. This StateField only rebuilds on
1150
+ // doc change; CM6's background parser advancing the tree later
1151
+ // doesn't retrigger it, so a partial tree at mount means orphaned
1152
+ // `| col |` raw lines for the rest of the session. 200ms budget
1153
+ // bounds the worst case on very long atoms.
1154
+ const tree = ensureSyntaxTree(state, state.doc.length, 200) ?? syntaxTree(state);
1155
+ const doc = state.doc;
1156
+ tree.iterate({
1157
+ enter: (node) => {
1158
+ if (node.name !== 'Table')
1159
+ return;
1160
+ const model = parseTable(state, node.node);
1161
+ if (!model)
1162
+ return;
1163
+ // Block-replace needs whole-line coverage.
1164
+ const startLine = doc.lineAt(node.from);
1165
+ const endLine = doc.lineAt(node.to);
1166
+ ranges.push(Decoration.replace({
1167
+ widget: new TableWidget(model, readOnly),
1168
+ block: true,
1169
+ }).range(startLine.from, endLine.to));
1170
+ return false; // don't descend
1171
+ },
1172
+ });
1173
+ return Decoration.set(ranges, true);
1174
+ }
1175
+ // Detect whether a doc change could have added, removed, or modified
1176
+ // a Table node. Two cheap signals:
1177
+ //
1178
+ // 1. Any existing table decoration overlaps the changed range
1179
+ // (edit to / deletion of an existing table).
1180
+ // 2. Any line touched by the change contains a pipe `|`. GFM
1181
+ // tables are pipe-delimited, so every table line has one and
1182
+ // editing one without touching a pipe character is impossible.
1183
+ // Prose rarely contains pipes; the occasional false positive
1184
+ // is fine because `buildTableWidgets` fails cleanly when
1185
+ // lezer didn't emit a Table.
1186
+ //
1187
+ // If neither fires, skip the full-doc walk and just map existing
1188
+ // decorations through the change.
1189
+ function changeAffectsTables(tr, existing) {
1190
+ let affected = false;
1191
+ tr.changes.iterChanges((fromA, toA) => {
1192
+ if (affected)
1193
+ return;
1194
+ existing.between(fromA, toA, () => {
1195
+ affected = true;
1196
+ return false;
1197
+ });
1198
+ });
1199
+ if (affected)
1200
+ return true;
1201
+ const state = tr.state;
1202
+ tr.changes.iterChanges((_fromA, _toA, fromB, toB) => {
1203
+ if (affected)
1204
+ return;
1205
+ const startLine = state.doc.lineAt(fromB);
1206
+ const endLine = toB > startLine.to ? state.doc.lineAt(toB) : startLine;
1207
+ for (let n = startLine.number; n <= endLine.number; n++) {
1208
+ if (state.doc.line(n).text.includes('|')) {
1209
+ affected = true;
1210
+ break;
1211
+ }
1212
+ }
1213
+ });
1214
+ return affected;
1215
+ }
1216
+ const tableField = StateField.define({
1217
+ create: (state) => buildTableWidgets(state),
1218
+ update(deco, tr) {
1219
+ // Tree-growth effect: lezer's background parser caught up to a
1220
+ // region that wasn't parsed when we last built. Rebuild so any
1221
+ // newly-visible Table nodes get their widget.
1222
+ for (const effect of tr.effects) {
1223
+ if (effect.is(treeGrowthEffect))
1224
+ return buildTableWidgets(tr.state);
1225
+ }
1226
+ // Reading-mode toggle: rebuild so cells re-render editable / inert.
1227
+ if (tr.startState.facet(readOnlyFacet) !== tr.state.facet(readOnlyFacet)) {
1228
+ return buildTableWidgets(tr.state);
1229
+ }
1230
+ if (!tr.docChanged)
1231
+ return deco;
1232
+ const mapped = deco.map(tr.changes);
1233
+ if (!changeAffectsTables(tr, deco))
1234
+ return mapped;
1235
+ return buildTableWidgets(tr.state);
1236
+ },
1237
+ provide: (f) => EditorView.decorations.from(f),
1238
+ });
1239
+ const defaultLinkOpener = (url) => {
1240
+ try {
1241
+ window.open(url, '_blank', 'noopener,noreferrer');
1242
+ }
1243
+ catch {
1244
+ // window.open can throw in sandboxed iframes etc.
1245
+ }
1246
+ };
1247
+ // Per-view facet so `makeCell`'s pointerdown handler can look up the
1248
+ // current link-click callback. Avoids threading the config through the
1249
+ // widget constructor and toDOM args.
1250
+ export const tableLinkClickFacet = Facet.define({
1251
+ combine: (values) => values[0] ?? defaultLinkOpener,
1252
+ });
1253
+ export function mossTables(config = {}) {
1254
+ return [
1255
+ tableField,
1256
+ treeProgressPlugin,
1257
+ ...(config.onLinkClick ? [tableLinkClickFacet.of(config.onLinkClick)] : []),
1258
+ // Prec.high so we run before the default Backspace binding.
1259
+ Prec.high(keymap.of([{ key: 'Backspace', run: backspaceAtTableBoundary }])),
1260
+ ];
1261
+ }
1262
+ //# sourceMappingURL=index.js.map