@k2b/editor 0.0.0 → 0.1.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/LICENSE +661 -0
- package/README.md +32 -1
- package/package.json +116 -4
- package/src/BubbleMenu.tsx +126 -0
- package/src/Editor.tsx +32 -0
- package/src/SlashMenu.tsx +13 -0
- package/src/Toolbar.tsx +205 -0
- package/src/actions.ts +102 -0
- package/src/chart-schema.ts +274 -0
- package/src/charts.ts +710 -0
- package/src/collab/history.ts +39 -0
- package/src/collab/index.ts +32 -0
- package/src/collab/relative-cursor.ts +18 -0
- package/src/collab/remote-cursors.ts +358 -0
- package/src/editor-state.ts +199 -0
- package/src/formula.ts +1283 -0
- package/src/index.ts +16 -0
- package/src/markdown/_lib/cursor-zone-field.ts +338 -0
- package/src/markdown/_lib/inline-syntax.ts +9 -0
- package/src/markdown/_lib/link-opener.ts +50 -0
- package/src/markdown/_lib/list-item-line.ts +34 -0
- package/src/markdown/_lib/list-structure.ts +227 -0
- package/src/markdown/_lib/math-syntax.ts +3 -0
- package/src/markdown/_lib/table-cell.ts +74 -0
- package/src/markdown/basic.ts +37 -0
- package/src/markdown/block-previews.tsx +453 -0
- package/src/markdown/chart-block-messages.ts +5 -0
- package/src/markdown/chart-copy.ts +20 -0
- package/src/markdown/code-fence-snippets.ts +167 -0
- package/src/markdown/code-font.ts +54 -0
- package/src/markdown/completion-icon.ts +9 -0
- package/src/markdown/data-block-template.ts +11 -0
- package/src/markdown/data-blocks.ts +214 -0
- package/src/markdown/editor-scope.ts +92 -0
- package/src/markdown/fade-scroll-margins.browser-harness.ts +78 -0
- package/src/markdown/fade-scroll-margins.ts +25 -0
- package/src/markdown/image-syntax.ts +26 -0
- package/src/markdown/images.ts +192 -0
- package/src/markdown/index.ts +54 -0
- package/src/markdown/info-block-snippets.ts +159 -0
- package/src/markdown/info-blocks.ts +156 -0
- package/src/markdown/katex.ts +303 -0
- package/src/markdown/lib/chart-blocks.ts +511 -0
- package/src/markdown/lib/heading-anchors.ts +40 -0
- package/src/markdown/lib/ligatures.ts +107 -0
- package/src/markdown/lib/markdown-context.ts +72 -0
- package/src/markdown/lib/markdown-fences.ts +22 -0
- package/src/markdown/lib/markdown-image.fixtures.ts +20 -0
- package/src/markdown/lib/markdown-image.ts +36 -0
- package/src/markdown/lib/named-blocks.ts +403 -0
- package/src/markdown/lib/query-blocks.ts +145 -0
- package/src/markdown/ligatures.ts +223 -0
- package/src/markdown/links.ts +158 -0
- package/src/markdown/list-commands.ts +1124 -0
- package/src/markdown/lists.ts +290 -0
- package/src/markdown/mark.ts +103 -0
- package/src/markdown/markdown.ts +78 -0
- package/src/markdown/markup.ts +385 -0
- package/src/markdown/mermaid-export.ts +78 -0
- package/src/markdown/mermaid-preview.tsx +76 -0
- package/src/markdown/mermaid-render.ts +22 -0
- package/src/markdown/mermaid-viewport.tsx +78 -0
- package/src/markdown/mermaid.ts +242 -0
- package/src/markdown/messages.ts +17 -0
- package/src/markdown/named-blocks.ts +84 -0
- package/src/markdown/note-scroller.browser-harness.ts +84 -0
- package/src/markdown/note-scroller.ts +57 -0
- package/src/markdown/notebook-charts.tsx +142 -0
- package/src/markdown/pretty-table-messages.ts +33 -0
- package/src/markdown/pretty-table.ts +220 -0
- package/src/markdown/search-theme.ts +98 -0
- package/src/markdown/shared-tables.ts +286 -0
- package/src/markdown/sub-sup.ts +115 -0
- package/src/markdown/surface-press.ts +17 -0
- package/src/markdown/tab-indent.ts +104 -0
- package/src/markdown/table-columns.ts +188 -0
- package/src/markdown/table-formulas.ts +178 -0
- package/src/markdown/tables.ts +703 -0
- package/src/markdown/theme.ts +243 -0
- package/src/markdown/urls.ts +29 -0
- package/src/messages.ts +45 -0
- package/src/slash-menu.ts +220 -0
- package/src/styles.css +376 -0
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown table widget — renders a `| col | col |` block as a tile-style
|
|
3
|
+
* preview when the cursor is on a different line, mirroring the read-mode
|
|
4
|
+
* HTML produced by the `marked` extension. Same `.md-table-*` classes
|
|
5
|
+
* flow through `cloud/src/styles/utilities-markdown-table.css` so the visual
|
|
6
|
+
* is identical in edit and read mode.
|
|
7
|
+
*
|
|
8
|
+
* Editing path:
|
|
9
|
+
* - Cursor inside the table's lines (or on the line right before / after) →
|
|
10
|
+
* widget hidden, raw markdown source visible, normal CM editing.
|
|
11
|
+
* - Click on the widget → cursor dispatched to the table's start so the
|
|
12
|
+
* source-show range fires; user can then arrow-key around inside.
|
|
13
|
+
*
|
|
14
|
+
* # Cursor navigation
|
|
15
|
+
*
|
|
16
|
+
* Four defense-in-depth measures around CodeMirror's vertical-nav
|
|
17
|
+
* behavior near block widgets:
|
|
18
|
+
*
|
|
19
|
+
* 1. `EditorView.atomicRanges` over the block-widget decorations.
|
|
20
|
+
* CM's official recommendation for replace-block decorations —
|
|
21
|
+
* snaps cursor positions that fall inside the widget range to
|
|
22
|
+
* the nearest edge.
|
|
23
|
+
*
|
|
24
|
+
* 2. StateField rebuild only on a doc or syntax-tree change OR
|
|
25
|
+
* cursor-crosses-table-boundary (NOT on every `tr.selection`). Prevents
|
|
26
|
+
* widget destroy+recreate on every vertical-nav keystroke,
|
|
27
|
+
* which was thrashing layout heights mid-move.
|
|
28
|
+
*
|
|
29
|
+
* 3. CSS-derived `TableWidget.estimatedHeight` (37px/row + dividers
|
|
30
|
+
* + 8px padding). Under-estimating causes upward layout shift after
|
|
31
|
+
* DOM measurement, which CM's vertical-nav sees as a moving
|
|
32
|
+
* target.
|
|
33
|
+
*
|
|
34
|
+
* 4. `Prec.highest` ArrowUp/Down keymap intercept that does
|
|
35
|
+
* strict logical-line navigation when tables are present in
|
|
36
|
+
* the doc. Bypasses CM's Y-coord based `moveVertically` for
|
|
37
|
+
* vertical motion, walking past widgets in a single hop.
|
|
38
|
+
*
|
|
39
|
+
* CodeMirror 6.39.13 / 6.40.0 / 6.41.0 fixed several upstream
|
|
40
|
+
* cursor-jump bugs around block widgets + line-wrapping (this
|
|
41
|
+
* project pulls them in via `@codemirror/view: ^6.42.1`), but
|
|
42
|
+
* our specific layout — multiple stacked `Decoration.replace({
|
|
43
|
+
* block: true })` widgets with `cm-lineWrapping` and a cursor-
|
|
44
|
+
* proximity source-toggle — still triggers a residual case where
|
|
45
|
+
* ArrowDown approaching a widget from above resolves past it
|
|
46
|
+
* instead of into its source. We therefore keep a `Prec.highest`
|
|
47
|
+
* custom keymap (4) that does line-by-line nav and walks past
|
|
48
|
+
* widgets in a single hop. With the CM upgrade as a strong base
|
|
49
|
+
* + the keymap as final guard, all the cursor-jump symptoms from
|
|
50
|
+
* the original bug report are gone.
|
|
51
|
+
*
|
|
52
|
+
* No pagination, no per-cell type detection, no column zebra — markdown
|
|
53
|
+
* tables are hand-edited and small. Data tables belong in the Grids app.
|
|
54
|
+
*/
|
|
55
|
+
import { syntaxTree } from "@codemirror/language";
|
|
56
|
+
import { type EditorState, type Extension, Prec, type Range, RangeSet, StateField } from "@codemirror/state";
|
|
57
|
+
import { Decoration, type DecorationSet, EditorView, keymap, WidgetType } from "@codemirror/view";
|
|
58
|
+
import { i18n } from "@k2b/stdlib";
|
|
59
|
+
import { clipboard } from "@k2b/stdlib/browser";
|
|
60
|
+
import { type EvalContext, type EvalResult, evaluateFormula, isFormula } from "../formula";
|
|
61
|
+
import { holdsDecorations, refreshMarkdownDecorationsEffect, selectionIntersectsRange, treeOrDocChanged } from "./_lib/cursor-zone-field";
|
|
62
|
+
import { splitTableLineCells, tableCellText } from "./_lib/table-cell";
|
|
63
|
+
import { isNamedBlockHandle } from "./lib/named-blocks";
|
|
64
|
+
import { applyLigatures } from "./ligatures";
|
|
65
|
+
import { formatFormulaError, formatFormulaValue, type PrettyTableOptions, renderPrettyTableHtml } from "./pretty-table";
|
|
66
|
+
|
|
67
|
+
type Align = "left" | "right" | "center" | null;
|
|
68
|
+
|
|
69
|
+
type TableData = {
|
|
70
|
+
caption?: string;
|
|
71
|
+
headers: string[];
|
|
72
|
+
rows: string[][];
|
|
73
|
+
align: Align[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const splitRow = (line: string): string[] => {
|
|
77
|
+
return splitTableLineCells(line).map((cell) => tableCellText(cell.text));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const parseAlign = (separator: string): Align => {
|
|
81
|
+
const t = separator.trim();
|
|
82
|
+
const startsColon = t.startsWith(":");
|
|
83
|
+
const endsColon = t.endsWith(":");
|
|
84
|
+
if (startsColon && endsColon) return "center";
|
|
85
|
+
if (endsColon) return "right";
|
|
86
|
+
if (startsColon) return "left";
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const parseTable = (text: string): TableData | null => {
|
|
91
|
+
const lines = text.split("\n").filter((line) => line.trim());
|
|
92
|
+
if (lines.length < 2) return null;
|
|
93
|
+
if (!lines[1]?.includes("---")) return null;
|
|
94
|
+
|
|
95
|
+
const allRows = lines.map(splitRow);
|
|
96
|
+
const headers = allRows[0] ?? [];
|
|
97
|
+
const align: Align[] = (allRows[1] ?? []).map(parseAlign);
|
|
98
|
+
const rows = allRows.slice(2);
|
|
99
|
+
|
|
100
|
+
const cols = headers.length;
|
|
101
|
+
const normalizedRows = rows.map((row) => {
|
|
102
|
+
while (row.length < cols) row.push("");
|
|
103
|
+
return row.slice(0, cols);
|
|
104
|
+
});
|
|
105
|
+
// Pad / truncate align to match column count.
|
|
106
|
+
while (align.length < cols) align.push(null);
|
|
107
|
+
|
|
108
|
+
return { headers, rows: normalizedRows, align: align.slice(0, cols) };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const renderTable = (data: TableData, presentation: PrettyTableOptions): string => renderPrettyTableHtml(data, presentation);
|
|
112
|
+
|
|
113
|
+
const sameArray = <T>(left: T[], right: T[]): boolean =>
|
|
114
|
+
left.length === right.length && left.every((value, index) => value === right[index]);
|
|
115
|
+
|
|
116
|
+
const sameRows = (left: string[][], right: string[][]): boolean =>
|
|
117
|
+
left.length === right.length && left.every((row, index) => sameArray(row, right[index] ?? []));
|
|
118
|
+
|
|
119
|
+
const sameTableData = (left: TableData, right: TableData): boolean =>
|
|
120
|
+
left.caption === right.caption &&
|
|
121
|
+
sameArray(left.headers, right.headers) &&
|
|
122
|
+
sameArray(left.align, right.align) &&
|
|
123
|
+
sameRows(left.rows, right.rows);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Inline live-preview widget for raw-mode formula cells. When the user
|
|
127
|
+
* has the cursor inside a table (so the markdown source is visible),
|
|
128
|
+
* we slip a small ` → <result> ` ghost after each `=...` cell so they
|
|
129
|
+
* see whether their formula evaluates correctly without having to
|
|
130
|
+
* leave the cell.
|
|
131
|
+
*
|
|
132
|
+
* Click-to-copy: clicking the OK preview copies the computed value to
|
|
133
|
+
* the clipboard and briefly swaps the text to ` copied ` for ~800 ms.
|
|
134
|
+
* The error variant stays inert — there's nothing useful to copy.
|
|
135
|
+
*
|
|
136
|
+
* `eq()` makes equal-content widgets DOM-stable so the in-flight
|
|
137
|
+
* "copied" state survives unrelated editor updates.
|
|
138
|
+
*/
|
|
139
|
+
const COPIED_REVERT_MS = 800;
|
|
140
|
+
const formulaCopyMessages = i18n.define({
|
|
141
|
+
baseLocale: "en",
|
|
142
|
+
messages: {
|
|
143
|
+
en: { copy: "Copy result", copied: "copied", failed: "Could not copy. Click to try again." },
|
|
144
|
+
de: { copy: "Ergebnis kopieren", copied: "kopiert", failed: "Kopieren fehlgeschlagen. Zum Wiederholen klicken." },
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Equal widgets may retain DOM while their WidgetType instance changes.
|
|
149
|
+
// Clipboard work and its timer therefore belong to the DOM lifetime.
|
|
150
|
+
const formulaPreviewCopies = new WeakMap<HTMLElement, { timer?: number; destroyed: boolean }>();
|
|
151
|
+
|
|
152
|
+
class FormulaPreviewWidget extends WidgetType {
|
|
153
|
+
constructor(
|
|
154
|
+
private text: string,
|
|
155
|
+
private isError: boolean,
|
|
156
|
+
private locale?: string,
|
|
157
|
+
) {
|
|
158
|
+
super();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
override toDOM() {
|
|
162
|
+
const span = document.createElement("span");
|
|
163
|
+
span.className = this.isError ? "cm-formula-preview cm-formula-preview-error" : "cm-formula-preview";
|
|
164
|
+
span.textContent = ` → ${this.text} `;
|
|
165
|
+
|
|
166
|
+
// Errors aren't copyable — no sensible value to put on the clipboard.
|
|
167
|
+
if (this.isError) return span;
|
|
168
|
+
|
|
169
|
+
const valueText = this.text;
|
|
170
|
+
const copyState: { timer?: number; destroyed: boolean } = { destroyed: false };
|
|
171
|
+
formulaPreviewCopies.set(span, copyState);
|
|
172
|
+
const messages = formulaCopyMessages.resolve(this.locale ? [this.locale] : []).t;
|
|
173
|
+
span.setAttribute("aria-live", "polite");
|
|
174
|
+
span.setAttribute("role", "button");
|
|
175
|
+
span.setAttribute("aria-label", messages.copy);
|
|
176
|
+
span.tabIndex = 0;
|
|
177
|
+
span.addEventListener("keydown", (event) => {
|
|
178
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
event.stopPropagation();
|
|
181
|
+
span.click();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// mousedown: we own the event. preventDefault stops CM from moving
|
|
185
|
+
// the cursor to the click position; stopPropagation keeps our DOM
|
|
186
|
+
// click handler reachable without CM also processing it.
|
|
187
|
+
span.addEventListener("mousedown", (e) => {
|
|
188
|
+
e.preventDefault();
|
|
189
|
+
e.stopPropagation();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
span.addEventListener("click", (e) => {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
e.stopPropagation();
|
|
195
|
+
clipboard
|
|
196
|
+
.copy(valueText)
|
|
197
|
+
.then(() => {
|
|
198
|
+
if (copyState.destroyed) return;
|
|
199
|
+
span.textContent = ` ${messages.copied} `;
|
|
200
|
+
span.classList.add("cm-formula-preview-copied");
|
|
201
|
+
if (copyState.timer !== undefined) window.clearTimeout(copyState.timer);
|
|
202
|
+
copyState.timer = window.setTimeout(() => {
|
|
203
|
+
span.textContent = ` → ${valueText} `;
|
|
204
|
+
span.classList.remove("cm-formula-preview-copied");
|
|
205
|
+
copyState.timer = undefined;
|
|
206
|
+
}, COPIED_REVERT_MS);
|
|
207
|
+
})
|
|
208
|
+
.catch(() => {
|
|
209
|
+
if (copyState.destroyed) return;
|
|
210
|
+
if (copyState.timer !== undefined) window.clearTimeout(copyState.timer);
|
|
211
|
+
copyState.timer = undefined;
|
|
212
|
+
span.classList.remove("cm-formula-preview-copied");
|
|
213
|
+
span.textContent = ` → ${valueText} · ${messages.failed} `;
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return span;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
override destroy(dom: HTMLElement) {
|
|
221
|
+
const copyState = formulaPreviewCopies.get(dom);
|
|
222
|
+
if (!copyState) return;
|
|
223
|
+
copyState.destroyed = true;
|
|
224
|
+
if (copyState.timer !== undefined) window.clearTimeout(copyState.timer);
|
|
225
|
+
formulaPreviewCopies.delete(dom);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
override eq(other: WidgetType) {
|
|
229
|
+
return (
|
|
230
|
+
other instanceof FormulaPreviewWidget && other.text === this.text && other.isError === this.isError && other.locale === this.locale
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
override ignoreEvent() {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Shared with the source-only extension; installs no table replacement or keymap. */
|
|
240
|
+
export const formulaPreviewWidget = (result: EvalResult, locale?: string): WidgetType => {
|
|
241
|
+
const text = result.kind === "ok" ? formatFormulaValue(result.value, locale) : `⚠ ${formatFormulaError(result, locale).split("\n")[0]}`;
|
|
242
|
+
return new FormulaPreviewWidget(text, result.kind === "error", locale);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** Build inline live-preview decorations for one table when its
|
|
246
|
+
* source is visible. Walks each body line, parses cell positions,
|
|
247
|
+
* evaluates any `=...` cells, and inserts a `Decoration.widget` at
|
|
248
|
+
* the cell-end position so it floats next to the literal source. */
|
|
249
|
+
const buildLivePreviewDecorations = (state: EditorState, tableNode: { from: number; to: number }, data: TableData): Range<Decoration>[] => {
|
|
250
|
+
const decorations: Range<Decoration>[] = [];
|
|
251
|
+
const startLine = state.doc.lineAt(tableNode.from);
|
|
252
|
+
const endLine = state.doc.lineAt(tableNode.to);
|
|
253
|
+
// Lines: [0]=header, [1]=separator, [2..]=body rows
|
|
254
|
+
for (let lineNum = startLine.number + 2; lineNum <= endLine.number; lineNum++) {
|
|
255
|
+
const line = state.doc.line(lineNum);
|
|
256
|
+
const bodyRowIdx = lineNum - startLine.number - 2;
|
|
257
|
+
if (bodyRowIdx < 0 || bodyRowIdx >= data.rows.length) continue;
|
|
258
|
+
const cells = splitTableLineCells(line.text);
|
|
259
|
+
cells.forEach((cell, colIdx) => {
|
|
260
|
+
if (colIdx >= data.headers.length) return;
|
|
261
|
+
const trimmed = tableCellText(cell.text);
|
|
262
|
+
if (!isFormula(trimmed)) return;
|
|
263
|
+
const ctx: EvalContext = {
|
|
264
|
+
headers: data.headers,
|
|
265
|
+
rows: data.rows,
|
|
266
|
+
currentRow: bodyRowIdx,
|
|
267
|
+
currentCol: colIdx,
|
|
268
|
+
};
|
|
269
|
+
const result = evaluateFormula(trimmed, ctx);
|
|
270
|
+
const locale = typeof document === "undefined" ? undefined : document.documentElement.lang;
|
|
271
|
+
decorations.push(
|
|
272
|
+
Decoration.widget({
|
|
273
|
+
widget: formulaPreviewWidget(result, locale),
|
|
274
|
+
side: 1,
|
|
275
|
+
}).range(line.from + cell.toInLine),
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return decorations;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* DataTable-style table preview. Click → dispatches cursor to the table's
|
|
284
|
+
* start (`fromPos`, captured at decoration time) so the source-show
|
|
285
|
+
* range fires and the user can edit inline.
|
|
286
|
+
*
|
|
287
|
+
* The click handler lives on the widget DOM, not in a view-level
|
|
288
|
+
* `mousedown`, because `view.posAtDOM(target)` falls back to
|
|
289
|
+
* `state.doc.length` for nodes inside a `contenteditable=false` widget
|
|
290
|
+
* — that fallback was the cause of the "click jumps to end of doc" bug.
|
|
291
|
+
* Capturing the start position at construction sidesteps the problem.
|
|
292
|
+
*/
|
|
293
|
+
class TableWidget extends WidgetType {
|
|
294
|
+
constructor(
|
|
295
|
+
private data: TableData,
|
|
296
|
+
private fromPos: number,
|
|
297
|
+
private presentation: PrettyTableOptions,
|
|
298
|
+
) {
|
|
299
|
+
super();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
override toDOM(view: EditorView) {
|
|
303
|
+
const container = document.createElement("div");
|
|
304
|
+
container.className = "cm-table-widget";
|
|
305
|
+
container.setAttribute("contenteditable", "false");
|
|
306
|
+
container.innerHTML = renderTable(this.data, this.presentation);
|
|
307
|
+
applyLigatures(container);
|
|
308
|
+
container.onmousedown = (event) => {
|
|
309
|
+
const wrap = event.target;
|
|
310
|
+
if (wrap instanceof HTMLElement && wrap.matches(".md-table-wrap")) {
|
|
311
|
+
const rect = wrap.getBoundingClientRect();
|
|
312
|
+
const left = rect.left + wrap.clientLeft;
|
|
313
|
+
const top = rect.top + wrap.clientTop;
|
|
314
|
+
// Leave presses on classic scrollbars to the browser so their thumbs can be dragged.
|
|
315
|
+
if (
|
|
316
|
+
event.clientX < left ||
|
|
317
|
+
event.clientX >= left + wrap.clientWidth ||
|
|
318
|
+
event.clientY < top ||
|
|
319
|
+
event.clientY >= top + wrap.clientHeight
|
|
320
|
+
)
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
event.preventDefault();
|
|
324
|
+
event.stopPropagation();
|
|
325
|
+
view.dispatch({ selection: { anchor: this.fromPos } });
|
|
326
|
+
view.focus();
|
|
327
|
+
};
|
|
328
|
+
// CM's default `dblclick` handler runs INDEPENDENTLY of mousedown.
|
|
329
|
+
// When the user double-clicks on the widget, CM falls back to
|
|
330
|
+
// `posAtDOM(target)` for word-selection, which returns
|
|
331
|
+
// `state.doc.length` for nodes inside a `contenteditable=false`
|
|
332
|
+
// widget — observed as "double-click on a table jumps the cursor
|
|
333
|
+
// to the end of the doc". Stopping propagation on dblclick
|
|
334
|
+
// (without preventDefault, so the browser's native click logic
|
|
335
|
+
// still works for any descendant `<a>`/button) keeps CM out of
|
|
336
|
+
// it.
|
|
337
|
+
container.ondblclick = (event) => {
|
|
338
|
+
event.stopPropagation();
|
|
339
|
+
};
|
|
340
|
+
return container;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
override eq(other: WidgetType) {
|
|
344
|
+
return (
|
|
345
|
+
other instanceof TableWidget &&
|
|
346
|
+
other.fromPos === this.fromPos &&
|
|
347
|
+
other.presentation === this.presentation &&
|
|
348
|
+
sameTableData(this.data, other.data)
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Widget handles its own clicks; keep CM's view-level handlers off
|
|
353
|
+
* this DOM tree so the click never reaches `posAtDOM`. */
|
|
354
|
+
override ignoreEvent() {
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
override get estimatedHeight() {
|
|
359
|
+
// Per-cell rendered metrics (from `utilities-markdown-table.css`):
|
|
360
|
+
// - padding: 0.5rem on every side (16px vertical)
|
|
361
|
+
// - font-size: 14px, line-height: 1.5 (21px), min-height: 2rem
|
|
362
|
+
// → 37px per single-line row plus its hairline divider.
|
|
363
|
+
// Plus 8px outer padding below `.cm-table-widget` (see the theme
|
|
364
|
+
// rule for why this is padding not margin: margin would collapse
|
|
365
|
+
// and confuse CM's height measurement, padding stays inside the
|
|
366
|
+
// border-box).
|
|
367
|
+
//
|
|
368
|
+
// Formula: (N+1) rows × 37px + max(1, N) dividers + 8px container
|
|
369
|
+
// The header divider stays even in a table with no body rows.
|
|
370
|
+
//
|
|
371
|
+
// ACCURATE estimate matters MORE than buffer — see history at
|
|
372
|
+
// file top for the click-drift bug this matches. It is exact only for
|
|
373
|
+
// single-line cells: wrapped cells and a classic horizontal scrollbar
|
|
374
|
+
// measure taller, and CodeMirror corrects the height once the table
|
|
375
|
+
// renders, before it paints.
|
|
376
|
+
return (this.data.rows.length + 1) * 37 + Math.max(1, this.data.rows.length) + 8;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Per-table source range — the from/to of the markdown `Table`
|
|
381
|
+
* node. Used by the StateField's update() to detect cursor-boundary
|
|
382
|
+
* crossings without re-iterating the syntax tree. */
|
|
383
|
+
type TableRange = { from: number; to: number };
|
|
384
|
+
|
|
385
|
+
/** Full StateField value. Both decoration sets are derived from the
|
|
386
|
+
* same scan: `decorations` includes block widgets + live-preview
|
|
387
|
+
* formula chips, `blockWidgetDecorations` is just the block widgets
|
|
388
|
+
* (feeds `EditorView.atomicRanges` so the cursor snaps to widget
|
|
389
|
+
* edges instead of landing inside the replaced range during
|
|
390
|
+
* vertical navigation). */
|
|
391
|
+
type TablesState = {
|
|
392
|
+
decorations: DecorationSet;
|
|
393
|
+
blockWidgetDecorations: DecorationSet;
|
|
394
|
+
/** All discovered table source ranges. Used for the
|
|
395
|
+
* cursor-crossed-a-boundary check in `update()`. */
|
|
396
|
+
tableRanges: TableRange[];
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/** Compute which table the cursor is currently INSIDE (or
|
|
400
|
+
* immediately before / after). Returns:
|
|
401
|
+
* - the matching range's `from` (stable key per-table)
|
|
402
|
+
* - or `null` when the cursor is outside all tables.
|
|
403
|
+
*
|
|
404
|
+
* We key by `from` because the array index changes when tables are
|
|
405
|
+
* added/removed, and a stable identity makes the crossing check
|
|
406
|
+
* robust against any reorder.
|
|
407
|
+
*
|
|
408
|
+
* The "source-visible zone" extends by ONE LINE in BOTH directions
|
|
409
|
+
* around the table:
|
|
410
|
+
*
|
|
411
|
+
* - Line BEFORE the table: ArrowDown from this line would
|
|
412
|
+
* otherwise hit the block widget and our line-based vertical
|
|
413
|
+
* nav would SKIP past it (= user can't enter the table by
|
|
414
|
+
* arrowing from above). With the zone extended upward, the
|
|
415
|
+
* table flips to source-visible when the cursor reaches the
|
|
416
|
+
* line above it, so the next ArrowDown lands inside the
|
|
417
|
+
* first source row.
|
|
418
|
+
*
|
|
419
|
+
* - Line AFTER the table: symmetric — ArrowUp from this line
|
|
420
|
+
* flips the table to source mode so the user enters the
|
|
421
|
+
* table source rather than skipping past it.
|
|
422
|
+
*
|
|
423
|
+
* This makes the table behave consistently regardless of approach
|
|
424
|
+
* direction. */
|
|
425
|
+
const cursorTableKey = (state: EditorState, ranges: TableRange[]): string | null => {
|
|
426
|
+
if (ranges.length === 0) return null;
|
|
427
|
+
const cursor = state.selection.main;
|
|
428
|
+
const hits: number[] = [];
|
|
429
|
+
for (const r of ranges) {
|
|
430
|
+
const prevLineStart = state.doc.lineAt(Math.max(r.from - 1, 0)).from;
|
|
431
|
+
const nextLineEnd = state.doc.lineAt(Math.min(r.to + 1, state.doc.length)).to;
|
|
432
|
+
if (selectionIntersectsRange(cursor, prevLineStart, nextLineEnd)) hits.push(r.from);
|
|
433
|
+
}
|
|
434
|
+
return hits.length > 0 ? hits.sort((a, b) => a - b).join(",") : null;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
/** Full scan: walks the syntax tree, finds every Markdown table,
|
|
438
|
+
* builds the right decoration mode for each (block widget when the
|
|
439
|
+
* cursor is elsewhere, live-preview formula chips when inside).
|
|
440
|
+
*
|
|
441
|
+
* Returns the full StateField value — both decoration sets and the
|
|
442
|
+
* list of table ranges. */
|
|
443
|
+
const scanTables = (state: EditorState, presentation: PrettyTableOptions): TablesState => {
|
|
444
|
+
const decorations: Range<Decoration>[] = [];
|
|
445
|
+
const blockWidgetDecorations: Range<Decoration>[] = [];
|
|
446
|
+
const tableRanges: TableRange[] = [];
|
|
447
|
+
const cursor = state.selection.main;
|
|
448
|
+
|
|
449
|
+
syntaxTree(state).iterate({
|
|
450
|
+
enter: (node) => {
|
|
451
|
+
if (node.type.name !== "Table") return;
|
|
452
|
+
|
|
453
|
+
const text = state.sliceDoc(node.from, node.to);
|
|
454
|
+
const data = parseTable(text);
|
|
455
|
+
if (!data || data.headers.length === 0) return;
|
|
456
|
+
const tableLine = state.doc.lineAt(node.from);
|
|
457
|
+
const handleLine = tableLine.number > 1 ? state.doc.line(tableLine.number - 1) : null;
|
|
458
|
+
const caption = handleLine ? isNamedBlockHandle(handleLine.text) : null;
|
|
459
|
+
const rangeFrom = caption ? handleLine!.from : node.from;
|
|
460
|
+
if (caption) data.caption = caption;
|
|
461
|
+
tableRanges.push({ from: rangeFrom, to: node.to });
|
|
462
|
+
|
|
463
|
+
// Show-source range: when the cursor is anywhere inside the table
|
|
464
|
+
// OR on the line right before / right after it, drop the block
|
|
465
|
+
// widget so the raw markdown becomes editable. Instead, slip in
|
|
466
|
+
// inline live-preview decorations after each formula cell — the
|
|
467
|
+
// user sees their `=hours * rate` followed by ` → 160` ghost
|
|
468
|
+
// text and can verify the result while still typing in the
|
|
469
|
+
// source.
|
|
470
|
+
//
|
|
471
|
+
// The line-before extension is what makes ArrowDown from above
|
|
472
|
+
// ENTER the table (rather than the line-based vertical-nav
|
|
473
|
+
// widget-skip kicking in). See `cursorTableKey` for the
|
|
474
|
+
// symmetric rationale and the original asymmetric-only bug.
|
|
475
|
+
const prevLine = state.doc.lineAt(Math.max(rangeFrom - 1, 0));
|
|
476
|
+
const nextLine = state.doc.lineAt(Math.min(node.to + 1, state.doc.length));
|
|
477
|
+
const sourceVisible = selectionIntersectsRange(cursor, prevLine.from, nextLine.to);
|
|
478
|
+
if (sourceVisible) {
|
|
479
|
+
for (const deco of buildLivePreviewDecorations(state, { from: node.from, to: node.to }, data)) {
|
|
480
|
+
decorations.push(deco);
|
|
481
|
+
}
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const blockDeco = Decoration.replace({
|
|
486
|
+
widget: new TableWidget(data, node.from, presentation),
|
|
487
|
+
block: true,
|
|
488
|
+
}).range(rangeFrom, node.to);
|
|
489
|
+
decorations.push(blockDeco);
|
|
490
|
+
blockWidgetDecorations.push(blockDeco);
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
decorations: decorations.length > 0 ? RangeSet.of(decorations, true) : Decoration.none,
|
|
496
|
+
blockWidgetDecorations: blockWidgetDecorations.length > 0 ? RangeSet.of(blockWidgetDecorations, true) : Decoration.none,
|
|
497
|
+
tableRanges,
|
|
498
|
+
};
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
export const tablesExtension = (presentation: PrettyTableOptions = {}): Extension => {
|
|
502
|
+
const stateField = StateField.define<TablesState>({
|
|
503
|
+
create(state) {
|
|
504
|
+
return scanTables(state, presentation);
|
|
505
|
+
},
|
|
506
|
+
update(value, tr) {
|
|
507
|
+
if (tr.effects.some((effect) => effect.is(refreshMarkdownDecorationsEffect))) {
|
|
508
|
+
return scanTables(tr.state, presentation);
|
|
509
|
+
}
|
|
510
|
+
// Doc or syntax tree changed → tables may have appeared /
|
|
511
|
+
// disappeared / shifted positions. Full rescan.
|
|
512
|
+
if (treeOrDocChanged(tr)) {
|
|
513
|
+
return scanTables(tr.state, presentation);
|
|
514
|
+
}
|
|
515
|
+
if (holdsDecorations(tr)) return value;
|
|
516
|
+
// Selection unchanged AND doc unchanged → nothing to do.
|
|
517
|
+
if (!tr.selection) {
|
|
518
|
+
return value;
|
|
519
|
+
}
|
|
520
|
+
// Selection changed but stayed in the same "table zone" — the
|
|
521
|
+
// widget vs. source-visible decision is identical for both
|
|
522
|
+
// states, so we can keep the existing decorations unchanged.
|
|
523
|
+
// This is the key perf + correctness win:
|
|
524
|
+
//
|
|
525
|
+
// - PERF: vertical-nav keystrokes don't burn a syntax-tree
|
|
526
|
+
// walk + decoration rebuild per keystroke.
|
|
527
|
+
// - CORRECTNESS: the decoration set stays STABLE between
|
|
528
|
+
// cursor moves that don't actually change widget visibility.
|
|
529
|
+
// The previous implementation rebuilt on every selection,
|
|
530
|
+
// which could DESTROY+RECREATE the block widget mid-move —
|
|
531
|
+
// layout heights shifted under CM's vertical-nav math and
|
|
532
|
+
// the cursor landed at unpredictable spots (the reported
|
|
533
|
+
// "ArrowUp jumps multiple lines" bug).
|
|
534
|
+
const oldKey = cursorTableKey(tr.startState, value.tableRanges);
|
|
535
|
+
const newKey = cursorTableKey(tr.state, value.tableRanges);
|
|
536
|
+
if (oldKey === newKey) {
|
|
537
|
+
return value;
|
|
538
|
+
}
|
|
539
|
+
// Cursor crossed INTO or OUT OF a table (or moved between two
|
|
540
|
+
// different tables) — the widget vs source-visible decision
|
|
541
|
+
// for at least one table flipped. Rebuild.
|
|
542
|
+
return scanTables(tr.state, presentation);
|
|
543
|
+
},
|
|
544
|
+
provide(field) {
|
|
545
|
+
return [
|
|
546
|
+
EditorView.decorations.from(field, (v) => v.decorations),
|
|
547
|
+
// Mark the block widget ranges as atomic so CM's selection
|
|
548
|
+
// dispatcher snaps the cursor to widget edges instead of
|
|
549
|
+
// landing inside the replaced range. This is what stops the
|
|
550
|
+
// "ArrowUp from below the table jumps to top of doc" bug:
|
|
551
|
+
// without atomicRanges, moveVertically's posAtCoords can
|
|
552
|
+
// resolve a Y-coordinate to a position INSIDE the widget
|
|
553
|
+
// range (which CM then renders at the START of the range,
|
|
554
|
+
// many lines above where the user expected).
|
|
555
|
+
EditorView.atomicRanges.of((view) => view.state.field(field).blockWidgetDecorations),
|
|
556
|
+
];
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
const theme = EditorView.theme({
|
|
561
|
+
".cm-table-widget": {
|
|
562
|
+
display: "block",
|
|
563
|
+
// PAD instead of margin. The shared CSS (`utilities-markdown-table.css`)
|
|
564
|
+
// sets `.md-table-wrap { margin-bottom: 0.5rem; }` for breathing room
|
|
565
|
+
// in the read-mode rendering, but inside a CM
|
|
566
|
+
// block-widget that pattern produces a measurement-vs-visual
|
|
567
|
+
// mismatch: the inner wrap's vertical margins COLLAPSE through
|
|
568
|
+
// the cm-table-widget container (which has no border/padding to
|
|
569
|
+
// contain them), so `getBoundingClientRect().height` on the
|
|
570
|
+
// container returns the table height EXCLUDING those 16px. CM's
|
|
571
|
+
// height-map records the smaller number; click resolution Y-coords
|
|
572
|
+
// then drift DOWN per table. Cumulative drift after 3 tables
|
|
573
|
+
// was ~48px = ~3 lines, which matches the observed bug.
|
|
574
|
+
//
|
|
575
|
+
// Fix: override the inner wrap to ZERO margin (already done below
|
|
576
|
+
// via the descendant selector) and add equivalent PADDING on the
|
|
577
|
+
// outer widget. Padding stays inside the border box, so
|
|
578
|
+
// `getBoundingClientRect().height` includes it, and CM's
|
|
579
|
+
// measurement matches the visual layout. Above the table, the
|
|
580
|
+
// header cell's top padding keeps the header text where it was.
|
|
581
|
+
// The wrap keeps its shared inline bleed, which reaches past the
|
|
582
|
+
// content into the scroller's inline padding (`theme.ts`).
|
|
583
|
+
padding: "0 0 0.5rem",
|
|
584
|
+
margin: "0 !important",
|
|
585
|
+
},
|
|
586
|
+
// Neutralise the shared read-mode wrap margins inside the CM
|
|
587
|
+
// widget — see the .cm-table-widget rule above for the rationale.
|
|
588
|
+
// A table wider than the reading column scrolls in its wrap, bleed
|
|
589
|
+
// included, as in the Book; the editor never scrolls sideways.
|
|
590
|
+
".cm-table-widget .md-table-wrap": {
|
|
591
|
+
marginTop: "0 !important",
|
|
592
|
+
marginBottom: "0 !important",
|
|
593
|
+
overflowX: "auto",
|
|
594
|
+
},
|
|
595
|
+
// The Book's table type: a step below the text.
|
|
596
|
+
".cm-table-widget .md-table": {
|
|
597
|
+
wordBreak: "normal",
|
|
598
|
+
fontSize: "0.875em",
|
|
599
|
+
lineHeight: "1.5",
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* High-precedence ArrowUp / ArrowDown intercept — strict
|
|
605
|
+
* logical-line vertical navigation, bypassing CM's Y-coord based
|
|
606
|
+
* `moveVertically`.
|
|
607
|
+
*
|
|
608
|
+
* The CodeMirror 6.39.13 / 6.40.0 / 6.41.0 upstream fixes
|
|
609
|
+
* resolved several block-widget + line-wrapping issues, but our
|
|
610
|
+
* specific layout (multiple stacked block widgets + `cm-lineWrapping`
|
|
611
|
+
* + `Decoration.replace({block: true})` with cursor-proximity
|
|
612
|
+
* source-toggle) still triggers the cursor-jump in some cases —
|
|
613
|
+
* specifically ArrowDown approaching a block widget from above,
|
|
614
|
+
* where the target Y resolves past the widget instead of into
|
|
615
|
+
* its source-visible range.
|
|
616
|
+
*
|
|
617
|
+
* This keymap intercept guarantees ONE-LINE-PER-KEYSTROKE
|
|
618
|
+
* deterministic navigation: caretLine ± 1, walking past widgets
|
|
619
|
+
* in a single hop when needed.
|
|
620
|
+
*/
|
|
621
|
+
const tableNavKeymap = Prec.highest(
|
|
622
|
+
keymap.of([
|
|
623
|
+
{ key: "ArrowUp", run: (view) => navigateLine(view, -1, false) },
|
|
624
|
+
{ key: "Shift-ArrowUp", run: (view) => navigateLine(view, -1, true) },
|
|
625
|
+
{ key: "ArrowDown", run: (view) => navigateLine(view, 1, false) },
|
|
626
|
+
{ key: "Shift-ArrowDown", run: (view) => navigateLine(view, 1, true) },
|
|
627
|
+
]),
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
function navigateLine(view: EditorView, dir: -1 | 1, extend: boolean): boolean {
|
|
631
|
+
const tablesState = view.state.field(stateField, false);
|
|
632
|
+
if (!tablesState || tablesState.tableRanges.length === 0) return false;
|
|
633
|
+
|
|
634
|
+
const sel = view.state.selection.main;
|
|
635
|
+
const head = sel.head;
|
|
636
|
+
const caretLine = view.state.doc.lineAt(head);
|
|
637
|
+
const caretCol = head - caretLine.from;
|
|
638
|
+
|
|
639
|
+
let targetLineNumber = caretLine.number + dir;
|
|
640
|
+
while (targetLineNumber >= 1 && targetLineNumber <= view.state.doc.lines) {
|
|
641
|
+
const line = view.state.doc.line(targetLineNumber);
|
|
642
|
+
const inside = isLineInBlockWidget(tablesState, line.from, line.to);
|
|
643
|
+
if (!inside) break;
|
|
644
|
+
const widgetStartLine = view.state.doc.lineAt(inside.from).number;
|
|
645
|
+
const widgetEndLine = view.state.doc.lineAt(inside.to).number;
|
|
646
|
+
targetLineNumber = dir < 0 ? widgetStartLine - 1 : widgetEndLine + 1;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (targetLineNumber < 1) {
|
|
650
|
+
view.dispatch({
|
|
651
|
+
selection: extend ? { anchor: sel.anchor, head: 0 } : { anchor: 0 },
|
|
652
|
+
scrollIntoView: true,
|
|
653
|
+
userEvent: "select",
|
|
654
|
+
});
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
if (targetLineNumber > view.state.doc.lines) {
|
|
658
|
+
const target = view.state.doc.length;
|
|
659
|
+
view.dispatch({
|
|
660
|
+
selection: extend ? { anchor: sel.anchor, head: target } : { anchor: target },
|
|
661
|
+
scrollIntoView: true,
|
|
662
|
+
userEvent: "select",
|
|
663
|
+
});
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const targetLine = view.state.doc.line(targetLineNumber);
|
|
668
|
+
const target = targetLine.from + Math.min(caretCol, targetLine.length);
|
|
669
|
+
view.dispatch({
|
|
670
|
+
selection: extend ? { anchor: sel.anchor, head: target } : { anchor: target },
|
|
671
|
+
scrollIntoView: true,
|
|
672
|
+
userEvent: "select",
|
|
673
|
+
});
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// No view-level mousedown handler — the widget's own onmousedown
|
|
678
|
+
// captures the click + stopPropagation prevents CM's internal
|
|
679
|
+
// logic from running posAtDOM against widget-DOM targets.
|
|
680
|
+
return [stateField, theme, tableNavKeymap];
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
/** Check whether the document range [from, to] is covered by a
|
|
684
|
+
* currently-rendered block-widget decoration. Returns the table's
|
|
685
|
+
* source range when yes, null otherwise. Source-visible tables
|
|
686
|
+
* (cursor near them) are NOT in `blockWidgetDecorations`, so
|
|
687
|
+
* they don't trigger the widget-skip path. */
|
|
688
|
+
const isLineInBlockWidget = (state: TablesState, from: number, to: number): TableRange | null => {
|
|
689
|
+
for (const r of state.tableRanges) {
|
|
690
|
+
const lineOverlapsRange = from <= r.to && to >= r.from;
|
|
691
|
+
if (!lineOverlapsRange) continue;
|
|
692
|
+
let isWidget = false;
|
|
693
|
+
state.blockWidgetDecorations.between(r.from, r.to, (decoFrom, decoTo) => {
|
|
694
|
+
if (decoFrom === r.from && decoTo === r.to) {
|
|
695
|
+
isWidget = true;
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
return undefined;
|
|
699
|
+
});
|
|
700
|
+
if (isWidget) return r;
|
|
701
|
+
}
|
|
702
|
+
return null;
|
|
703
|
+
};
|