draftly 2.0.0 → 2.1.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.
@@ -1,900 +1,1759 @@
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
- });
1
+ import { Annotation, EditorState, Extension, Prec, Range, RangeSet } from "@codemirror/state";
2
+ import { syntaxTree } from "@codemirror/language";
3
+ import { BlockWrapper, Decoration, EditorView, KeyBinding, WidgetType, keymap } from "@codemirror/view";
4
+ import { SyntaxNode } from "@lezer/common";
5
+ import { MarkdownConfig, Table } from "@lezer/markdown";
6
+ import { createTheme } from "../editor";
7
+ import { DraftlyConfig } from "../editor/draftly";
8
+ import { DecorationContext, DecorationPlugin, PluginContext } from "../editor/plugin";
9
+ import { ThemeEnum } from "../editor/utils";
10
+ import { PreviewRenderer } from "../preview/renderer";
11
+
12
+ type Alignment = "left" | "center" | "right";
13
+ type TableRowKind = "header" | "body";
14
+
15
+ interface ParsedTable {
16
+ headers: string[];
17
+ alignments: Alignment[];
18
+ rows: string[][];
19
+ }
20
+
21
+ interface PreviewContextLike {
22
+ sliceDoc(from: number, to: number): string;
23
+ sanitize(html: string): string;
24
+ }
25
+
26
+ interface TableCellInfo {
27
+ rowKind: TableRowKind;
28
+ rowIndex: number;
29
+ columnIndex: number;
30
+ from: number;
31
+ to: number;
32
+ contentFrom: number;
33
+ contentTo: number;
34
+ lineFrom: number;
35
+ lineNumber: number;
36
+ rawText: string;
37
+ }
38
+
39
+ interface TableInfo {
40
+ from: number;
41
+ to: number;
42
+ startLineNumber: number;
43
+ delimiterLineNumber: number;
44
+ endLineNumber: number;
45
+ columnCount: number;
46
+ alignments: Alignment[];
47
+ cellsByRow: TableCellInfo[][];
48
+ headerCells: TableCellInfo[];
49
+ bodyCells: TableCellInfo[][];
50
+ }
51
+
52
+ const BREAK_TAG = "<br />";
53
+ const BREAK_TAG_REGEX = /<br\s*\/?>/gi;
54
+ const DELIMITER_CELL_PATTERN = /^:?-{3,}:?$/;
55
+ const TABLE_SUB_NODE_NAMES = new Set(["TableHeader", "TableDelimiter", "TableRow", "TableCell"]);
56
+ const TABLE_TEMPLATE: ParsedTable = {
57
+ headers: ["Header 1", "Header 2", "Header 3"],
58
+ alignments: ["left", "left", "left"],
59
+ rows: [["", "", ""]],
60
+ };
61
+
62
+ const normalizeAnnotation = Annotation.define<boolean>();
63
+ const repairSelectionAnnotation = Annotation.define<boolean>();
64
+ const pipeReplace = Decoration.replace({});
65
+ const delimiterReplace = Decoration.replace({});
66
+
67
+ const tableBlockWrapper = BlockWrapper.create({
68
+ tagName: "div",
69
+ attributes: { class: "cm-draftly-table-wrapper" },
70
+ });
71
+
72
+ class TableBreakWidget extends WidgetType {
73
+ /** Reuses the same widget instance for identical break markers. */
74
+ override eq(): boolean {
75
+ return true;
76
+ }
77
+
78
+ /** Renders an inline `<br />` placeholder inside a table cell. */
79
+ toDOM(): HTMLElement {
80
+ const span = document.createElement("span");
81
+ span.className = "cm-draftly-table-break";
82
+ span.setAttribute("aria-label", "line break");
83
+ span.appendChild(document.createElement("br"));
84
+ return span;
85
+ }
86
+
87
+ /** Allows the editor to observe events on the rendered break widget. */
88
+ override ignoreEvent(): boolean {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ class TableControlsWidget extends WidgetType {
94
+ constructor(
95
+ private readonly onAddRow: (view: EditorView) => void,
96
+ private readonly onAddColumn: (view: EditorView) => void
97
+ ) {
98
+ super();
99
+ }
100
+
101
+ /** Forces the control widget to be recreated so handlers stay current. */
102
+ override eq(): boolean {
103
+ return false;
104
+ }
105
+
106
+ /** Renders the hover controls used to append rows and columns. */
107
+ toDOM(view: EditorView): HTMLElement {
108
+ const anchor = document.createElement("span");
109
+ anchor.className = "cm-draftly-table-controls-anchor";
110
+ anchor.setAttribute("aria-hidden", "true");
111
+
112
+ const rightButton = this.createButton("Add column", "cm-draftly-table-control cm-draftly-table-control-column");
113
+ rightButton.addEventListener("mousedown", (event) => {
114
+ event.preventDefault();
115
+ event.stopPropagation();
116
+ this.onAddColumn(view);
117
+ });
118
+
119
+ const bottomButton = this.createButton("Add row", "cm-draftly-table-control cm-draftly-table-control-row");
120
+ bottomButton.addEventListener("mousedown", (event) => {
121
+ event.preventDefault();
122
+ event.stopPropagation();
123
+ this.onAddRow(view);
124
+ });
125
+
126
+ anchor.append(rightButton, bottomButton);
127
+ return anchor;
128
+ }
129
+
130
+ /** Lets button events bubble through the widget. */
131
+ override ignoreEvent(): boolean {
132
+ return false;
133
+ }
134
+
135
+ /** Builds a single control button with the provided label and class. */
136
+ private createButton(label: string, className: string): HTMLButtonElement {
137
+ const button = document.createElement("button");
138
+ button.type = "button";
139
+ button.className = className;
140
+ button.setAttribute("tabindex", "-1");
141
+ button.setAttribute("aria-label", label);
142
+ button.textContent = "+";
143
+ return button;
144
+ }
145
+ }
146
+
147
+ /** Returns whether the character at the given index is backslash-escaped. */
148
+ function isEscaped(text: string, index: number): boolean {
149
+ let slashCount = 0;
150
+ for (let i = index - 1; i >= 0 && text[i] === "\\"; i--) {
151
+ slashCount++;
152
+ }
153
+ return slashCount % 2 === 1;
154
+ }
155
+
156
+ /** Collects the positions of every unescaped pipe character in a line. */
157
+ function getPipePositions(lineText: string): number[] {
158
+ const positions: number[] = [];
159
+ for (let index = 0; index < lineText.length; index++) {
160
+ if (lineText[index] === "|" && !isEscaped(lineText, index)) {
161
+ positions.push(index);
162
+ }
163
+ }
164
+ return positions;
165
+ }
166
+
167
+ /** Splits a markdown table row into raw cell strings. */
168
+ function splitTableLine(lineText: string): string[] {
169
+ const cells: string[] = [];
170
+ const trimmed = lineText.trim();
171
+
172
+ if (!trimmed.includes("|")) {
173
+ return [trimmed];
174
+ }
175
+
176
+ let current = "";
177
+ for (let index = 0; index < trimmed.length; index++) {
178
+ const char = trimmed[index]!;
179
+ if (char === "|" && !isEscaped(trimmed, index)) {
180
+ cells.push(current);
181
+ current = "";
182
+ continue;
183
+ }
184
+ current += char;
185
+ }
186
+ cells.push(current);
187
+
188
+ if (trimmed.startsWith("|")) {
189
+ cells.shift();
190
+ }
191
+ if (trimmed.endsWith("|")) {
192
+ cells.pop();
193
+ }
194
+
195
+ return cells;
196
+ }
197
+
198
+ /** Checks whether the given line can participate in a table block. */
199
+ function isTableRowLine(lineText: string): boolean {
200
+ return getPipePositions(lineText.trim()).length > 0;
201
+ }
202
+
203
+ /** Parses a delimiter cell token into a table alignment value. */
204
+ function parseAlignment(cell: string): Alignment {
205
+ const trimmed = cell.trim();
206
+ const left = trimmed.startsWith(":");
207
+ const right = trimmed.endsWith(":");
208
+ if (left && right) return "center";
209
+ if (right) return "right";
210
+ return "left";
211
+ }
212
+
213
+ /** Parses the delimiter line and returns per-column alignments. */
214
+ function parseDelimiterAlignments(lineText: string): Alignment[] | null {
215
+ const cells = splitTableLine(lineText).map((cell) => cell.trim());
216
+ if (cells.length === 0 || !cells.every((cell) => DELIMITER_CELL_PATTERN.test(cell))) {
217
+ return null;
218
+ }
219
+ return cells.map(parseAlignment);
220
+ }
221
+
222
+ /** Splits a table node slice into its table lines and any trailing markdown. */
223
+ function splitTableAndTrailingMarkdown(markdown: string): { tableMarkdown: string; trailingMarkdown: string } {
224
+ const lines = markdown.split("\n");
225
+ if (lines.length < 2) {
226
+ return { tableMarkdown: markdown, trailingMarkdown: "" };
227
+ }
228
+
229
+ const headerLine = lines[0] || "";
230
+ const delimiterLine = lines[1] || "";
231
+ if (!isTableRowLine(headerLine) || !parseDelimiterAlignments(delimiterLine)) {
232
+ return { tableMarkdown: markdown, trailingMarkdown: "" };
233
+ }
234
+
235
+ let endIndex = 1;
236
+ for (let index = 2; index < lines.length; index++) {
237
+ if (!isTableRowLine(lines[index] || "")) {
238
+ break;
239
+ }
240
+ endIndex = index;
241
+ }
242
+
243
+ return {
244
+ tableMarkdown: lines.slice(0, endIndex + 1).join("\n"),
245
+ trailingMarkdown: lines.slice(endIndex + 1).join("\n"),
246
+ };
247
+ }
248
+
249
+ /** Normalizes every supported `<br>` form to the canonical `<br />` token. */
250
+ function canonicalizeBreakTags(text: string): string {
251
+ return text.replace(BREAK_TAG_REGEX, BREAK_TAG);
252
+ }
253
+
254
+ /** Escapes literal pipe characters so cell content stays GFM-compatible. */
255
+ function escapeUnescapedPipes(text: string): string {
256
+ let result = "";
257
+ for (let index = 0; index < text.length; index++) {
258
+ const char = text[index]!;
259
+ if (char === "|" && !isEscaped(text, index)) {
260
+ result += "\\|";
261
+ continue;
262
+ }
263
+ result += char;
264
+ }
265
+ return result;
266
+ }
267
+
268
+ /** Trims and normalizes cell content before it is written back to markdown. */
269
+ function normalizeCellContent(text: string): string {
270
+ const normalizedBreaks = canonicalizeBreakTags(text.trim());
271
+ if (!normalizedBreaks) {
272
+ return "";
273
+ }
274
+
275
+ const parts = normalizedBreaks.split(BREAK_TAG_REGEX).map((part) => escapeUnescapedPipes(part.trim()));
276
+ if (parts.length === 1) {
277
+ return parts[0] || "";
278
+ }
279
+
280
+ return parts.join(` ${BREAK_TAG} `).trim();
281
+ }
282
+
283
+ /** Measures the visible width of a cell for markdown alignment output. */
284
+ function renderWidth(text: string): number {
285
+ return canonicalizeBreakTags(text).replace(BREAK_TAG, " ").replace(/\\\|/g, "|").length;
286
+ }
287
+
288
+ /** Pads a cell according to its alignment for normalized markdown output. */
289
+ function padCell(text: string, width: number, alignment: Alignment): string {
290
+ const safeWidth = Math.max(width, renderWidth(text));
291
+ const difference = safeWidth - renderWidth(text);
292
+ if (difference <= 0) {
293
+ return text;
294
+ }
295
+
296
+ if (alignment === "right") {
297
+ return " ".repeat(difference) + text;
298
+ }
299
+
300
+ if (alignment === "center") {
301
+ const left = Math.floor(difference / 2);
302
+ const right = difference - left;
303
+ return " ".repeat(left) + text + " ".repeat(right);
304
+ }
305
+
306
+ return text + " ".repeat(difference);
307
+ }
308
+
309
+ /** Builds the markdown delimiter token for a column. */
310
+ function delimiterCell(width: number, alignment: Alignment): string {
311
+ const hyphenCount = Math.max(width, 3);
312
+ if (alignment === "center") {
313
+ return ":" + "-".repeat(Math.max(1, hyphenCount - 2)) + ":";
314
+ }
315
+ if (alignment === "right") {
316
+ return "-".repeat(Math.max(2, hyphenCount - 1)) + ":";
317
+ }
318
+ return "-".repeat(hyphenCount);
319
+ }
320
+
321
+ /** Parses a markdown table block into header, alignment, and body rows. */
322
+ function parseTableMarkdown(markdown: string): ParsedTable | null {
323
+ const { tableMarkdown } = splitTableAndTrailingMarkdown(markdown);
324
+ const lines = tableMarkdown.split("\n");
325
+ if (lines.length < 2) {
326
+ return null;
327
+ }
328
+
329
+ const headers = splitTableLine(lines[0] || "").map((cell) => cell.trim());
330
+ const alignments = parseDelimiterAlignments(lines[1] || "");
331
+ if (!alignments) {
332
+ return null;
333
+ }
334
+
335
+ const rows = lines
336
+ .slice(2)
337
+ .filter((line) => isTableRowLine(line))
338
+ .map((line) => splitTableLine(line).map((cell) => cell.trim()));
339
+
340
+ return { headers, alignments, rows };
341
+ }
342
+
343
+ /** Expands all rows so the parsed table has a consistent column count. */
344
+ function normalizeParsedTable(parsed: ParsedTable): ParsedTable {
345
+ const columnCount = Math.max(
346
+ parsed.headers.length,
347
+ parsed.alignments.length,
348
+ ...parsed.rows.map((row) => row.length),
349
+ 1
350
+ );
351
+
352
+ const headers = Array.from({ length: columnCount }, (_, index) => normalizeCellContent(parsed.headers[index] || ""));
353
+ const alignments = Array.from({ length: columnCount }, (_, index) => parsed.alignments[index] || "left");
354
+ const rows = parsed.rows.map((row) =>
355
+ Array.from({ length: columnCount }, (_, index) => normalizeCellContent(row[index] || ""))
356
+ );
357
+
358
+ return { headers, alignments, rows };
359
+ }
360
+
361
+ /** Formats a parsed table back into normalized GFM markdown. */
362
+ function formatTableMarkdown(parsed: ParsedTable): string {
363
+ const normalized = normalizeParsedTable(parsed);
364
+ const widths = normalized.headers.map((header, index) =>
365
+ Math.max(renderWidth(header), ...normalized.rows.map((row) => renderWidth(row[index] || "")), 3)
366
+ );
367
+
368
+ const formatRow = (cells: string[]) =>
369
+ `| ${cells.map((cell, index) => padCell(cell, widths[index] || 3, normalized.alignments[index] || "left")).join(" | ")} |`;
370
+
371
+ const headerLine = formatRow(normalized.headers);
372
+ const delimiterLine = `| ${normalized.alignments
373
+ .map((alignment, index) => delimiterCell(widths[index] || 3, alignment))
374
+ .join(" | ")} |`;
375
+ const bodyLines = normalized.rows.map((row) => formatRow(row));
376
+
377
+ return [headerLine, delimiterLine, ...bodyLines].join("\n");
378
+ }
379
+
380
+ /** Creates a blank row with the requested number of columns. */
381
+ function buildEmptyRow(columnCount: number): string[] {
382
+ return Array.from({ length: columnCount }, () => "");
383
+ }
384
+
385
+ /** Creates a preview renderer that skips paragraph wrapping inside cells. */
386
+ function createPreviewRenderer(markdown: string, config?: DraftlyConfig): PreviewRenderer {
387
+ const plugins = (config?.plugins || []).filter((plugin) => plugin.name !== "paragraph");
388
+ return new PreviewRenderer(markdown, plugins, config?.markdown || [], config?.theme || ThemeEnum.AUTO, true);
389
+ }
390
+
391
+ /** Removes a single top-level paragraph wrapper from preview HTML. */
392
+ function stripSingleParagraph(html: string): string {
393
+ const trimmed = html.trim();
394
+ const match = trimmed.match(/^<p\b[^>]*>([\s\S]*)<\/p>$/i);
395
+ return match?.[1] || trimmed;
396
+ }
397
+
398
+ /** Renders one table cell through the preview pipeline. */
399
+ async function renderCellToHtml(text: string, config?: DraftlyConfig): Promise<string> {
400
+ if (!text.trim()) {
401
+ return "&nbsp;";
402
+ }
403
+
404
+ return stripSingleParagraph(await createPreviewRenderer(text, config).render());
405
+ }
406
+
407
+ /** Renders a parsed table into semantic preview HTML. */
408
+ async function renderTableToHtml(parsed: ParsedTable, config?: DraftlyConfig): Promise<string> {
409
+ const normalized = normalizeParsedTable(parsed);
410
+ let html = '<div class="cm-draftly-table-widget"><table class="cm-draftly-table cm-draftly-table-preview">';
411
+ html += '<thead><tr class="cm-draftly-table-row cm-draftly-table-header-row">';
412
+
413
+ for (let index = 0; index < normalized.headers.length; index++) {
414
+ const alignment = normalized.alignments[index] || "left";
415
+ const content = await renderCellToHtml(normalized.headers[index] || "", config);
416
+ html += `<th class="cm-draftly-table-cell cm-draftly-table-th${
417
+ alignment === "center"
418
+ ? " cm-draftly-table-cell-center"
419
+ : alignment === "right"
420
+ ? " cm-draftly-table-cell-right"
421
+ : ""
422
+ }${index === normalized.headers.length - 1 ? " cm-draftly-table-cell-last" : ""}">${content}</th>`;
423
+ }
424
+
425
+ html += "</tr></thead><tbody>";
426
+
427
+ for (let rowIndex = 0; rowIndex < normalized.rows.length; rowIndex++) {
428
+ const row = normalized.rows[rowIndex] || [];
429
+ html += `<tr class="cm-draftly-table-row cm-draftly-table-body-row${
430
+ rowIndex % 2 === 1 ? " cm-draftly-table-row-even" : ""
431
+ }${rowIndex === normalized.rows.length - 1 ? " cm-draftly-table-row-last" : ""}">`;
432
+
433
+ for (let index = 0; index < normalized.headers.length; index++) {
434
+ const alignment = normalized.alignments[index] || "left";
435
+ const content = await renderCellToHtml(row[index] || "", config);
436
+ html += `<td class="cm-draftly-table-cell${
437
+ alignment === "center"
438
+ ? " cm-draftly-table-cell-center"
439
+ : alignment === "right"
440
+ ? " cm-draftly-table-cell-right"
441
+ : ""
442
+ }${index === normalized.headers.length - 1 ? " cm-draftly-table-cell-last" : ""}">${content}</td>`;
443
+ }
444
+
445
+ html += "</tr>";
446
+ }
447
+
448
+ html += "</tbody></table></div>";
449
+ return html;
450
+ }
451
+
452
+ /** Finds the visible content bounds inside a raw table cell span. */
453
+ function getVisibleBounds(rawCellText: string): { startOffset: number; endOffset: number } {
454
+ const leading = rawCellText.length - rawCellText.trimStart().length;
455
+ const trailing = rawCellText.length - rawCellText.trimEnd().length;
456
+ const trimmedLength = rawCellText.trim().length;
457
+
458
+ if (trimmedLength === 0) {
459
+ const placeholderOffset = Math.min(Math.floor(rawCellText.length / 2), Math.max(rawCellText.length - 1, 0));
460
+ return {
461
+ startOffset: placeholderOffset,
462
+ endOffset: Math.min(placeholderOffset + 1, rawCellText.length),
463
+ };
464
+ }
465
+
466
+ return {
467
+ startOffset: leading,
468
+ endOffset: rawCellText.length - trailing,
469
+ };
470
+ }
471
+
472
+ /** Returns whether every cell in a body row is empty. */
473
+ function isBodyRowEmpty(row: TableCellInfo[]): boolean {
474
+ return row.every((cell) => normalizeCellContent(cell.rawText) === "");
475
+ }
476
+
477
+ /** Converts the live editor table model into a serializable table structure. */
478
+ function buildTableFromInfo(tableInfo: TableInfo): ParsedTable {
479
+ return {
480
+ headers: tableInfo.headerCells.map((cell) => normalizeCellContent(cell.rawText)),
481
+ alignments: [...tableInfo.alignments],
482
+ rows: tableInfo.bodyCells.map((row) => row.map((cell) => normalizeCellContent(cell.rawText))),
483
+ };
484
+ }
485
+
486
+ /** Maps a logical row index to its physical line index in formatted markdown. */
487
+ function getRowLineIndex(rowIndex: number): number {
488
+ return rowIndex === 0 ? 0 : rowIndex + 1;
489
+ }
490
+
491
+ /** Resolves the caret anchor for a cell inside normalized table markdown. */
492
+ function getCellAnchorInFormattedTable(
493
+ formattedTable: string,
494
+ rowIndex: number,
495
+ columnIndex: number,
496
+ offset = 0
497
+ ): number {
498
+ const lines = formattedTable.split("\n");
499
+ const lineIndex = getRowLineIndex(rowIndex);
500
+ const lineText = lines[lineIndex] || "";
501
+ const pipes = getPipePositions(lineText);
502
+
503
+ if (pipes.length < columnIndex + 2) {
504
+ return formattedTable.length;
505
+ }
506
+
507
+ const rawFrom = pipes[columnIndex]! + 1;
508
+ const rawTo = pipes[columnIndex + 1]!;
509
+ const visible = getVisibleBounds(lineText.slice(rawFrom, rawTo));
510
+ const lineOffset = lines.slice(0, lineIndex).reduce((sum, line) => sum + line.length + 1, 0);
511
+ return (
512
+ lineOffset +
513
+ Math.min(rawFrom + visible.startOffset + offset, rawFrom + Math.max(visible.endOffset - 1, visible.startOffset))
514
+ );
515
+ }
516
+
517
+ /** Wraps a table replacement with the required blank spacer lines. */
518
+ function createTableInsert(state: EditorState, from: number, to: number, tableMarkdown: string) {
519
+ let insert = tableMarkdown;
520
+ let prefixLength = 0;
521
+
522
+ const startLine = state.doc.lineAt(from);
523
+ if (startLine.number === 1 || state.doc.line(startLine.number - 1).text.trim() !== "") {
524
+ insert = "\n" + insert;
525
+ prefixLength = 1;
526
+ }
527
+
528
+ const endLine = state.doc.lineAt(Math.max(from, to));
529
+ if (endLine.number === state.doc.lines || state.doc.line(endLine.number + 1).text.trim() !== "") {
530
+ insert += "\n";
531
+ }
532
+
533
+ return { from, to, insert, prefixLength };
534
+ }
535
+
536
+ /** Builds a live table model from the current editor document. */
537
+ function readTableInfo(state: EditorState, nodeFrom: number, nodeTo: number): TableInfo | null {
538
+ const startLine = state.doc.lineAt(nodeFrom);
539
+ const endLine = state.doc.lineAt(nodeTo);
540
+ const delimiterLineNumber = startLine.number + 1;
541
+ if (delimiterLineNumber > endLine.number) {
542
+ return null;
543
+ }
544
+
545
+ const delimiterLine = state.doc.line(delimiterLineNumber);
546
+ const alignments = parseDelimiterAlignments(delimiterLine.text);
547
+ if (!alignments) {
548
+ return null;
549
+ }
550
+
551
+ let effectiveEndLineNumber = delimiterLineNumber;
552
+ for (let lineNumber = delimiterLineNumber + 1; lineNumber <= endLine.number; lineNumber++) {
553
+ const line = state.doc.line(lineNumber);
554
+ if (!isTableRowLine(line.text)) {
555
+ break;
556
+ }
557
+ effectiveEndLineNumber = lineNumber;
558
+ }
559
+
560
+ const cellsByRow: TableCellInfo[][] = [];
561
+ for (let lineNumber = startLine.number; lineNumber <= effectiveEndLineNumber; lineNumber++) {
562
+ if (lineNumber === delimiterLineNumber) {
563
+ continue;
564
+ }
565
+
566
+ const line = state.doc.line(lineNumber);
567
+ const pipes = getPipePositions(line.text);
568
+ if (pipes.length < 2) {
569
+ return null;
570
+ }
571
+
572
+ const isHeader = lineNumber === startLine.number;
573
+ const rowIndex = isHeader ? 0 : cellsByRow.length;
574
+ const cells: TableCellInfo[] = [];
575
+
576
+ for (let columnIndex = 0; columnIndex < pipes.length - 1; columnIndex++) {
577
+ const from = line.from + pipes[columnIndex]! + 1;
578
+ const to = line.from + pipes[columnIndex + 1]!;
579
+ const rawText = line.text.slice(pipes[columnIndex]! + 1, pipes[columnIndex + 1]);
580
+ const visible = getVisibleBounds(rawText);
581
+
582
+ cells.push({
583
+ rowKind: isHeader ? "header" : "body",
584
+ rowIndex,
585
+ columnIndex,
586
+ from,
587
+ to,
588
+ contentFrom: from + visible.startOffset,
589
+ contentTo: from + visible.endOffset,
590
+ lineFrom: line.from,
591
+ lineNumber,
592
+ rawText,
593
+ });
594
+ }
595
+
596
+ cellsByRow.push(cells);
597
+ }
598
+
599
+ if (cellsByRow.length === 0) {
600
+ return null;
601
+ }
602
+
603
+ return {
604
+ from: startLine.from,
605
+ to: state.doc.line(effectiveEndLineNumber).to,
606
+ startLineNumber: startLine.number,
607
+ delimiterLineNumber,
608
+ endLineNumber: effectiveEndLineNumber,
609
+ columnCount: cellsByRow[0]!.length,
610
+ alignments: Array.from({ length: cellsByRow[0]!.length }, (_, index) => alignments[index] || "left"),
611
+ cellsByRow,
612
+ headerCells: cellsByRow[0]!,
613
+ bodyCells: cellsByRow.slice(1),
614
+ };
615
+ }
616
+
617
+ /** Finds the table model that contains the given document position. */
618
+ function getTableInfoAtPosition(state: EditorState, position: number): TableInfo | null {
619
+ let resolved: TableInfo | null = null;
620
+
621
+ syntaxTree(state).iterate({
622
+ enter: (node) => {
623
+ if (resolved || node.name !== "Table") {
624
+ return;
625
+ }
626
+
627
+ const info = readTableInfo(state, node.from, node.to);
628
+ if (info && position >= info.from && position <= info.to) {
629
+ resolved = info;
630
+ }
631
+ },
632
+ });
633
+
634
+ return resolved;
635
+ }
636
+
637
+ /** Returns the table cell containing the given cursor position. */
638
+ function findCellAtPosition(tableInfo: TableInfo, position: number): TableCellInfo | null {
639
+ for (const row of tableInfo.cellsByRow) {
640
+ for (const cell of row) {
641
+ if (position >= cell.from && position <= cell.to) {
642
+ return cell;
643
+ }
644
+ }
645
+ }
646
+
647
+ for (const row of tableInfo.cellsByRow) {
648
+ for (const cell of row) {
649
+ if (position >= cell.from - 1 && position <= cell.to + 1) {
650
+ return cell;
651
+ }
652
+ }
653
+ }
654
+
655
+ let nearestCell: TableCellInfo | null = null;
656
+ let nearestDistance = Number.POSITIVE_INFINITY;
657
+ for (const row of tableInfo.cellsByRow) {
658
+ for (const cell of row) {
659
+ const distance = Math.min(Math.abs(position - cell.from), Math.abs(position - cell.to));
660
+ if (distance < nearestDistance) {
661
+ nearestCell = cell;
662
+ nearestDistance = distance;
663
+ }
664
+ }
665
+ }
666
+
667
+ return nearestCell;
668
+ }
669
+
670
+ /** Clamps a document position into the editable content span of a cell. */
671
+ function clampCellPosition(cell: TableCellInfo, position: number): number {
672
+ const cellEnd = Math.max(cell.contentFrom, cell.contentTo);
673
+ return Math.max(cell.contentFrom, Math.min(position, cellEnd));
674
+ }
675
+
676
+ /** Collects all `<br />` token ranges from the current table. */
677
+ function collectBreakRanges(tableInfo: TableInfo): Array<{ from: number; to: number }> {
678
+ const ranges: Array<{ from: number; to: number }> = [];
679
+
680
+ for (const row of tableInfo.cellsByRow) {
681
+ for (const cell of row) {
682
+ let match: RegExpExecArray | null;
683
+ const regex = new RegExp(BREAK_TAG_REGEX);
684
+ while ((match = regex.exec(cell.rawText)) !== null) {
685
+ ranges.push({
686
+ from: cell.from + match.index,
687
+ to: cell.from + match.index + match[0].length,
688
+ });
689
+ }
690
+ }
691
+ }
692
+
693
+ return ranges;
694
+ }
695
+
696
+ const lineDecorations = {
697
+ header: Decoration.line({ class: "cm-draftly-table-row cm-draftly-table-header-row" }),
698
+ delimiter: Decoration.line({ class: "cm-draftly-table-row cm-draftly-table-delimiter-row" }),
699
+ body: Decoration.line({ class: "cm-draftly-table-row cm-draftly-table-body-row" }),
700
+ even: Decoration.line({ class: "cm-draftly-table-row cm-draftly-table-body-row cm-draftly-table-row-even" }),
701
+ last: Decoration.line({ class: "cm-draftly-table-row-last" }),
702
+ };
703
+
704
+ const cellDecorations = {
705
+ "th-left": Decoration.mark({ class: "cm-draftly-table-cell cm-draftly-table-th" }),
706
+ "th-center": Decoration.mark({ class: "cm-draftly-table-cell cm-draftly-table-th cm-draftly-table-cell-center" }),
707
+ "th-right": Decoration.mark({ class: "cm-draftly-table-cell cm-draftly-table-th cm-draftly-table-cell-right" }),
708
+ "th-left-last": Decoration.mark({ class: "cm-draftly-table-cell cm-draftly-table-th cm-draftly-table-cell-last" }),
709
+ "th-center-last": Decoration.mark({
710
+ class: "cm-draftly-table-cell cm-draftly-table-th cm-draftly-table-cell-center cm-draftly-table-cell-last",
711
+ }),
712
+ "th-right-last": Decoration.mark({
713
+ class: "cm-draftly-table-cell cm-draftly-table-th cm-draftly-table-cell-right cm-draftly-table-cell-last",
714
+ }),
715
+ "td-left": Decoration.mark({ class: "cm-draftly-table-cell" }),
716
+ "td-center": Decoration.mark({ class: "cm-draftly-table-cell cm-draftly-table-cell-center" }),
717
+ "td-right": Decoration.mark({ class: "cm-draftly-table-cell cm-draftly-table-cell-right" }),
718
+ "td-left-last": Decoration.mark({ class: "cm-draftly-table-cell cm-draftly-table-cell-last" }),
719
+ "td-center-last": Decoration.mark({
720
+ class: "cm-draftly-table-cell cm-draftly-table-cell-center cm-draftly-table-cell-last",
721
+ }),
722
+ "td-right-last": Decoration.mark({
723
+ class: "cm-draftly-table-cell cm-draftly-table-cell-right cm-draftly-table-cell-last",
724
+ }),
725
+ } as const;
726
+
727
+ type CellDecorationKey = keyof typeof cellDecorations;
728
+
729
+ function getCellDecoration(isHeader: boolean, alignment: Alignment, isLastCell: boolean): Decoration {
730
+ const key = `${isHeader ? "th" : "td"}-${alignment}${isLastCell ? "-last" : ""}` as CellDecorationKey;
731
+ return cellDecorations[key];
732
+ }
733
+
734
+ export class TablePlugin extends DecorationPlugin {
735
+ readonly name = "table";
736
+ readonly version = "2.0.0";
737
+ override decorationPriority = 20;
738
+ override readonly requiredNodes = ["Table", "TableHeader", "TableDelimiter", "TableRow", "TableCell"] as const;
739
+
740
+ private draftlyConfig: DraftlyConfig | undefined;
741
+ private pendingNormalizationView: EditorView | null = null;
742
+ private pendingPaddingView: EditorView | null = null;
743
+ private pendingSelectionRepairView: EditorView | null = null;
744
+
745
+ /** Stores the editor config for preview rendering and shared behavior. */
746
+ override onRegister(context: PluginContext): void {
747
+ super.onRegister(context);
748
+ this.draftlyConfig = context.config;
749
+ }
750
+
751
+ /** Exposes the plugin theme used for editor and preview styling. */
752
+ override get theme() {
753
+ return theme;
754
+ }
755
+
756
+ /** Enables GFM table parsing for the editor and preview renderer. */
757
+ override getMarkdownConfig(): MarkdownConfig {
758
+ return Table;
759
+ }
760
+
761
+ /** Registers block wrappers and atomic ranges for the table UI. */
762
+ override getExtensions(): Extension[] {
763
+ return [
764
+ Prec.highest(keymap.of(this.buildTableKeymap())),
765
+ EditorView.blockWrappers.of((view) => this.computeBlockWrappers(view)),
766
+ EditorView.atomicRanges.of((view) => this.computeAtomicRanges(view)),
767
+ EditorView.domEventHandlers({
768
+ keydown: (event, view) => this.handleDomKeydown(view, event),
769
+ }),
770
+ ];
771
+ }
772
+
773
+ /** Provides the table-specific keyboard shortcuts and navigation. */
774
+ override getKeymap(): KeyBinding[] {
775
+ return [];
776
+ }
777
+
778
+ /** Builds the high-priority key bindings used inside tables. */
779
+ private buildTableKeymap(): KeyBinding[] {
780
+ return [
781
+ { key: "Mod-Shift-t", run: (view) => this.insertTable(view), preventDefault: true },
782
+ { key: "Mod-Alt-ArrowDown", run: (view) => this.addRow(view), preventDefault: true },
783
+ { key: "Mod-Alt-ArrowRight", run: (view) => this.addColumn(view), preventDefault: true },
784
+ { key: "Mod-Alt-Backspace", run: (view) => this.removeRow(view), preventDefault: true },
785
+ { key: "Mod-Alt-Delete", run: (view) => this.removeColumn(view), preventDefault: true },
786
+ { key: "Tab", run: (view) => this.handleTab(view, false) },
787
+ { key: "Shift-Tab", run: (view) => this.handleTab(view, true) },
788
+ { key: "ArrowLeft", run: (view) => this.handleArrowHorizontal(view, false) },
789
+ { key: "ArrowRight", run: (view) => this.handleArrowHorizontal(view, true) },
790
+ { key: "ArrowUp", run: (view) => this.handleArrowVertical(view, false) },
791
+ { key: "ArrowDown", run: (view) => this.handleArrowVertical(view, true) },
792
+ { key: "Enter", run: (view) => this.handleEnter(view) },
793
+ { key: "Shift-Enter", run: (view) => this.insertBreakTag(view), preventDefault: true },
794
+ { key: "Backspace", run: (view) => this.handleBreakDeletion(view, false) },
795
+ { key: "Delete", run: (view) => this.handleBreakDeletion(view, true) },
796
+ ];
797
+ }
798
+
799
+ /** Schedules an initial normalization pass once the view is ready. */
800
+ override onViewReady(view: EditorView): void {
801
+ this.scheduleNormalization(view);
802
+ }
803
+
804
+ /** Re-schedules normalization after user-driven document changes. */
805
+ override onViewUpdate(update: import("@codemirror/view").ViewUpdate): void {
806
+ if (update.docChanged && !update.transactions.some((transaction) => transaction.annotation(normalizeAnnotation))) {
807
+ this.schedulePadding(update.view);
808
+ }
809
+
810
+ if (
811
+ update.selectionSet &&
812
+ !update.transactions.some((transaction) => transaction.annotation(repairSelectionAnnotation))
813
+ ) {
814
+ this.scheduleSelectionRepair(update.view);
815
+ }
816
+ }
817
+
818
+ /** Intercepts table-specific DOM key handling before browser defaults run. */
819
+ private handleDomKeydown(view: EditorView, event: KeyboardEvent): boolean {
820
+ if (event.defaultPrevented || event.isComposing || event.altKey || event.metaKey || event.ctrlKey) {
821
+ return false;
822
+ }
823
+
824
+ let handled = false;
825
+
826
+ if (event.key === "Tab") {
827
+ handled = this.handleTab(view, event.shiftKey);
828
+ } else if (event.key === "Enter" && event.shiftKey) {
829
+ handled = this.insertBreakTag(view);
830
+ } else if (event.key === "Enter") {
831
+ handled = this.handleEnter(view);
832
+ } else if (event.key === "ArrowLeft") {
833
+ handled = this.handleArrowHorizontal(view, false);
834
+ } else if (event.key === "ArrowRight") {
835
+ handled = this.handleArrowHorizontal(view, true);
836
+ } else if (event.key === "ArrowUp") {
837
+ handled = this.handleArrowVertical(view, false);
838
+ } else if (event.key === "ArrowDown") {
839
+ handled = this.handleArrowVertical(view, true);
840
+ } else if (event.key === "Backspace") {
841
+ handled = this.handleBreakDeletion(view, false);
842
+ } else if (event.key === "Delete") {
843
+ handled = this.handleBreakDeletion(view, true);
844
+ }
845
+
846
+ if (handled) {
847
+ event.preventDefault();
848
+ event.stopPropagation();
849
+ }
850
+
851
+ return handled;
852
+ }
853
+
854
+ /** Builds the visual table decorations for every parsed table block. */
855
+ override buildDecorations(ctx: DecorationContext): void {
856
+ const { view, decorations } = ctx;
857
+
858
+ syntaxTree(view.state).iterate({
859
+ enter: (node) => {
860
+ if (node.name !== "Table") {
861
+ return;
862
+ }
863
+
864
+ const tableInfo = readTableInfo(view.state, node.from, node.to);
865
+ if (tableInfo) {
866
+ this.decorateTable(view, decorations, tableInfo);
867
+ }
868
+ },
869
+ });
870
+ }
871
+
872
+ /** Renders the full table node to semantic preview HTML. */
873
+ override async renderToHTML(node: SyntaxNode, _children: string, ctx: PreviewContextLike): Promise<string | null> {
874
+ if (node.name === "Table") {
875
+ const content = ctx.sliceDoc(node.from, node.to);
876
+ const { tableMarkdown, trailingMarkdown } = splitTableAndTrailingMarkdown(content);
877
+ const parsed = parseTableMarkdown(tableMarkdown);
878
+ if (!parsed) {
879
+ return null;
880
+ }
881
+
882
+ const tableHtml = await renderTableToHtml(parsed, this.draftlyConfig);
883
+ if (!trailingMarkdown.trim()) {
884
+ return tableHtml;
885
+ }
886
+
887
+ return tableHtml + (await createPreviewRenderer(trailingMarkdown, this.draftlyConfig).render());
888
+ }
889
+
890
+ if (TABLE_SUB_NODE_NAMES.has(node.name)) {
891
+ return "";
892
+ }
893
+
894
+ return null;
895
+ }
896
+
897
+ /** Computes the block wrapper ranges used to group table lines. */
898
+ private computeBlockWrappers(view: EditorView): RangeSet<BlockWrapper> {
899
+ const wrappers: Range<BlockWrapper>[] = [];
900
+
901
+ syntaxTree(view.state).iterate({
902
+ enter: (node) => {
903
+ if (node.name !== "Table") {
904
+ return;
905
+ }
906
+
907
+ const tableInfo = readTableInfo(view.state, node.from, node.to);
908
+ if (tableInfo) {
909
+ wrappers.push(tableBlockWrapper.range(tableInfo.from, tableInfo.to));
910
+ }
911
+ },
912
+ });
913
+
914
+ return BlockWrapper.set(wrappers, true);
915
+ }
916
+
917
+ /** Computes atomic ranges for delimiters and inline break tags. */
918
+ private computeAtomicRanges(view: EditorView): RangeSet<Decoration> {
919
+ const ranges: Range<Decoration>[] = [];
920
+
921
+ syntaxTree(view.state).iterate({
922
+ enter: (node) => {
923
+ if (node.name !== "Table") {
924
+ return;
925
+ }
926
+
927
+ const tableInfo = readTableInfo(view.state, node.from, node.to);
928
+ if (!tableInfo) {
929
+ return;
930
+ }
931
+
932
+ for (let lineNumber = tableInfo.startLineNumber; lineNumber <= tableInfo.endLineNumber; lineNumber++) {
933
+ const line = view.state.doc.line(lineNumber);
934
+ if (lineNumber === tableInfo.delimiterLineNumber) {
935
+ ranges.push(delimiterReplace.range(line.from, line.to));
936
+ continue;
937
+ }
938
+
939
+ const pipes = getPipePositions(line.text);
940
+ for (const pipe of pipes) {
941
+ ranges.push(pipeReplace.range(line.from + pipe, line.from + pipe + 1));
942
+ }
943
+
944
+ for (let columnIndex = 0; columnIndex < pipes.length - 1; columnIndex++) {
945
+ const rawFrom = pipes[columnIndex]! + 1;
946
+ const rawTo = pipes[columnIndex + 1]!;
947
+ const rawText = line.text.slice(rawFrom, rawTo);
948
+ const visible = getVisibleBounds(rawText);
949
+
950
+ if (visible.startOffset > 0) {
951
+ ranges.push(pipeReplace.range(line.from + rawFrom, line.from + rawFrom + visible.startOffset));
952
+ }
953
+ if (visible.endOffset < rawText.length) {
954
+ ranges.push(pipeReplace.range(line.from + rawFrom + visible.endOffset, line.from + rawTo));
955
+ }
956
+
957
+ let match: RegExpExecArray | null;
958
+ const regex = new RegExp(BREAK_TAG_REGEX);
959
+ while ((match = regex.exec(rawText)) !== null) {
960
+ ranges.push(
961
+ Decoration.replace({ widget: new TableBreakWidget() }).range(
962
+ line.from + rawFrom + match.index,
963
+ line.from + rawFrom + match.index + match[0].length
964
+ )
965
+ );
966
+ }
967
+ }
968
+ }
969
+ },
970
+ });
971
+
972
+ return RangeSet.of(ranges, true);
973
+ }
974
+
975
+ /** Applies row, cell, and control decorations for a single table. */
976
+ private decorateTable(view: EditorView, decorations: Range<Decoration>[], tableInfo: TableInfo): void {
977
+ for (let lineNumber = tableInfo.startLineNumber; lineNumber <= tableInfo.endLineNumber; lineNumber++) {
978
+ const line = view.state.doc.line(lineNumber);
979
+ const isHeader = lineNumber === tableInfo.startLineNumber;
980
+ const isDelimiter = lineNumber === tableInfo.delimiterLineNumber;
981
+ const isLastBody = !isHeader && !isDelimiter && lineNumber === tableInfo.endLineNumber;
982
+ const bodyIndex = isHeader || isDelimiter ? -1 : lineNumber - tableInfo.delimiterLineNumber - 1;
983
+
984
+ if (isHeader) {
985
+ decorations.push(lineDecorations.header.range(line.from));
986
+ } else if (isDelimiter) {
987
+ decorations.push(lineDecorations.delimiter.range(line.from));
988
+ } else if (bodyIndex % 2 === 1) {
989
+ decorations.push(lineDecorations.even.range(line.from));
990
+ } else {
991
+ decorations.push(lineDecorations.body.range(line.from));
992
+ }
993
+
994
+ if (isLastBody) {
995
+ decorations.push(lineDecorations.last.range(line.from));
996
+ }
997
+
998
+ if (isDelimiter) {
999
+ decorations.push(delimiterReplace.range(line.from, line.to));
1000
+ continue;
1001
+ }
1002
+
1003
+ this.decorateLine(decorations, line.from, line.text, tableInfo.alignments, isHeader);
1004
+ }
1005
+
1006
+ decorations.push(
1007
+ Decoration.widget({
1008
+ widget: new TableControlsWidget(
1009
+ (view) => {
1010
+ const liveTable = getTableInfoAtPosition(view.state, tableInfo.from);
1011
+ if (liveTable) {
1012
+ this.appendRow(view, liveTable, liveTable.columnCount - 1);
1013
+ }
1014
+ },
1015
+ (view) => {
1016
+ const liveTable = getTableInfoAtPosition(view.state, tableInfo.from);
1017
+ if (liveTable) {
1018
+ this.appendColumn(view, liveTable);
1019
+ }
1020
+ }
1021
+ ),
1022
+ side: 1,
1023
+ }).range(tableInfo.to)
1024
+ );
1025
+ }
1026
+
1027
+ /** Applies the visual cell decorations for a single table row line. */
1028
+ private decorateLine(
1029
+ decorations: Range<Decoration>[],
1030
+ lineFrom: number,
1031
+ lineText: string,
1032
+ alignments: Alignment[],
1033
+ isHeader: boolean
1034
+ ): void {
1035
+ const pipes = getPipePositions(lineText);
1036
+ if (pipes.length < 2) {
1037
+ return;
1038
+ }
1039
+
1040
+ for (const pipe of pipes) {
1041
+ decorations.push(pipeReplace.range(lineFrom + pipe, lineFrom + pipe + 1));
1042
+ }
1043
+
1044
+ for (let columnIndex = 0; columnIndex < pipes.length - 1; columnIndex++) {
1045
+ const rawFrom = pipes[columnIndex]! + 1;
1046
+ const rawTo = pipes[columnIndex + 1]!;
1047
+ const rawText = lineText.slice(rawFrom, rawTo);
1048
+ const visible = getVisibleBounds(rawText);
1049
+ const absoluteFrom = lineFrom + rawFrom;
1050
+ const absoluteTo = lineFrom + rawTo;
1051
+
1052
+ if (visible.startOffset > 0) {
1053
+ decorations.push(pipeReplace.range(absoluteFrom, absoluteFrom + visible.startOffset));
1054
+ }
1055
+ if (visible.endOffset < rawText.length) {
1056
+ decorations.push(pipeReplace.range(absoluteFrom + visible.endOffset, absoluteTo));
1057
+ }
1058
+
1059
+ decorations.push(
1060
+ getCellDecoration(isHeader, alignments[columnIndex] || "left", columnIndex === pipes.length - 2).range(
1061
+ absoluteFrom,
1062
+ absoluteTo
1063
+ )
1064
+ );
1065
+
1066
+ let match: RegExpExecArray | null;
1067
+ const regex = new RegExp(BREAK_TAG_REGEX);
1068
+ while ((match = regex.exec(rawText)) !== null) {
1069
+ decorations.push(
1070
+ Decoration.replace({ widget: new TableBreakWidget() }).range(
1071
+ absoluteFrom + match.index,
1072
+ absoluteFrom + match.index + match[0].length
1073
+ )
1074
+ );
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ /** Normalizes every parsed table block back into canonical markdown. */
1080
+ private normalizeTables(view: EditorView): void {
1081
+ const changes: Array<{ from: number; to: number; insert: string }> = [];
1082
+
1083
+ syntaxTree(view.state).iterate({
1084
+ enter: (node) => {
1085
+ if (node.name !== "Table") {
1086
+ return;
1087
+ }
1088
+
1089
+ const content = view.state.sliceDoc(node.from, node.to);
1090
+ const { tableMarkdown } = splitTableAndTrailingMarkdown(content);
1091
+ const parsed = parseTableMarkdown(tableMarkdown);
1092
+ if (!parsed) {
1093
+ return;
1094
+ }
1095
+
1096
+ const formatted = formatTableMarkdown(parsed);
1097
+ const change = createTableInsert(view.state, node.from, node.from + tableMarkdown.length, formatted);
1098
+ if (
1099
+ change.insert !== tableMarkdown ||
1100
+ change.from !== node.from ||
1101
+ change.to !== node.from + tableMarkdown.length
1102
+ ) {
1103
+ changes.push({
1104
+ from: change.from,
1105
+ to: change.to,
1106
+ insert: change.insert,
1107
+ });
1108
+ }
1109
+ },
1110
+ });
1111
+
1112
+ if (changes.length > 0) {
1113
+ view.dispatch({
1114
+ changes: changes.sort((left, right) => right.from - left.from),
1115
+ annotations: normalizeAnnotation.of(true),
1116
+ });
1117
+ }
1118
+ }
1119
+
1120
+ /** Defers table normalization until the current update cycle is finished. */
1121
+ private scheduleNormalization(view: EditorView): void {
1122
+ if (this.pendingNormalizationView === view) {
1123
+ return;
1124
+ }
1125
+
1126
+ this.pendingNormalizationView = view;
1127
+ queueMicrotask(() => {
1128
+ if (this.pendingNormalizationView !== view) {
1129
+ return;
1130
+ }
1131
+
1132
+ this.pendingNormalizationView = null;
1133
+ this.normalizeTables(view);
1134
+ });
1135
+ }
1136
+
1137
+ /** Adds missing spacer lines above and below tables after edits. */
1138
+ private ensureTablePadding(view: EditorView): void {
1139
+ const changes: Array<{ from: number; to: number; insert: string }> = [];
1140
+
1141
+ syntaxTree(view.state).iterate({
1142
+ enter: (node) => {
1143
+ if (node.name !== "Table") {
1144
+ return;
1145
+ }
1146
+
1147
+ const tableInfo = readTableInfo(view.state, node.from, node.to);
1148
+ if (!tableInfo) {
1149
+ return;
1150
+ }
1151
+
1152
+ const startLine = view.state.doc.lineAt(tableInfo.from);
1153
+ if (startLine.number === 1) {
1154
+ changes.push({ from: startLine.from, to: startLine.from, insert: "\n" });
1155
+ } else {
1156
+ const previousLine = view.state.doc.line(startLine.number - 1);
1157
+ if (previousLine.text.trim() !== "") {
1158
+ changes.push({ from: startLine.from, to: startLine.from, insert: "\n" });
1159
+ }
1160
+ }
1161
+
1162
+ const endLine = view.state.doc.lineAt(tableInfo.to);
1163
+ if (endLine.number === view.state.doc.lines) {
1164
+ changes.push({ from: endLine.to, to: endLine.to, insert: "\n" });
1165
+ } else {
1166
+ const nextLine = view.state.doc.line(endLine.number + 1);
1167
+ if (nextLine.text.trim() !== "") {
1168
+ changes.push({ from: endLine.to, to: endLine.to, insert: "\n" });
1169
+ }
1170
+ }
1171
+ },
1172
+ });
1173
+
1174
+ if (changes.length > 0) {
1175
+ view.dispatch({
1176
+ changes: changes.sort((left, right) => right.from - left.from),
1177
+ annotations: normalizeAnnotation.of(true),
1178
+ });
1179
+ }
1180
+ }
1181
+
1182
+ /** Schedules a padding-only pass after the current update cycle finishes. */
1183
+ private schedulePadding(view: EditorView): void {
1184
+ if (this.pendingPaddingView === view) {
1185
+ return;
1186
+ }
1187
+
1188
+ this.pendingPaddingView = view;
1189
+ queueMicrotask(() => {
1190
+ if (this.pendingPaddingView !== view) {
1191
+ return;
1192
+ }
1193
+
1194
+ this.pendingPaddingView = null;
1195
+ this.ensureTablePadding(view);
1196
+ });
1197
+ }
1198
+
1199
+ /** Repairs carets that land in hidden table markup instead of editable cell content. */
1200
+ private ensureTableSelection(view: EditorView): void {
1201
+ const selection = view.state.selection.main;
1202
+ if (!selection.empty) {
1203
+ return;
1204
+ }
1205
+
1206
+ const tableInfo = getTableInfoAtPosition(view.state, selection.head);
1207
+ if (!tableInfo) {
1208
+ return;
1209
+ }
1210
+
1211
+ const cell = findCellAtPosition(tableInfo, selection.head);
1212
+ if (!cell) {
1213
+ return;
1214
+ }
1215
+
1216
+ const anchor = clampCellPosition(cell, selection.head);
1217
+ if (anchor === selection.head) {
1218
+ return;
1219
+ }
1220
+
1221
+ view.dispatch({
1222
+ selection: { anchor },
1223
+ annotations: repairSelectionAnnotation.of(true),
1224
+ scrollIntoView: true,
1225
+ });
1226
+ }
1227
+
1228
+ /** Schedules table selection repair after the current update finishes. */
1229
+ private scheduleSelectionRepair(view: EditorView): void {
1230
+ if (this.pendingSelectionRepairView === view) {
1231
+ return;
1232
+ }
1233
+
1234
+ this.pendingSelectionRepairView = view;
1235
+ queueMicrotask(() => {
1236
+ if (this.pendingSelectionRepairView !== view) {
1237
+ return;
1238
+ }
1239
+
1240
+ this.pendingSelectionRepairView = null;
1241
+ this.ensureTableSelection(view);
1242
+ });
1243
+ }
1244
+
1245
+ /** Rewrites a table block and restores the caret to a target cell position. */
1246
+ private replaceTable(
1247
+ view: EditorView,
1248
+ tableInfo: TableInfo,
1249
+ parsed: ParsedTable,
1250
+ targetRowIndex: number,
1251
+ targetColumnIndex: number,
1252
+ offset = 0
1253
+ ): void {
1254
+ const formatted = formatTableMarkdown(parsed);
1255
+ const change = createTableInsert(view.state, tableInfo.from, tableInfo.to, formatted);
1256
+ const selection =
1257
+ change.from +
1258
+ change.prefixLength +
1259
+ getCellAnchorInFormattedTable(
1260
+ formatted,
1261
+ Math.max(0, targetRowIndex),
1262
+ Math.max(0, Math.min(targetColumnIndex, Math.max(parsed.headers.length - 1, 0))),
1263
+ Math.max(0, offset)
1264
+ );
1265
+
1266
+ view.dispatch({
1267
+ changes: { from: change.from, to: change.to, insert: change.insert },
1268
+ selection: { anchor: selection },
1269
+ });
1270
+ }
1271
+
1272
+ /** Inserts an empty body row below the given logical row index. */
1273
+ private insertRowBelow(view: EditorView, tableInfo: TableInfo, afterRowIndex: number, targetColumn: number): void {
1274
+ const parsed = normalizeParsedTable(buildTableFromInfo(tableInfo));
1275
+ const insertBodyIndex = Math.max(0, Math.min(afterRowIndex, parsed.rows.length));
1276
+ parsed.rows.splice(insertBodyIndex, 0, buildEmptyRow(tableInfo.columnCount));
1277
+ this.replaceTable(view, tableInfo, parsed, insertBodyIndex + 1, targetColumn);
1278
+ }
1279
+
1280
+ /** Inserts a starter table near the current cursor line. */
1281
+ private insertTable(view: EditorView): boolean {
1282
+ const { state } = view;
1283
+ const cursor = state.selection.main.head;
1284
+ const line = state.doc.lineAt(cursor);
1285
+ const insertAt = line.text.trim() ? line.to : line.from;
1286
+ const formatted = formatTableMarkdown(TABLE_TEMPLATE);
1287
+ const change = createTableInsert(state, insertAt, insertAt, formatted);
1288
+ const selection = change.from + change.prefixLength + getCellAnchorInFormattedTable(formatted, 0, 0);
1289
+
1290
+ view.dispatch({
1291
+ changes: { from: change.from, to: change.to, insert: change.insert },
1292
+ selection: { anchor: selection },
1293
+ });
1294
+
1295
+ return true;
1296
+ }
1297
+
1298
+ /** Adds a new empty body row to the active table. */
1299
+ private addRow(view: EditorView): boolean {
1300
+ const tableInfo = this.getTableAtCursor(view);
1301
+ if (!tableInfo) {
1302
+ return false;
1303
+ }
1304
+
1305
+ const cell = this.getCurrentCell(view, tableInfo);
1306
+ this.appendRow(view, tableInfo, cell?.columnIndex || 0);
1307
+ return true;
1308
+ }
1309
+
1310
+ /** Appends a row and keeps the caret in the requested column. */
1311
+ private appendRow(view: EditorView, tableInfo: TableInfo, targetColumn: number): void {
1312
+ this.insertRowBelow(view, tableInfo, tableInfo.bodyCells.length, targetColumn);
1313
+ }
1314
+
1315
+ /** Inserts a new column after the current column. */
1316
+ private addColumn(view: EditorView): boolean {
1317
+ const tableInfo = this.getTableAtCursor(view);
1318
+ if (!tableInfo) {
1319
+ return false;
1320
+ }
1321
+
1322
+ const cell = this.getCurrentCell(view, tableInfo);
1323
+ const insertAfter = cell?.columnIndex ?? tableInfo.columnCount - 1;
1324
+ const parsed = normalizeParsedTable(buildTableFromInfo(tableInfo));
1325
+
1326
+ parsed.headers.splice(insertAfter + 1, 0, "");
1327
+ parsed.alignments.splice(insertAfter + 1, 0, "left");
1328
+ for (const row of parsed.rows) {
1329
+ row.splice(insertAfter + 1, 0, "");
1330
+ }
1331
+
1332
+ this.replaceTable(view, tableInfo, parsed, cell?.rowIndex || 0, insertAfter + 1);
1333
+
1334
+ return true;
1335
+ }
1336
+
1337
+ /** Appends a new column at the far right of the table. */
1338
+ private appendColumn(view: EditorView, tableInfo: TableInfo): void {
1339
+ const parsed = normalizeParsedTable(buildTableFromInfo(tableInfo));
1340
+ parsed.headers.push("");
1341
+ parsed.alignments.push("left");
1342
+ for (const row of parsed.rows) {
1343
+ row.push("");
1344
+ }
1345
+
1346
+ this.replaceTable(view, tableInfo, parsed, 0, parsed.headers.length - 1);
1347
+ }
1348
+
1349
+ /** Removes the current body row or clears the last remaining row. */
1350
+ private removeRow(view: EditorView): boolean {
1351
+ const tableInfo = this.getTableAtCursor(view);
1352
+ if (!tableInfo) {
1353
+ return false;
1354
+ }
1355
+
1356
+ const cell = this.getCurrentCell(view, tableInfo);
1357
+ if (!cell || cell.rowKind !== "body") {
1358
+ return false;
1359
+ }
1360
+
1361
+ const parsed = normalizeParsedTable(buildTableFromInfo(tableInfo));
1362
+ const bodyIndex = cell.rowIndex - 1;
1363
+ if (bodyIndex < 0 || bodyIndex >= parsed.rows.length) {
1364
+ return false;
1365
+ }
1366
+
1367
+ if (parsed.rows.length === 1) {
1368
+ parsed.rows[0] = buildEmptyRow(tableInfo.columnCount);
1369
+ } else {
1370
+ parsed.rows.splice(bodyIndex, 1);
1371
+ }
1372
+
1373
+ const nextRowIndex = Math.max(1, Math.min(cell.rowIndex, parsed.rows.length));
1374
+ this.replaceTable(view, tableInfo, parsed, nextRowIndex, Math.min(cell.columnIndex, tableInfo.columnCount - 1));
1375
+
1376
+ return true;
1377
+ }
1378
+
1379
+ /** Removes the current column when the table has more than one column. */
1380
+ private removeColumn(view: EditorView): boolean {
1381
+ const tableInfo = this.getTableAtCursor(view);
1382
+ if (!tableInfo || tableInfo.columnCount <= 1) {
1383
+ return false;
1384
+ }
1385
+
1386
+ const cell = this.getCurrentCell(view, tableInfo);
1387
+ const removeAt = cell?.columnIndex ?? tableInfo.columnCount - 1;
1388
+ const parsed = normalizeParsedTable(buildTableFromInfo(tableInfo));
1389
+
1390
+ parsed.headers.splice(removeAt, 1);
1391
+ parsed.alignments.splice(removeAt, 1);
1392
+ for (const row of parsed.rows) {
1393
+ row.splice(removeAt, 1);
1394
+ }
1395
+
1396
+ this.replaceTable(view, tableInfo, parsed, cell?.rowIndex || 0, Math.min(removeAt, parsed.headers.length - 1));
1397
+
1398
+ return true;
1399
+ }
1400
+
1401
+ /** Moves to the next or previous logical cell with Tab navigation. */
1402
+ private handleTab(view: EditorView, backwards: boolean): boolean {
1403
+ const tableInfo = this.getTableAtCursor(view);
1404
+ if (!tableInfo) {
1405
+ return false;
1406
+ }
1407
+
1408
+ const cell = this.getCurrentCell(view, tableInfo);
1409
+ if (!cell) {
1410
+ return false;
1411
+ }
1412
+
1413
+ const cells = tableInfo.cellsByRow.flat();
1414
+ const currentIndex = cells.findIndex((candidate) => candidate.from === cell.from && candidate.to === cell.to);
1415
+ if (currentIndex < 0) {
1416
+ return false;
1417
+ }
1418
+
1419
+ const nextIndex = backwards ? currentIndex - 1 : currentIndex + 1;
1420
+ if (nextIndex < 0) {
1421
+ return true;
1422
+ }
1423
+ if (nextIndex >= cells.length) {
1424
+ this.appendRow(view, tableInfo, 0);
1425
+ return true;
1426
+ }
1427
+
1428
+ this.moveSelectionToCell(view, cells[nextIndex]!);
1429
+ return true;
1430
+ }
1431
+
1432
+ /** Moves horizontally between adjacent cells when the caret hits an edge. */
1433
+ private handleArrowHorizontal(view: EditorView, forward: boolean): boolean {
1434
+ const tableInfo = this.getTableAtCursor(view);
1435
+ if (!tableInfo) {
1436
+ return false;
1437
+ }
1438
+
1439
+ const cell = this.getCurrentCell(view, tableInfo);
1440
+ if (!cell) {
1441
+ return false;
1442
+ }
1443
+
1444
+ const cursor = view.state.selection.main.head;
1445
+ const rightEdge = Math.max(cell.contentFrom, cell.contentTo);
1446
+ if (forward && cursor < rightEdge) {
1447
+ return false;
1448
+ }
1449
+ if (!forward && cursor > cell.contentFrom) {
1450
+ return false;
1451
+ }
1452
+
1453
+ const row = tableInfo.cellsByRow[cell.rowIndex] || [];
1454
+ const nextCell = row[cell.columnIndex + (forward ? 1 : -1)];
1455
+ if (!nextCell) {
1456
+ return false;
1457
+ }
1458
+
1459
+ this.moveSelectionToCell(view, nextCell);
1460
+ return true;
1461
+ }
1462
+
1463
+ /** Moves vertically between rows while keeping the current column. */
1464
+ private handleArrowVertical(view: EditorView, forward: boolean): boolean {
1465
+ const tableInfo = this.getTableAtCursor(view);
1466
+ if (!tableInfo) {
1467
+ return false;
1468
+ }
1469
+
1470
+ const cell = this.getCurrentCell(view, tableInfo);
1471
+ if (!cell) {
1472
+ return false;
1473
+ }
1474
+
1475
+ const nextRow = tableInfo.cellsByRow[cell.rowIndex + (forward ? 1 : -1)];
1476
+ if (!nextRow) {
1477
+ return false;
1478
+ }
1479
+
1480
+ const nextCell = nextRow[cell.columnIndex];
1481
+ if (!nextCell) {
1482
+ return false;
1483
+ }
1484
+
1485
+ this.moveSelectionToCell(view, nextCell);
1486
+ return true;
1487
+ }
1488
+
1489
+ /** Advances downward on Enter and manages the trailing empty row behavior. */
1490
+ private handleEnter(view: EditorView): boolean {
1491
+ const tableInfo = this.getTableAtCursor(view);
1492
+ if (!tableInfo) {
1493
+ return false;
1494
+ }
1495
+
1496
+ const cell = this.getCurrentCell(view, tableInfo);
1497
+ if (!cell) {
1498
+ return false;
1499
+ }
1500
+
1501
+ if (cell.rowKind === "body") {
1502
+ const currentRow = tableInfo.bodyCells[cell.rowIndex - 1];
1503
+ if (currentRow && isBodyRowEmpty(currentRow)) {
1504
+ const parsed = normalizeParsedTable(buildTableFromInfo(tableInfo));
1505
+ parsed.rows.splice(cell.rowIndex - 1, 1);
1506
+ const formatted = formatTableMarkdown(parsed);
1507
+ const change = createTableInsert(view.state, tableInfo.from, tableInfo.to, formatted);
1508
+ const anchor = Math.min(change.from + change.insert.length, view.state.doc.length + change.insert.length);
1509
+
1510
+ view.dispatch({
1511
+ changes: { from: change.from, to: change.to, insert: change.insert },
1512
+ selection: { anchor },
1513
+ });
1514
+ return true;
1515
+ }
1516
+ }
1517
+
1518
+ if (cell.rowKind === "body" && cell.rowIndex === tableInfo.cellsByRow.length - 1) {
1519
+ const parsed = normalizeParsedTable(buildTableFromInfo(tableInfo));
1520
+ parsed.rows.push(buildEmptyRow(tableInfo.columnCount));
1521
+ this.replaceTable(view, tableInfo, parsed, parsed.rows.length, cell.columnIndex);
1522
+ return true;
1523
+ }
1524
+
1525
+ this.insertRowBelow(view, tableInfo, cell.rowIndex, cell.columnIndex);
1526
+ return true;
1527
+ }
1528
+
1529
+ /** Inserts a canonical `<br />` token inside the current table cell. */
1530
+ private insertBreakTag(view: EditorView): boolean {
1531
+ const tableInfo = this.getTableAtCursor(view);
1532
+ if (!tableInfo) {
1533
+ return false;
1534
+ }
1535
+
1536
+ const selection = view.state.selection.main;
1537
+ view.dispatch({
1538
+ changes: { from: selection.from, to: selection.to, insert: BREAK_TAG },
1539
+ selection: { anchor: selection.from + BREAK_TAG.length },
1540
+ });
1541
+ return true;
1542
+ }
1543
+
1544
+ /** Deletes a whole `<br />` token when backspace or delete hits it. */
1545
+ private handleBreakDeletion(view: EditorView, forward: boolean): boolean {
1546
+ const tableInfo = this.getTableAtCursor(view);
1547
+ if (!tableInfo) {
1548
+ return false;
1549
+ }
1550
+
1551
+ const selection = view.state.selection.main;
1552
+ const cursor = selection.head;
1553
+
1554
+ for (const range of collectBreakRanges(tableInfo)) {
1555
+ const within = cursor > range.from && cursor < range.to;
1556
+ const matchesBackspace = !forward && cursor === range.to;
1557
+ const matchesDelete = forward && cursor === range.from;
1558
+ const overlapsSelection = !selection.empty && selection.from <= range.from && selection.to >= range.to;
1559
+
1560
+ if (within || matchesBackspace || matchesDelete || overlapsSelection) {
1561
+ view.dispatch({
1562
+ changes: { from: range.from, to: range.to, insert: "" },
1563
+ selection: { anchor: range.from },
1564
+ });
1565
+ return true;
1566
+ }
1567
+ }
1568
+
1569
+ return false;
1570
+ }
1571
+
1572
+ /** Moves the current selection anchor into a target cell. */
1573
+ private moveSelectionToCell(view: EditorView, cell: TableCellInfo, offset = 0): void {
1574
+ const end = Math.max(cell.contentFrom, cell.contentTo);
1575
+ view.dispatch({
1576
+ selection: { anchor: Math.min(cell.contentFrom + offset, end) },
1577
+ scrollIntoView: true,
1578
+ });
1579
+ }
1580
+
1581
+ /** Returns the table currently containing the editor cursor. */
1582
+ private getTableAtCursor(view: EditorView): TableInfo | null {
1583
+ return getTableInfoAtPosition(view.state, view.state.selection.main.head);
1584
+ }
1585
+
1586
+ /** Returns the active cell under the current selection head. */
1587
+ private getCurrentCell(view: EditorView, tableInfo: TableInfo): TableCellInfo | null {
1588
+ return findCellAtPosition(tableInfo, view.state.selection.main.head);
1589
+ }
1590
+ }
1591
+
1592
+ const theme = createTheme({
1593
+ default: {
1594
+ ".cm-draftly-table-wrapper, .cm-draftly-table-widget": {
1595
+ display: "table",
1596
+ width: "100%",
1597
+ borderCollapse: "separate",
1598
+ borderSpacing: "0",
1599
+ position: "relative",
1600
+ overflow: "visible",
1601
+ border: "1px solid var(--color-border, #d7dee7)",
1602
+ borderRadius: "0.75rem",
1603
+ backgroundColor: "var(--color-background, #ffffff)",
1604
+
1605
+ "& .cm-draftly-table": {
1606
+ width: "100%",
1607
+ borderCollapse: "separate",
1608
+ borderSpacing: "0",
1609
+ tableLayout: "auto",
1610
+ },
1611
+
1612
+ "& .cm-draftly-table-row": {
1613
+ display: "table-row !important",
1614
+ },
1615
+
1616
+ "& .cm-draftly-table-header-row": {
1617
+ backgroundColor: "rgba(15, 23, 42, 0.04)",
1618
+ },
1619
+
1620
+ "& .cm-draftly-table-row-even": {
1621
+ backgroundColor: "rgba(15, 23, 42, 0.02)",
1622
+ },
1623
+
1624
+ "& .cm-draftly-table-delimiter-row": {
1625
+ display: "none !important",
1626
+ },
1627
+
1628
+ "& .cm-draftly-table-cell": {
1629
+ display: "table-cell",
1630
+ minWidth: "4rem",
1631
+ minHeight: "2.5rem",
1632
+ height: "2.75rem",
1633
+ padding: "0.5rem 0.875rem",
1634
+ verticalAlign: "top",
1635
+ borderRight: "1px solid var(--color-border, #d7dee7)",
1636
+ borderBottom: "1px solid var(--color-border, #d7dee7)",
1637
+ whiteSpace: "normal",
1638
+ overflowWrap: "break-word",
1639
+ wordBreak: "normal",
1640
+ lineHeight: "1.6",
1641
+ },
1642
+
1643
+ "& .cm-draftly-table-body-row": {
1644
+ minHeight: "2.75rem",
1645
+ },
1646
+
1647
+ "& .cm-draftly-table-cell .cm-draftly-code-inline": {
1648
+ whiteSpace: "normal",
1649
+ overflowWrap: "anywhere",
1650
+ },
1651
+
1652
+ "& .cm-draftly-table-th": {
1653
+ fontWeight: "600",
1654
+ borderBottomWidth: "2px",
1655
+ },
1656
+
1657
+ "& .cm-draftly-table-cell-last": {
1658
+ borderRight: "none",
1659
+ },
1660
+
1661
+ "& .cm-draftly-table-row-last .cm-draftly-table-cell": {
1662
+ borderBottom: "none",
1663
+ },
1664
+
1665
+ "& .cm-draftly-table-cell-center": {
1666
+ textAlign: "center",
1667
+ },
1668
+
1669
+ "& .cm-draftly-table-cell-right": {
1670
+ textAlign: "right",
1671
+ },
1672
+
1673
+ "& .cm-draftly-table-break": {
1674
+ display: "inline",
1675
+ },
1676
+
1677
+ "& .cm-draftly-table-controls-anchor": {
1678
+ position: "absolute",
1679
+ inset: "0",
1680
+ pointerEvents: "none",
1681
+ },
1682
+
1683
+ "& .cm-draftly-table-control": {
1684
+ position: "absolute",
1685
+ width: "1.75rem",
1686
+ height: "1.75rem",
1687
+ border: "1px solid var(--color-border, #d7dee7)",
1688
+ borderRadius: "999px",
1689
+ backgroundColor: "var(--color-background, #ffffff)",
1690
+ color: "var(--color-text, #0f172a)",
1691
+ boxShadow: "0 10px 24px rgba(15, 23, 42, 0.12)",
1692
+ display: "inline-flex",
1693
+ alignItems: "center",
1694
+ justifyContent: "center",
1695
+ opacity: "0",
1696
+ pointerEvents: "auto",
1697
+ transition: "opacity 120ms ease, transform 120ms ease, background-color 120ms ease",
1698
+ },
1699
+
1700
+ "& .cm-draftly-table-control:hover": {
1701
+ backgroundColor: "rgba(15, 23, 42, 0.05)",
1702
+ },
1703
+
1704
+ "& .cm-draftly-table-control-column": {
1705
+ top: "50%",
1706
+ right: "-0.95rem",
1707
+ transform: "translate(0.35rem, -50%)",
1708
+ },
1709
+
1710
+ "& .cm-draftly-table-control-row": {
1711
+ left: "50%",
1712
+ bottom: "-0.95rem",
1713
+ transform: "translate(-50%, 0.35rem)",
1714
+ },
1715
+
1716
+ "&:hover .cm-draftly-table-control, &:focus-within .cm-draftly-table-control": {
1717
+ opacity: "1",
1718
+ },
1719
+
1720
+ "&:hover .cm-draftly-table-control-column, &:focus-within .cm-draftly-table-control-column": {
1721
+ transform: "translate(0, -50%)",
1722
+ },
1723
+
1724
+ "&:hover .cm-draftly-table-control-row, &:focus-within .cm-draftly-table-control-row": {
1725
+ transform: "translate(-50%, 0)",
1726
+ },
1727
+ },
1728
+ },
1729
+
1730
+ dark: {
1731
+ ".cm-draftly-table-wrapper, .cm-draftly-table-widget": {
1732
+ borderColor: "var(--color-border, #30363d)",
1733
+ backgroundColor: "var(--color-background, #0d1117)",
1734
+
1735
+ "& .cm-draftly-table-header-row": {
1736
+ backgroundColor: "rgba(255, 255, 255, 0.05)",
1737
+ },
1738
+
1739
+ "& .cm-draftly-table-row-even": {
1740
+ backgroundColor: "rgba(255, 255, 255, 0.025)",
1741
+ },
1742
+
1743
+ "& .cm-draftly-table-cell": {
1744
+ borderColor: "var(--color-border, #30363d)",
1745
+ },
1746
+
1747
+ "& .cm-draftly-table-control": {
1748
+ borderColor: "var(--color-border, #30363d)",
1749
+ backgroundColor: "var(--color-background, #161b22)",
1750
+ color: "var(--color-text, #e6edf3)",
1751
+ boxShadow: "0 12px 28px rgba(0, 0, 0, 0.35)",
1752
+ },
1753
+
1754
+ "& .cm-draftly-table-control:hover": {
1755
+ backgroundColor: "rgba(255, 255, 255, 0.08)",
1756
+ },
1757
+ },
1758
+ },
1759
+ });