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,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 `` 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
|
+
// `` 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
|