draftly 1.0.7 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +12 -0
  2. package/dist/chunk-3T55CBNZ.cjs +33 -0
  3. package/dist/chunk-3T55CBNZ.cjs.map +1 -0
  4. package/dist/chunk-5MC4T7JH.cjs +58 -0
  5. package/dist/chunk-5MC4T7JH.cjs.map +1 -0
  6. package/dist/{chunk-72ZYRGRT.cjs → chunk-BWJLMREN.cjs} +11 -9
  7. package/dist/chunk-BWJLMREN.cjs.map +1 -0
  8. package/dist/{chunk-KBQDZ5IW.cjs → chunk-CLW73JRX.cjs} +100 -75
  9. package/dist/chunk-CLW73JRX.cjs.map +1 -0
  10. package/dist/{chunk-DFQYXFOP.js → chunk-EEHILRG5.js} +26 -3
  11. package/dist/chunk-EEHILRG5.js.map +1 -0
  12. package/dist/{chunk-HPSMS2WB.js → chunk-I563H35S.js} +101 -75
  13. package/dist/chunk-I563H35S.js.map +1 -0
  14. package/dist/chunk-IAXF4SJL.js +55 -0
  15. package/dist/chunk-IAXF4SJL.js.map +1 -0
  16. package/dist/chunk-JF3WXXMJ.js +31 -0
  17. package/dist/chunk-JF3WXXMJ.js.map +1 -0
  18. package/dist/{chunk-N3WL3XPB.js → chunk-L2XSK57Y.js} +1761 -478
  19. package/dist/chunk-L2XSK57Y.js.map +1 -0
  20. package/dist/{chunk-KDEDLC3D.cjs → chunk-TBVZEK2H.cjs} +27 -2
  21. package/dist/chunk-TBVZEK2H.cjs.map +1 -0
  22. package/dist/{chunk-2B3A3VSQ.cjs → chunk-W5ALMXG2.cjs} +1808 -504
  23. package/dist/chunk-W5ALMXG2.cjs.map +1 -0
  24. package/dist/{chunk-CG4M4TC7.js → chunk-ZUI3GI3W.js} +7 -5
  25. package/dist/chunk-ZUI3GI3W.js.map +1 -0
  26. package/dist/{draftly-BLnx3uGX.d.cts → draftly-BBL-AdOl.d.cts} +5 -1
  27. package/dist/{draftly-BLnx3uGX.d.ts → draftly-BBL-AdOl.d.ts} +5 -1
  28. package/dist/editor/index.cjs +22 -14
  29. package/dist/editor/index.d.cts +2 -1
  30. package/dist/editor/index.d.ts +2 -1
  31. package/dist/editor/index.js +2 -2
  32. package/dist/index.cjs +65 -39
  33. package/dist/index.d.cts +6 -3
  34. package/dist/index.d.ts +6 -3
  35. package/dist/index.js +6 -4
  36. package/dist/lib/index.cjs +12 -0
  37. package/dist/lib/index.cjs.map +1 -0
  38. package/dist/lib/index.d.cts +16 -0
  39. package/dist/lib/index.d.ts +16 -0
  40. package/dist/lib/index.js +3 -0
  41. package/dist/lib/index.js.map +1 -0
  42. package/dist/plugins/index.cjs +27 -17
  43. package/dist/plugins/index.d.cts +144 -9
  44. package/dist/plugins/index.d.ts +144 -9
  45. package/dist/plugins/index.js +5 -3
  46. package/dist/preview/index.cjs +16 -11
  47. package/dist/preview/index.d.cts +19 -4
  48. package/dist/preview/index.d.ts +19 -4
  49. package/dist/preview/index.js +3 -2
  50. package/package.json +8 -1
  51. package/src/editor/draftly.ts +1 -0
  52. package/src/editor/plugin.ts +5 -1
  53. package/src/editor/theme.ts +1 -0
  54. package/src/editor/utils.ts +31 -0
  55. package/src/index.ts +5 -4
  56. package/src/lib/index.ts +2 -0
  57. package/src/lib/input-handler.ts +45 -0
  58. package/src/plugins/code-plugin.theme.ts +426 -0
  59. package/src/plugins/code-plugin.ts +810 -561
  60. package/src/plugins/emoji-plugin.ts +140 -0
  61. package/src/plugins/index.ts +63 -57
  62. package/src/plugins/inline-plugin.ts +305 -291
  63. package/src/plugins/math-plugin.ts +12 -0
  64. package/src/plugins/table-plugin.ts +900 -0
  65. package/src/preview/context.ts +4 -1
  66. package/src/preview/css-generator.ts +14 -1
  67. package/src/preview/index.ts +9 -1
  68. package/src/preview/preview.ts +2 -1
  69. package/src/preview/renderer.ts +21 -20
  70. package/src/preview/syntax-theme.ts +110 -0
  71. package/src/preview/types.ts +14 -0
  72. package/dist/chunk-2B3A3VSQ.cjs.map +0 -1
  73. package/dist/chunk-72ZYRGRT.cjs.map +0 -1
  74. package/dist/chunk-CG4M4TC7.js.map +0 -1
  75. package/dist/chunk-DFQYXFOP.js.map +0 -1
  76. package/dist/chunk-HPSMS2WB.js.map +0 -1
  77. package/dist/chunk-KBQDZ5IW.cjs.map +0 -1
  78. package/dist/chunk-KDEDLC3D.cjs.map +0 -1
  79. package/dist/chunk-N3WL3XPB.js.map +0 -1
@@ -0,0 +1,900 @@
1
+ import { Decoration, EditorView, KeyBinding, WidgetType } from "@codemirror/view";
2
+ import { syntaxTree } from "@codemirror/language";
3
+ import { DecorationContext, DecorationPlugin, PluginContext } from "../editor/plugin";
4
+ import { createTheme, ThemeEnum } from "../editor";
5
+ import { SyntaxNode } from "@lezer/common";
6
+ import { DraftlyConfig } from "../editor/draftly";
7
+ import { PreviewRenderer } from "../preview/renderer";
8
+
9
+ // ============================================================================
10
+ // Types
11
+ // ============================================================================
12
+
13
+ /** Column alignment parsed from the delimiter row */
14
+ type Alignment = "left" | "center" | "right";
15
+
16
+ /** Parsed table structure */
17
+ interface ParsedTable {
18
+ /** Header row cells */
19
+ headers: string[];
20
+ /** Column alignments */
21
+ alignments: Alignment[];
22
+ /** Data rows, each an array of cell strings */
23
+ rows: string[][];
24
+ }
25
+
26
+ type PreviewContextLike = {
27
+ sliceDoc(from: number, to: number): string;
28
+ sanitize(html: string): string;
29
+ };
30
+
31
+ // ============================================================================
32
+ // Utilities
33
+ // ============================================================================
34
+
35
+ /**
36
+ * Parse alignment from a delimiter cell (e.g., `:---:`, `---:`, `:---`, `---`)
37
+ */
38
+ function parseAlignment(cell: string): Alignment {
39
+ const trimmed = cell.trim();
40
+ const left = trimmed.startsWith(":");
41
+ const right = trimmed.endsWith(":");
42
+ if (left && right) return "center";
43
+ if (right) return "right";
44
+ return "left";
45
+ }
46
+
47
+ /**
48
+ * Parse a markdown table string into structured data
49
+ */
50
+ function parseTableMarkdown(markdown: string): ParsedTable | null {
51
+ const lines = markdown.split("\n").filter((l) => l.trim().length > 0);
52
+ if (lines.length < 2) return null;
53
+
54
+ const parseCells = (line: string): string[] => {
55
+ // Remove leading/trailing pipes, then split by pipe
56
+ let trimmed = line.trim();
57
+ if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
58
+ if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1);
59
+ return trimmed.split("|").map((c) => c.trim());
60
+ };
61
+
62
+ const headers = parseCells(lines[0]!);
63
+ const delimiterCells = parseCells(lines[1]!);
64
+
65
+ // Validate delimiter row (must contain only -, :, and spaces)
66
+ const isDelimiter = delimiterCells.every((c) => /^:?-+:?$/.test(c.trim()));
67
+ if (!isDelimiter) return null;
68
+
69
+ const alignments = delimiterCells.map(parseAlignment);
70
+
71
+ const rows: string[][] = [];
72
+ for (let i = 2; i < lines.length; i++) {
73
+ rows.push(parseCells(lines[i]!));
74
+ }
75
+
76
+ return { headers, alignments, rows };
77
+ }
78
+
79
+ /**
80
+ * Check if a row is completely empty (all cells are empty/whitespace)
81
+ */
82
+ function isRowEmpty(rowText: string): boolean {
83
+ const trimmed = rowText.trim();
84
+ if (!trimmed.startsWith("|")) return false;
85
+ let inner = trimmed;
86
+ if (inner.startsWith("|")) inner = inner.slice(1);
87
+ if (inner.endsWith("|")) inner = inner.slice(0, -1);
88
+ return inner.split("|").every((cell) => cell.trim() === "");
89
+ }
90
+
91
+ async function renderCellWithPreviewRenderer(text: string, config?: DraftlyConfig): Promise<string> {
92
+ if (!text.trim()) {
93
+ return "&nbsp;";
94
+ }
95
+
96
+ const renderer = new PreviewRenderer(
97
+ text,
98
+ config?.plugins || [],
99
+ config?.markdown || [],
100
+ config?.theme || ThemeEnum.AUTO,
101
+ true
102
+ );
103
+ const html = await renderer.render();
104
+
105
+ // If wrapped in a single paragraph, unwrap for table cell display
106
+ const paragraphMatch = html.match(/^\s*<p>([\s\S]*)<\/p>\s*$/i);
107
+ if (paragraphMatch && paragraphMatch[1] !== undefined) {
108
+ return paragraphMatch[1];
109
+ }
110
+
111
+ return html;
112
+ }
113
+
114
+ function getColumnAlignment(alignments: Alignment[], index: number): Alignment {
115
+ return alignments[index] || "left";
116
+ }
117
+
118
+ function getColumnCount(headers: string[], row: string[]): number {
119
+ return Math.max(headers.length, row.length);
120
+ }
121
+
122
+ async function renderTableToHtml(parsed: ParsedTable, config?: DraftlyConfig): Promise<string> {
123
+ const { headers, alignments, rows } = parsed;
124
+ let html = '<div class="cm-draftly-table-widget">';
125
+ html += '<table class="cm-draftly-table">';
126
+
127
+ html += "<thead><tr>";
128
+ for (let i = 0; i < headers.length; i++) {
129
+ const cell = headers[i] || "";
130
+ const align = getColumnAlignment(alignments, i);
131
+ const rendered = await renderCellWithPreviewRenderer(cell, config);
132
+ html += `<th style="text-align: ${align}">${rendered}</th>`;
133
+ }
134
+ html += "</tr></thead>";
135
+
136
+ html += "<tbody>";
137
+ for (const row of rows) {
138
+ html += "<tr>";
139
+ const colCount = getColumnCount(headers, row);
140
+ for (let i = 0; i < colCount; i++) {
141
+ const align = getColumnAlignment(alignments, i);
142
+ const cell = row[i] || "";
143
+ const rendered = await renderCellWithPreviewRenderer(cell, config);
144
+ html += `<td style="text-align: ${align}">${rendered}</td>`;
145
+ }
146
+ html += "</tr>";
147
+ }
148
+ html += "</tbody>";
149
+
150
+ html += "</table></div>";
151
+ return html;
152
+ }
153
+
154
+ // ============================================================================
155
+ // Widget
156
+ // ============================================================================
157
+
158
+ /**
159
+ * Widget to render a markdown table as a styled HTML table.
160
+ * Shows rounded borders, alternate row colors, cell borders, and alignments.
161
+ */
162
+ class TableWidget extends WidgetType {
163
+ constructor(
164
+ readonly tableMarkdown: string,
165
+ readonly from: number,
166
+ readonly to: number,
167
+ readonly config?: DraftlyConfig
168
+ ) {
169
+ super();
170
+ }
171
+
172
+ override eq(other: TableWidget): boolean {
173
+ return (
174
+ other.tableMarkdown === this.tableMarkdown &&
175
+ other.from === this.from &&
176
+ other.to === this.to &&
177
+ other.config === this.config
178
+ );
179
+ }
180
+
181
+ toDOM(view: EditorView): HTMLElement {
182
+ const wrapper = document.createElement("div");
183
+ wrapper.className = "cm-draftly-table-widget";
184
+
185
+ const parsed = parseTableMarkdown(this.tableMarkdown);
186
+ if (!parsed) {
187
+ wrapper.textContent = "[Invalid table]";
188
+ return wrapper;
189
+ }
190
+
191
+ const { headers, alignments, rows } = parsed;
192
+
193
+ // Build the table
194
+ const table = document.createElement("table");
195
+ table.className = "cm-draftly-table";
196
+
197
+ // Thead
198
+ const thead = document.createElement("thead");
199
+ const headerRow = document.createElement("tr");
200
+ headers.forEach((h, i) => {
201
+ const th = document.createElement("th");
202
+ th.innerHTML = "&nbsp;";
203
+ this.renderCellAsync(h, th);
204
+ const align = alignments[i];
205
+ if (align) th.style.textAlign = align;
206
+ headerRow.appendChild(th);
207
+ });
208
+ thead.appendChild(headerRow);
209
+ table.appendChild(thead);
210
+
211
+ // Tbody
212
+ const tbody = document.createElement("tbody");
213
+ rows.forEach((row) => {
214
+ const tr = document.createElement("tr");
215
+ // Ensure we render enough cells for the column count
216
+ const colCount = getColumnCount(headers, row);
217
+ for (let i = 0; i < colCount; i++) {
218
+ const td = document.createElement("td");
219
+ td.innerHTML = "&nbsp;";
220
+ this.renderCellAsync(row[i] || "", td);
221
+ const align = getColumnAlignment(alignments, i);
222
+ if (align) td.style.textAlign = align;
223
+ tr.appendChild(td);
224
+ }
225
+ tbody.appendChild(tr);
226
+ });
227
+ table.appendChild(tbody);
228
+
229
+ wrapper.appendChild(table);
230
+
231
+ // Click handler — set cursor inside table to reveal raw markdown
232
+ wrapper.addEventListener("click", (e) => {
233
+ e.preventDefault();
234
+ e.stopPropagation();
235
+ view.dispatch({
236
+ selection: { anchor: this.from },
237
+ scrollIntoView: true,
238
+ });
239
+ view.focus();
240
+ });
241
+
242
+ return wrapper;
243
+ }
244
+
245
+ /**
246
+ * Render cell content asynchronously using PreviewRenderer
247
+ */
248
+ private async renderCellAsync(text: string, element: HTMLElement) {
249
+ if (!text.trim()) {
250
+ element.innerHTML = "&nbsp;";
251
+ return;
252
+ }
253
+
254
+ try {
255
+ element.innerHTML = await renderCellWithPreviewRenderer(text, this.config);
256
+ } catch (error) {
257
+ console.error("Failed to render table cell:", error);
258
+ element.textContent = text;
259
+ }
260
+ }
261
+
262
+ override ignoreEvent(event: Event): boolean {
263
+ return event.type !== "click";
264
+ }
265
+ }
266
+
267
+ // ============================================================================
268
+ // Decorations
269
+ // ============================================================================
270
+
271
+ const tableMarkDecorations = {
272
+ "table-line": Decoration.line({ class: "cm-draftly-table-line" }),
273
+ "table-line-start": Decoration.line({ class: "cm-draftly-table-line-start" }),
274
+ "table-line-end": Decoration.line({ class: "cm-draftly-table-line-end" }),
275
+ "table-delimiter": Decoration.line({ class: "cm-draftly-table-delimiter-line" }),
276
+ "table-rendered": Decoration.line({ class: "cm-draftly-table-rendered" }),
277
+ // "table-hidden": Decoration.mark({ class: "cm-draftly-table-hidden" }),
278
+ "table-hidden": Decoration.replace({}),
279
+ };
280
+
281
+ // ============================================================================
282
+ // Plugin
283
+ // ============================================================================
284
+
285
+ /**
286
+ * TablePlugin — Renders GFM markdown tables as styled widgets.
287
+ *
288
+ * Features:
289
+ * - Rendered table widget with rounded borders, alternate row colors, cell borders
290
+ * - Alignment support (`:---:`, `----:`, `:---`)
291
+ * - Monospace raw markdown when cursor is inside the table
292
+ * - Keyboard shortcuts for table creation, adding rows/columns
293
+ * - Enter in last row/last cell: creates row, again removes it
294
+ * - Auto-formats table markdown to align pipes
295
+ */
296
+ export class TablePlugin extends DecorationPlugin {
297
+ readonly name = "table";
298
+ readonly version = "1.0.0";
299
+ override decorationPriority = 20;
300
+ override readonly requiredNodes = ["Table", "TableHeader", "TableDelimiter", "TableRow", "TableCell"] as const;
301
+
302
+ /** Configuration stored from onRegister */
303
+ private draftlyConfig: DraftlyConfig | undefined;
304
+
305
+ override onRegister(context: PluginContext): void {
306
+ super.onRegister(context);
307
+ this.draftlyConfig = context.config;
308
+ }
309
+
310
+ override get theme() {
311
+ return theme;
312
+ }
313
+
314
+ // ============================================
315
+ // Keymaps
316
+ // ============================================
317
+
318
+ override getKeymap(): KeyBinding[] {
319
+ return [
320
+ {
321
+ key: "Mod-Shift-t",
322
+ run: (view) => this.insertTable(view),
323
+ preventDefault: true,
324
+ },
325
+ {
326
+ key: "Mod-Enter",
327
+ run: (view) => this.addRow(view),
328
+ preventDefault: true,
329
+ },
330
+ {
331
+ key: "Mod-Shift-Enter",
332
+ run: (view) => this.addColumn(view),
333
+ preventDefault: true,
334
+ },
335
+ {
336
+ key: "Enter",
337
+ run: (view) => this.handleEnter(view),
338
+ },
339
+ {
340
+ key: "Tab",
341
+ run: (view) => this.handleTab(view, false),
342
+ },
343
+ {
344
+ key: "Shift-Tab",
345
+ run: (view) => this.handleTab(view, true),
346
+ },
347
+ ];
348
+ }
349
+
350
+ // ============================================
351
+ // Decorations
352
+ // ============================================
353
+
354
+ buildDecorations(ctx: DecorationContext): void {
355
+ const { view, decorations } = ctx;
356
+ const tree = syntaxTree(view.state);
357
+
358
+ tree.iterate({
359
+ enter: (node) => {
360
+ if (node.name !== "Table") return;
361
+
362
+ const { from, to } = node;
363
+ const nodeLineStart = view.state.doc.lineAt(from);
364
+ const nodeLineEnd = view.state.doc.lineAt(to);
365
+ const cursorInRange = ctx.selectionOverlapsRange(nodeLineStart.from, nodeLineEnd.to);
366
+
367
+ if (cursorInRange) {
368
+ // Cursor inside: show raw markdown with monospace styling
369
+ // Add line decorations for every line in the table
370
+ for (let i = nodeLineStart.number; i <= nodeLineEnd.number; i++) {
371
+ const line = view.state.doc.line(i);
372
+ decorations.push(tableMarkDecorations["table-line"].range(line.from));
373
+
374
+ if (i === nodeLineStart.number) {
375
+ decorations.push(tableMarkDecorations["table-line-start"].range(line.from));
376
+ }
377
+ if (i === nodeLineEnd.number) {
378
+ decorations.push(tableMarkDecorations["table-line-end"].range(line.from));
379
+ }
380
+
381
+ // Check if this is the delimiter line (line 2 of the table)
382
+ if (i === nodeLineStart.number + 1) {
383
+ decorations.push(tableMarkDecorations["table-delimiter"].range(line.from));
384
+ }
385
+ }
386
+ } else {
387
+ // Cursor outside: hide raw text and show rendered widget
388
+ const tableContent = view.state.sliceDoc(from, to);
389
+
390
+ // Add line decorations to hide all lines
391
+ for (let i = nodeLineStart.number; i <= nodeLineEnd.number; i++) {
392
+ const line = view.state.doc.line(i);
393
+ decorations.push(tableMarkDecorations["table-rendered"].range(line.from));
394
+
395
+ // Hide the raw text content
396
+ decorations.push(tableMarkDecorations["table-hidden"].range(line.from, line.to));
397
+ }
398
+
399
+ // Add the rendered table widget at the end
400
+ decorations.push(
401
+ Decoration.widget({
402
+ widget: new TableWidget(tableContent, from, to, this.draftlyConfig),
403
+ side: 1,
404
+ block: false,
405
+ }).range(to)
406
+ );
407
+ }
408
+ },
409
+ });
410
+ }
411
+
412
+ // ============================================
413
+ // Keymap Handlers
414
+ // ============================================
415
+
416
+ /**
417
+ * Insert a new 3×3 table at cursor position
418
+ */
419
+ private insertTable(view: EditorView): boolean {
420
+ const { state } = view;
421
+ const cursor = state.selection.main.head;
422
+ const line = state.doc.lineAt(cursor);
423
+
424
+ // Insert at the beginning of the next line if current line has content
425
+ const insertPos = line.text.trim() ? line.to : line.from;
426
+
427
+ const template = [
428
+ "| Header 1 | Header 2 | Header 3 |",
429
+ "| -------- | -------- | -------- |",
430
+ "| | | |",
431
+ ].join("\n");
432
+
433
+ const prefix = line.text.trim() ? "\n" : "";
434
+ const suffix = "\n";
435
+
436
+ view.dispatch({
437
+ changes: {
438
+ from: insertPos,
439
+ insert: prefix + template + suffix,
440
+ },
441
+ selection: {
442
+ anchor: insertPos + prefix.length + 2, // Position cursor in first header cell
443
+ },
444
+ });
445
+
446
+ return true;
447
+ }
448
+
449
+ /**
450
+ * Add a new row below the current row (Mod-Enter)
451
+ */
452
+ private addRow(view: EditorView): boolean {
453
+ const tableInfo = this.getTableAtCursor(view);
454
+ if (!tableInfo) return false;
455
+
456
+ const { state } = view;
457
+ const cursor = state.selection.main.head;
458
+ const currentLine = state.doc.lineAt(cursor);
459
+
460
+ // Parse the table to know the column count
461
+ const parsed = parseTableMarkdown(state.sliceDoc(tableInfo.from, tableInfo.to));
462
+ if (!parsed) return false;
463
+
464
+ const colCount = parsed.headers.length;
465
+ const emptyRow = "| " + Array.from({ length: colCount }, () => " ").join(" | ") + " |";
466
+
467
+ // Insert after the current line
468
+ view.dispatch({
469
+ changes: {
470
+ from: currentLine.to,
471
+ insert: "\n" + emptyRow,
472
+ },
473
+ selection: {
474
+ anchor: currentLine.to + 3, // Position in first cell of new row
475
+ },
476
+ });
477
+
478
+ return true;
479
+ }
480
+
481
+ /**
482
+ * Add a new column after the current column (Mod-Shift-Enter)
483
+ */
484
+ private addColumn(view: EditorView): boolean {
485
+ const tableInfo = this.getTableAtCursor(view);
486
+ if (!tableInfo) return false;
487
+
488
+ const { state } = view;
489
+ const cursor = state.selection.main.head;
490
+
491
+ // Find which column the cursor is in
492
+ const currentLine = state.doc.lineAt(cursor);
493
+ const lineText = currentLine.text;
494
+ const cursorInLine = cursor - currentLine.from;
495
+
496
+ // Count pipes before cursor to find column index
497
+ let colIndex = -1;
498
+ for (let i = 0; i < cursorInLine; i++) {
499
+ if (lineText[i] === "|") colIndex++;
500
+ }
501
+ colIndex = Math.max(0, colIndex);
502
+
503
+ // Get all lines of the table
504
+ const tableText = state.sliceDoc(tableInfo.from, tableInfo.to);
505
+ const lines = tableText.split("\n");
506
+
507
+ // Insert a new column after colIndex in each line
508
+ const newLines = lines.map((line, lineIdx) => {
509
+ const cells = this.splitLineToCells(line);
510
+ const insertAfter = Math.min(colIndex, cells.length - 1);
511
+
512
+ if (lineIdx === 1) {
513
+ // Delimiter row
514
+ cells.splice(insertAfter + 1, 0, " -------- ");
515
+ } else {
516
+ cells.splice(insertAfter + 1, 0, " ");
517
+ }
518
+
519
+ return "|" + cells.join("|") + "|";
520
+ });
521
+
522
+ view.dispatch({
523
+ changes: {
524
+ from: tableInfo.from,
525
+ to: tableInfo.to,
526
+ insert: newLines.join("\n"),
527
+ },
528
+ });
529
+
530
+ return true;
531
+ }
532
+
533
+ /**
534
+ * Handle Enter key inside a table.
535
+ * - Last cell of last row: create a new row
536
+ * - Empty last row: remove it and move cursor after table
537
+ */
538
+ private handleEnter(view: EditorView): boolean {
539
+ const tableInfo = this.getTableAtCursor(view);
540
+ if (!tableInfo) return false;
541
+
542
+ const { state } = view;
543
+ const cursor = state.selection.main.head;
544
+ const cursorLine = state.doc.lineAt(cursor);
545
+ const tableEndLine = state.doc.lineAt(tableInfo.to);
546
+
547
+ // Check if cursor is on the last line of the table
548
+ if (cursorLine.number !== tableEndLine.number) return false;
549
+
550
+ // Check if cursor is in the last cell (after the second-to-last pipe)
551
+ const lineText = cursorLine.text;
552
+ const cursorOffset = cursor - cursorLine.from;
553
+ const pipes: number[] = [];
554
+ for (let i = 0; i < lineText.length; i++) {
555
+ if (lineText[i] === "|") pipes.push(i);
556
+ }
557
+
558
+ // Cursor needs to be after the second-to-last pipe (in the last cell)
559
+ if (pipes.length < 2) return false;
560
+ const lastCellStart = pipes[pipes.length - 2]!;
561
+ if (cursorOffset < lastCellStart) return false;
562
+
563
+ // If this row is empty, remove it and move cursor after the table
564
+ if (isRowEmpty(lineText)) {
565
+ // Remove this row (including the preceding newline)
566
+ const removeFrom = cursorLine.from - 1; // Include the newline before
567
+ const removeTo = cursorLine.to;
568
+
569
+ view.dispatch({
570
+ changes: { from: Math.max(0, removeFrom), to: removeTo },
571
+ selection: {
572
+ anchor: Math.min(Math.max(0, removeFrom) + 1, view.state.doc.length),
573
+ },
574
+ });
575
+
576
+ return true;
577
+ }
578
+
579
+ // Otherwise, create a new empty row
580
+ const parsed = parseTableMarkdown(state.sliceDoc(tableInfo.from, tableInfo.to));
581
+ if (!parsed) return false;
582
+
583
+ const colCount = parsed.headers.length;
584
+ const emptyRow = "| " + Array.from({ length: colCount }, () => " ").join(" | ") + " |";
585
+
586
+ view.dispatch({
587
+ changes: {
588
+ from: cursorLine.to,
589
+ insert: "\n" + emptyRow,
590
+ },
591
+ selection: {
592
+ anchor: cursorLine.to + 3, // Position in first cell of new row
593
+ },
594
+ });
595
+
596
+ return true;
597
+ }
598
+
599
+ /**
600
+ * Handle Tab key inside a table — move to next/previous cell
601
+ */
602
+ private handleTab(view: EditorView, backwards: boolean): boolean {
603
+ const tableInfo = this.getTableAtCursor(view);
604
+ if (!tableInfo) return false;
605
+
606
+ const { state } = view;
607
+ const cursor = state.selection.main.head;
608
+ const tableText = state.sliceDoc(tableInfo.from, tableInfo.to);
609
+ const lines = tableText.split("\n");
610
+
611
+ // Collect all cell positions (skip delimiter row)
612
+ const cellPositions: { lineFrom: number; start: number; end: number }[] = [];
613
+ for (let li = 0; li < lines.length; li++) {
614
+ if (li === 1) continue; // Skip delimiter row
615
+ const line = lines[li]!;
616
+ const lineFrom = tableInfo.from + lines.slice(0, li).reduce((sum, l) => sum + l.length + 1, 0);
617
+
618
+ const pipes: number[] = [];
619
+ for (let i = 0; i < line.length; i++) {
620
+ if (line[i] === "|") pipes.push(i);
621
+ }
622
+
623
+ for (let p = 0; p < pipes.length - 1; p++) {
624
+ const cellStart = pipes[p]! + 1;
625
+ const cellEnd = pipes[p + 1]!;
626
+ cellPositions.push({
627
+ lineFrom,
628
+ start: cellStart,
629
+ end: cellEnd,
630
+ });
631
+ }
632
+ }
633
+
634
+ // Find which cell the cursor is currently in
635
+ let currentCellIdx = -1;
636
+ for (let i = 0; i < cellPositions.length; i++) {
637
+ const cell = cellPositions[i]!;
638
+ const absStart = cell.lineFrom + cell.start;
639
+ const absEnd = cell.lineFrom + cell.end;
640
+ if (cursor >= absStart && cursor <= absEnd) {
641
+ currentCellIdx = i;
642
+ break;
643
+ }
644
+ }
645
+
646
+ if (currentCellIdx === -1) return false;
647
+
648
+ // Move to next/previous cell
649
+ const nextIdx = backwards ? currentCellIdx - 1 : currentCellIdx + 1;
650
+ if (nextIdx < 0 || nextIdx >= cellPositions.length) return false;
651
+
652
+ const nextCell = cellPositions[nextIdx]!;
653
+ const cellText = state.sliceDoc(nextCell.lineFrom + nextCell.start, nextCell.lineFrom + nextCell.end);
654
+ const trimStart = cellText.length - cellText.trimStart().length;
655
+ const trimEnd = cellText.length - cellText.trimEnd().length;
656
+
657
+ const selectFrom = nextCell.lineFrom + nextCell.start + (trimStart > 0 ? 1 : 0);
658
+ const selectTo = nextCell.lineFrom + nextCell.end - (trimEnd > 0 ? 1 : 0);
659
+
660
+ view.dispatch({
661
+ selection: {
662
+ anchor: selectFrom,
663
+ head: selectTo,
664
+ },
665
+ scrollIntoView: true,
666
+ });
667
+
668
+ return true;
669
+ }
670
+
671
+ // ============================================
672
+ // Helpers
673
+ // ============================================
674
+
675
+ /**
676
+ * Find the Table node at the cursor position
677
+ */
678
+ private getTableAtCursor(view: EditorView): { from: number; to: number } | null {
679
+ const tree = syntaxTree(view.state);
680
+ const cursor = view.state.selection.main.head;
681
+
682
+ let result: { from: number; to: number } | null = null;
683
+ tree.iterate({
684
+ enter: (node) => {
685
+ if (node.name === "Table" && cursor >= node.from && cursor <= node.to) {
686
+ result = { from: node.from, to: node.to };
687
+ }
688
+ },
689
+ });
690
+
691
+ return result;
692
+ }
693
+
694
+ /**
695
+ * Split a table line into cells (keeping the whitespace around content)
696
+ */
697
+ private splitLineToCells(line: string): string[] {
698
+ let trimmed = line.trim();
699
+ if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
700
+ if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1);
701
+ return trimmed.split("|");
702
+ }
703
+
704
+ // ============================================
705
+ // Preview Rendering
706
+ // ============================================
707
+
708
+ override async renderToHTML(
709
+ node: SyntaxNode,
710
+ _children: string,
711
+ _ctx: PreviewContextLike
712
+ ): Promise<string | null> {
713
+ if (node.name === "Table") {
714
+ const content = _ctx.sliceDoc(node.from, node.to);
715
+ const parsed = parseTableMarkdown(content);
716
+
717
+ if (!parsed) return null;
718
+
719
+ return await renderTableToHtml(parsed, this.draftlyConfig);
720
+ }
721
+
722
+ // Sub-nodes are handled by the Table renderer
723
+ if (
724
+ node.name === "TableHeader" ||
725
+ node.name === "TableDelimiter" ||
726
+ node.name === "TableRow" ||
727
+ node.name === "TableCell"
728
+ ) {
729
+ return "";
730
+ }
731
+
732
+ return null;
733
+ }
734
+ }
735
+
736
+ // ============================================================================
737
+ // Theme
738
+ // ============================================================================
739
+
740
+ const theme = createTheme({
741
+ default: {
742
+ // Raw table lines — monospace when cursor is inside
743
+ ".cm-draftly-table-line": {
744
+ "--radius": "0.375rem",
745
+ fontFamily: "var(--font-jetbrains-mono, monospace)",
746
+ fontSize: "0.9rem",
747
+ backgroundColor: "rgba(0, 0, 0, 0.02)",
748
+ padding: "0 0.75rem !important",
749
+ lineHeight: "1.6",
750
+ borderLeft: "1px solid var(--color-border, #e2e8f0)",
751
+ borderRight: "1px solid var(--color-border, #e2e8f0)",
752
+ },
753
+
754
+ ".cm-draftly-table-line-start": {
755
+ borderTopLeftRadius: "var(--radius)",
756
+ borderTopRightRadius: "var(--radius)",
757
+ borderTop: "1px solid var(--color-border, #e2e8f0)",
758
+ },
759
+
760
+ ".cm-draftly-table-line-end": {
761
+ borderBottomLeftRadius: "var(--radius)",
762
+ borderBottomRightRadius: "var(--radius)",
763
+ borderBottom: "1px solid var(--color-border, #e2e8f0)",
764
+ },
765
+
766
+ ".cm-draftly-table-delimiter-line": {
767
+ opacity: "0.5",
768
+ },
769
+
770
+ // Hidden table text (when cursor is not in range)
771
+ ".cm-draftly-table-hidden": {
772
+ display: "none",
773
+ },
774
+
775
+ // Line decoration for rendered state — hide line breaks
776
+ ".cm-draftly-table-rendered": {
777
+ padding: "0 !important",
778
+ },
779
+
780
+ ".cm-draftly-table-rendered br": {
781
+ display: "none",
782
+ },
783
+
784
+ // Rendered table widget container
785
+ ".cm-draftly-table-widget": {
786
+ cursor: "pointer",
787
+ overflow: "auto",
788
+ padding: "0.5rem 0",
789
+ },
790
+
791
+ // Table element
792
+ ".cm-draftly-table": {
793
+ width: "100%",
794
+ borderCollapse: "separate",
795
+ borderSpacing: "0",
796
+ borderRadius: "0.5rem",
797
+ overflow: "hidden",
798
+ border: "1px solid var(--color-border, #e2e8f0)",
799
+ fontFamily: "var(--font-sans, sans-serif)",
800
+ fontSize: "0.9375rem",
801
+ lineHeight: "1.5",
802
+ },
803
+
804
+ // Table header
805
+ ".cm-draftly-table thead th": {
806
+ padding: "0rem 0.875rem",
807
+ fontWeight: "600",
808
+ borderBottom: "2px solid var(--color-border, #e2e8f0)",
809
+ backgroundColor: "rgba(0, 0, 0, 0.03)",
810
+ },
811
+
812
+ // Table cells
813
+ ".cm-draftly-table td": {
814
+ padding: "0rem 0.875rem",
815
+ borderBottom: "1px solid var(--color-border, #e2e8f0)",
816
+ borderRight: "1px solid var(--color-border, #e2e8f0)",
817
+ },
818
+
819
+ // Remove right border on last cell
820
+ ".cm-draftly-table td:last-child, .cm-draftly-table th:last-child": {
821
+ borderRight: "none",
822
+ },
823
+
824
+ // Remove bottom border on last row
825
+ ".cm-draftly-table tbody tr:last-child td": {
826
+ borderBottom: "none",
827
+ },
828
+
829
+ // Alternate row colors
830
+ ".cm-draftly-table tbody tr:nth-child(even)": {
831
+ backgroundColor: "rgba(0, 0, 0, 0.02)",
832
+ },
833
+
834
+ // Header cells right border
835
+ ".cm-draftly-table thead th:not(:last-child)": {
836
+ borderRight: "1px solid var(--color-border, #e2e8f0)",
837
+ },
838
+
839
+ // Hover effect on rows
840
+ ".cm-draftly-table tbody tr:hover": {
841
+ backgroundColor: "rgba(0, 0, 0, 0.04)",
842
+ },
843
+
844
+ // Inline code in table cells
845
+ ".cm-draftly-table-inline-code": {
846
+ fontFamily: "var(--font-jetbrains-mono, monospace)",
847
+ fontSize: "0.85em",
848
+ padding: "0.1em 0.35em",
849
+ borderRadius: "0.25rem",
850
+ backgroundColor: "rgba(0, 0, 0, 0.06)",
851
+ },
852
+
853
+ // Links in table cells
854
+ ".cm-draftly-table-link": {
855
+ color: "var(--color-link, #0969da)",
856
+ textDecoration: "none",
857
+ },
858
+
859
+ ".cm-draftly-table-link:hover": {
860
+ textDecoration: "underline",
861
+ },
862
+
863
+ // Math in table cells
864
+ ".cm-draftly-table-math": {
865
+ fontFamily: "var(--font-jetbrains-mono, monospace)",
866
+ fontSize: "0.9em",
867
+ color: "#6a737d",
868
+ },
869
+ },
870
+
871
+ dark: {
872
+ ".cm-draftly-table-line": {
873
+ backgroundColor: "rgba(255, 255, 255, 0.03)",
874
+ },
875
+
876
+ ".cm-draftly-table thead th": {
877
+ backgroundColor: "rgba(255, 255, 255, 0.05)",
878
+ },
879
+
880
+ ".cm-draftly-table tbody tr:nth-child(even)": {
881
+ backgroundColor: "rgba(255, 255, 255, 0.02)",
882
+ },
883
+
884
+ ".cm-draftly-table tbody tr:hover": {
885
+ backgroundColor: "rgba(255, 255, 255, 0.05)",
886
+ },
887
+
888
+ ".cm-draftly-table-inline-code": {
889
+ backgroundColor: "rgba(255, 255, 255, 0.08)",
890
+ },
891
+
892
+ ".cm-draftly-table-link": {
893
+ color: "var(--color-link, #58a6ff)",
894
+ },
895
+
896
+ ".cm-draftly-table-math": {
897
+ color: "#8b949e",
898
+ },
899
+ },
900
+ });