mdsquare 0.0.0-stage → 0.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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -2
  3. package/dist/callout.d.ts +17 -0
  4. package/dist/callout.js +541 -0
  5. package/dist/calloutHeader.d.ts +47 -0
  6. package/dist/calloutHeader.js +75 -0
  7. package/dist/codeBlock.d.ts +78 -0
  8. package/dist/codeBlock.js +686 -0
  9. package/dist/codeTheme.d.ts +47 -0
  10. package/dist/codeTheme.js +394 -0
  11. package/dist/delimiter.d.ts +20 -0
  12. package/dist/delimiter.js +73 -0
  13. package/dist/editor.d.ts +114 -0
  14. package/dist/editor.js +199 -0
  15. package/dist/emphasis.d.ts +6 -0
  16. package/dist/emphasis.js +101 -0
  17. package/dist/escape.d.ts +4 -0
  18. package/dist/escape.js +39 -0
  19. package/dist/extensions.d.ts +66 -0
  20. package/dist/extensions.js +79 -0
  21. package/dist/footnote.d.ts +24 -0
  22. package/dist/footnote.js +456 -0
  23. package/dist/footnoteSyntax.d.ts +23 -0
  24. package/dist/footnoteSyntax.js +107 -0
  25. package/dist/hashtag.d.ts +4 -0
  26. package/dist/hashtag.js +64 -0
  27. package/dist/hashtagSyntax.d.ts +36 -0
  28. package/dist/hashtagSyntax.js +99 -0
  29. package/dist/heading.d.ts +7 -0
  30. package/dist/heading.js +159 -0
  31. package/dist/highlight.d.ts +5 -0
  32. package/dist/highlight.js +6 -0
  33. package/dist/horizontalRule.d.ts +21 -0
  34. package/dist/horizontalRule.js +142 -0
  35. package/dist/image.d.ts +58 -0
  36. package/dist/image.js +344 -0
  37. package/dist/index.d.ts +16 -0
  38. package/dist/index.js +16 -0
  39. package/dist/inlineCode.d.ts +4 -0
  40. package/dist/inlineCode.js +68 -0
  41. package/dist/link.d.ts +47 -0
  42. package/dist/link.js +217 -0
  43. package/dist/list.d.ts +14 -0
  44. package/dist/list.js +136 -0
  45. package/dist/listEnter.d.ts +20 -0
  46. package/dist/listEnter.js +72 -0
  47. package/dist/listIndent.d.ts +23 -0
  48. package/dist/listIndent.js +335 -0
  49. package/dist/markVision.d.ts +17 -0
  50. package/dist/markVision.js +56 -0
  51. package/dist/math.d.ts +45 -0
  52. package/dist/math.js +233 -0
  53. package/dist/mathSyntax.d.ts +24 -0
  54. package/dist/mathSyntax.js +179 -0
  55. package/dist/mention.d.ts +4 -0
  56. package/dist/mention.js +58 -0
  57. package/dist/mentionSyntax.d.ts +14 -0
  58. package/dist/mentionSyntax.js +71 -0
  59. package/dist/quote.d.ts +9 -0
  60. package/dist/quote.js +122 -0
  61. package/dist/setup.d.ts +13 -0
  62. package/dist/setup.js +40 -0
  63. package/dist/style.css +6 -0
  64. package/dist/style.css.d.ts +2 -0
  65. package/dist/table.d.ts +38 -0
  66. package/dist/table.js +960 -0
  67. package/dist/tableEdit.d.ts +30 -0
  68. package/dist/tableEdit.js +305 -0
  69. package/dist/tableMenu.d.ts +65 -0
  70. package/dist/tableMenu.js +1125 -0
  71. package/dist/tableModel.d.ts +168 -0
  72. package/dist/tableModel.js +433 -0
  73. package/dist/tableState.d.ts +36 -0
  74. package/dist/tableState.js +96 -0
  75. package/dist/task.d.ts +13 -0
  76. package/dist/task.js +179 -0
  77. package/dist/textMate.d.ts +49 -0
  78. package/dist/textMate.js +284 -0
  79. package/dist/tikz.d.ts +56 -0
  80. package/dist/tikz.js +343 -0
  81. package/dist/underline.d.ts +5 -0
  82. package/dist/underline.js +6 -0
  83. package/dist/utils/blockMargin.d.ts +6 -0
  84. package/dist/utils/blockMargin.js +6 -0
  85. package/dist/utils/codePoint.d.ts +13 -0
  86. package/dist/utils/codePoint.js +43 -0
  87. package/dist/utils/container.d.ts +10 -0
  88. package/dist/utils/container.js +10 -0
  89. package/dist/utils/reveal.d.ts +99 -0
  90. package/dist/utils/reveal.js +161 -0
  91. package/dist/utils/selection.d.ts +33 -0
  92. package/dist/utils/selection.js +45 -0
  93. package/dist/utils/syntaxTree.d.ts +18 -0
  94. package/dist/utils/syntaxTree.js +23 -0
  95. package/package.json +79 -4
package/dist/table.js ADDED
@@ -0,0 +1,960 @@
1
+ import { EditorSelection, EditorState as State, Facet, Prec, RangeSet, } from "@codemirror/state";
2
+ import { BlockWrapper, Decoration, EditorView, ViewPlugin, WidgetType, keymap, } from "@codemirror/view";
3
+ import { TABLE_CONTAINERS, clampToRow, extraCellsFrom, readTableRow, readTableRows, tableNodeAt, rowCursorRange, tableRowAt, TABLE_COLORS, } from "./tableModel.js";
4
+ import { editTableRow, rowRemovedChange } from "./tableEdit.js";
5
+ import { tableHandles, tableMenuTheme } from "./tableMenu.js";
6
+ import { selectedCells, tableBlockAt, tablesField, tableSelectionField, } from "./tableState.js";
7
+ import { RevealTracker, createRevealField, needsRebuild } from "./utils/reveal.js";
8
+ import { iterateVisibleSyntaxTree } from "./utils/syntaxTree.js";
9
+ // ---------------------------------------------------------------------------
10
+ // 표 전체를 감싸는 격자 (BlockWrapper)
11
+ // <div class="cm-table" style="--cm-table-columns: N"> ← grid: N개 열 + 남는 폭
12
+ // <div class="cm-line cm-table-row"> ← subgrid: 칸이 표의 열에 맞춰짐
13
+ // <span class="cm-table-cell cm-table-col-0">…</span> …
14
+ // 줄마다 따로 그려지는 CodeMirror 줄들이 subgrid로 같은 열 너비를 공유하므로, 열 너비는 브라우저가
15
+ // 표처럼 내용에 맞춰 정합니다. (넓으면 칸 안에서 줄바꿈)
16
+ // ---------------------------------------------------------------------------
17
+ const wrapperCache = new Map();
18
+ function wrapper(columns, classes) {
19
+ const key = `${columns}|${classes}`;
20
+ let value = wrapperCache.get(key);
21
+ if (!value) {
22
+ value = BlockWrapper.create({
23
+ tagName: "div",
24
+ attributes: { class: classes, style: `--cm-table-columns: ${columns}` },
25
+ });
26
+ wrapperCache.set(key, value);
27
+ }
28
+ return value;
29
+ }
30
+ const tableWrappers = EditorView.blockWrappers.compute([tablesField], (state) => {
31
+ const ranges = state.field(tablesField).map((table) => {
32
+ const cls = ["cm-table"];
33
+ if (table.options.headerRow)
34
+ cls.push("cm-table-header-row");
35
+ if (table.options.headerColumn)
36
+ cls.push("cm-table-header-column");
37
+ return wrapper(Math.max(1, table.columns), cls.join(" ")).range(table.lineFrom, table.lineTo);
38
+ });
39
+ return RangeSet.of(ranges);
40
+ });
41
+ // ---------------------------------------------------------------------------
42
+ // 숨기는 줄: 구분 줄(|---|)과 설정 주석. 커서가 그 줄에 닿으면 원문을 보여줍니다.
43
+ // ---------------------------------------------------------------------------
44
+ class HiddenLineWidget extends WidgetType {
45
+ toDOM() {
46
+ const el = document.createElement("div");
47
+ el.className = "cm-table-hidden-line";
48
+ return el;
49
+ }
50
+ eq() {
51
+ return true;
52
+ }
53
+ get estimatedHeight() {
54
+ return 0;
55
+ }
56
+ }
57
+ const hiddenLine = Decoration.replace({ block: true, widget: new HiddenLineWidget() });
58
+ const hiddenLinesField = createRevealField({
59
+ scan(state) {
60
+ const lines = [];
61
+ for (const table of state.field(tablesField)) {
62
+ if (table.comment)
63
+ lines.push({ from: table.comment.lineFrom, to: table.comment.lineTo });
64
+ lines.push(table.delimiterLine);
65
+ }
66
+ return lines;
67
+ },
68
+ build(items, revealed) {
69
+ return Decoration.set(items.flatMap((line, i) => (revealed[i] ? [] : [hiddenLine.range(line.from, line.to)])));
70
+ },
71
+ });
72
+ // ---------------------------------------------------------------------------
73
+ // 행 꾸밈 (보이는 범위만)
74
+ // ---------------------------------------------------------------------------
75
+ /** 칸 수가 모자란 행의 빈 칸 (display: contents라 칸마다 격자 칸이 됨) */
76
+ class MissingCellsWidget extends WidgetType {
77
+ /** 칸마다 클래스 */
78
+ classes;
79
+ constructor(classes) {
80
+ super();
81
+ this.classes = classes;
82
+ }
83
+ toDOM() {
84
+ const el = document.createElement("span");
85
+ el.className = "cm-table-missing";
86
+ for (const cls of this.classes) {
87
+ const cell = document.createElement("span");
88
+ cell.className = `${cls} cm-table-cell-missing`;
89
+ el.appendChild(cell);
90
+ }
91
+ return el;
92
+ }
93
+ eq(other) {
94
+ return other.classes.join("\n") === this.classes.join("\n");
95
+ }
96
+ ignoreEvent() {
97
+ return false;
98
+ }
99
+ }
100
+ /** ` cm-table-col-fg-red cm-table-row-bg-blue` (글자 색과 배경 색 따로) */
101
+ const colorClasses = (axis, pair) => (pair?.fg ? ` cm-table-${axis}-fg-${pair.fg}` : "") + (pair?.bg ? ` cm-table-${axis}-bg-${pair.bg}` : "");
102
+ const cellClass = (col, align, selected) => `cm-table-cell cm-table-col-${col}${align ? ` cm-table-align-${align}` : ""}${selected}`;
103
+ const markCache = new Map();
104
+ function cached(key, create) {
105
+ let deco = markCache.get(key);
106
+ if (!deco)
107
+ markCache.set(key, (deco = create()));
108
+ return deco;
109
+ }
110
+ // inclusive: 칸 경계에 있는 숨긴 조각("|", 공백)도 칸 요소 안에 그려지도록 합니다.
111
+ const cellMark = (col, align, selected) => cached(`cell:${col}:${align}:${selected}`, () => Decoration.mark({ class: cellClass(col, align, selected), inclusive: true }));
112
+ const rowLine = (index, indent) => cached(`line:${index === 0}:${indent}`, () => Decoration.line({
113
+ class: `cm-table-row${index === 0 ? " cm-table-row-0" : ""}${indent > 0 ? ` cm-table-indent cm-table-indent-${indent}` : ""}`,
114
+ }));
115
+ const extraMark = Decoration.mark({
116
+ class: "cm-table-extra",
117
+ attributes: { title: "머리글보다 많은 칸 (GFM에서는 무시됨). 칸 안에 | 를 쓰려면 \\| 로 쓰세요." },
118
+ });
119
+ const delimiterLine = Decoration.line({ class: "cm-table-delimiter-row" });
120
+ const markerMark = Decoration.mark({ class: "cm-formatting cm-table-cell-marker" });
121
+ const hidden = Decoration.replace({});
122
+ const atom = Decoration.mark({});
123
+ /** 목록 안 표의 들여쓰기(인용 기호 뒤 공백) 폭. 줄 왼쪽 여백으로 바꿔 그립니다. */
124
+ const MAX_INDENT = 40;
125
+ function indentOf(prefix) {
126
+ return Math.min(MAX_INDENT, prefix.replace(/^(?:[ \t]*>[ ]?)*/, "").length);
127
+ }
128
+ /**
129
+ * 메뉴로 고른 행/열의 테두리 클래스. 칸마다 바깥쪽 변만 그려 행/열 전체를 하나의 상자로 감쌉니다.
130
+ * (열: 모든 칸의 좌우 + 첫 행 위 + 마지막 행 아래, 행: 모든 칸의 위아래 + 첫 칸 왼쪽 + 마지막 칸 오른쪽)
131
+ */
132
+ function selectedClass(selection, col, row, columns, lastRow) {
133
+ if (!selection)
134
+ return "";
135
+ if (selection.kind === "cells" && selection.cells) {
136
+ const r = selection.cells;
137
+ if (row < r.fromRow || row > r.toRow || col < r.fromColumn || col > r.toColumn)
138
+ return "";
139
+ return (" cm-table-sel cm-table-picked" +
140
+ (row === r.fromRow ? " cm-table-sel-t" : "") +
141
+ (row === r.toRow ? " cm-table-sel-b" : "") +
142
+ (col === r.fromColumn ? " cm-table-sel-l" : "") +
143
+ (col === r.toColumn ? " cm-table-sel-r" : ""));
144
+ }
145
+ if (selection.kind === "column" && selection.index === col) {
146
+ return ` cm-table-sel cm-table-sel-l cm-table-sel-r${row === 0 ? " cm-table-sel-t" : ""}${row === lastRow ? " cm-table-sel-b" : ""}`;
147
+ }
148
+ if (selection.kind === "row" && selection.index === row) {
149
+ return ` cm-table-sel cm-table-sel-t cm-table-sel-b${col === 0 ? " cm-table-sel-l" : ""}${col === columns - 1 ? " cm-table-sel-r" : ""}`;
150
+ }
151
+ return "";
152
+ }
153
+ function decorateRow(state, row, table, selection, lastRow, reveal, out) {
154
+ const { doc } = state;
155
+ const { cells, pipes } = row;
156
+ const columns = table.columns;
157
+ // 꾸밈 클래스: 고른 행/열 테두리 + 행/열 색 (행 색이 열 색보다 우선: 테마에서 뒤에 정의)
158
+ const rowColor = colorClasses("row", table.colors.rows.get(row.index));
159
+ const selected = (col) => selectedClass(selection, col, row.index, columns, lastRow) + colorClasses("col", table.colors.columns.get(col)) + rowColor;
160
+ const prefix = doc.sliceString(row.lineFrom, row.from);
161
+ const indent = indentOf(prefix);
162
+ out.decorations.push(rowLine(row.index, indent).range(row.lineFrom));
163
+ // 목록 들여쓰기 공백은 숨기고 줄 왼쪽 여백으로 그립니다. (`>`는 quote()가 숨기거나 보여줌)
164
+ if (indent > 0)
165
+ out.decorations.push(hidden.range(row.from - indent, row.from));
166
+ const real = Math.min(cells.length, columns);
167
+ // 머리글보다 많은 칸: 바로 앞 "|"부터 줄 끝까지 원문 그대로, 마지막 칸 안에 흐리게 (칸 안에서 줄바꿈되어 넘치지 않음)
168
+ const end = row.lineTo;
169
+ let extraFrom = row.lineTo;
170
+ if (cells.length > columns) {
171
+ extraFrom = extraCellsFrom(row, columns);
172
+ out.decorations.push(extraMark.range(extraFrom, row.lineTo));
173
+ }
174
+ // 줄의 모든 글자가 어느 칸에 속하도록 칸 범위를 잡습니다. (접두어와 앞 "|"는 첫 칸, 칸 뒤 "|"는 그 칸, 줄 끝 공백은 마지막 칸)
175
+ // 칸 밖에 남는 조각이 있으면 격자 칸을 차지하거나, 숨긴 조각의 위치로 커서 좌표를 재게 됩니다.
176
+ for (let i = 0; i < real; i++) {
177
+ const cell = cells[i];
178
+ const align = table.aligns[i] ?? null;
179
+ const from = i === 0 ? row.lineFrom : cell.from;
180
+ const after = pipes.find((p) => p.from >= cell.to);
181
+ const to = i === real - 1 ? end : after ? after.to : end;
182
+ const { marker } = cell;
183
+ const cellColor = marker
184
+ ? `${marker.fg ? ` cm-table-cell-fg-${marker.fg}` : ""}${marker.bg ? ` cm-table-cell-bg-${marker.bg}` : ""}`
185
+ : "";
186
+ if (from < to)
187
+ out.outer.push(cellMark(i, align, selected(i) + cellColor).range(from, to));
188
+ const nonEmpty = cell.contentFrom < cell.contentTo;
189
+ // 색 표식: 커서가 닿으면 원문(흐리게), 아니면 숨김. 표식 뒤 공백까지 닿는 범위로 봅니다.
190
+ // (키보드로는 칸 내용 처음까지만 가므로, 표식 끝만 보면 펼칠 방법이 없음)
191
+ const revealed = marker ? reveal(marker.from, Math.max(marker.to, cell.textFrom)) : false;
192
+ if (marker && revealed) {
193
+ if (cell.from < marker.from)
194
+ out.decorations.push(hidden.range(cell.from, marker.from));
195
+ out.decorations.push(markerMark.range(marker.from, marker.to));
196
+ }
197
+ else if (marker || nonEmpty) {
198
+ // "|" 바로 옆 공백 한 칸씩만 숨깁니다. (빈 칸의 공백은 커서 자리로 남김) 표식이 있으면 표식과 그 뒤 공백까지.
199
+ // 공백을 모두 숨기면 내용 뒤에 친 공백이 숨긴 범위 안에 들어가, 이어서 입력한 글자가 엉뚱한 자리에 들어갑니다.
200
+ if (cell.from < cell.textFrom)
201
+ out.decorations.push(hidden.range(cell.from, cell.textFrom));
202
+ }
203
+ if (nonEmpty && cell.textTo < cell.to)
204
+ out.decorations.push(hidden.range(cell.textTo, cell.to));
205
+ // 칸 사이(보이는 글자 끝 ~ 다음 칸 보이는 글자 시작)는 한 번에 건너뜁니다. (펼친 표식 안으로는 들어갈 수 있게)
206
+ const start = i === 0 ? row.from : cells[i - 1].textTo;
207
+ const stop = revealed ? marker.from : cell.textFrom;
208
+ if (start < stop)
209
+ out.atomic.push(atom.range(start, stop));
210
+ }
211
+ if (real === 0 && row.lineFrom < end)
212
+ out.outer.push(cellMark(0, table.aligns[0] ?? null, selected(0)).range(row.lineFrom, end));
213
+ // "|"는 숨깁니다. 마지막 칸 뒤의 "|"는 줄 끝 공백까지 함께 숨깁니다.
214
+ const lastCellEnd = real > 0 ? cells[real - 1].to : row.from;
215
+ for (const pipe of pipes) {
216
+ if (pipe.from >= extraFrom)
217
+ break;
218
+ const trailing = pipe.from >= lastCellEnd && extraFrom === row.lineTo;
219
+ out.decorations.push(hidden.range(pipe.from, trailing ? row.lineTo : pipe.to));
220
+ }
221
+ if (real > 0 && cells.length <= columns) {
222
+ const last = cells[real - 1];
223
+ if (last.textTo < row.lineTo)
224
+ out.atomic.push(atom.range(last.textTo, row.lineTo));
225
+ }
226
+ // 칸 수가 모자라면 빈 칸 모양을 채웁니다.
227
+ const missingFrom = Math.max(real, 1);
228
+ if (missingFrom < columns) {
229
+ const classes = [];
230
+ for (let i = missingFrom; i < columns; i++)
231
+ classes.push(cellClass(i, table.aligns[i] ?? null, selected(i)));
232
+ out.decorations.push(Decoration.widget({ widget: new MissingCellsWidget(classes), side: 1 }).range(row.lineTo));
233
+ }
234
+ }
235
+ const sameRange = (a, b) => a === b ||
236
+ (!!a && !!b && a.fromRow === b.fromRow && a.toRow === b.toRow && a.fromColumn === b.fromColumn && a.toColumn === b.toColumn);
237
+ class TableRowsPlugin {
238
+ decorations = Decoration.none;
239
+ outer = Decoration.none;
240
+ atomic = Decoration.none;
241
+ /** 칸 범위를 고른 중인지 (CodeMirror의 글자 선택 표시를 숨김) */
242
+ cellSelection = null;
243
+ /** 색 표식의 펼침 여부 (선택만 바뀌었을 때 다시 그릴지 판단) */
244
+ tracker = new RevealTracker();
245
+ constructor(view) {
246
+ this.build(view);
247
+ }
248
+ update(update) {
249
+ const cells = update.selectionSet ? selectedCells(update.state) : this.cellSelection;
250
+ if (needsRebuild(update) ||
251
+ update.startState.field(tablesField) !== update.state.field(tablesField) ||
252
+ update.startState.field(tableSelectionField) !== update.state.field(tableSelectionField) ||
253
+ (update.selectionSet && (this.tracker.changed(update.state.selection) || !sameRange(cells, this.cellSelection) || cells?.table !== this.cellSelection?.table))) {
254
+ this.build(update.view);
255
+ }
256
+ }
257
+ build(view) {
258
+ const { state } = view;
259
+ const out = { decorations: [], outer: [], atomic: [] };
260
+ const ranges = view.visibleRanges;
261
+ const visible = (from, to) => ranges.some((r) => from <= r.to && to >= r.from);
262
+ const menu = state.field(tableSelectionField, false) ?? null;
263
+ const cells = selectedCells(state);
264
+ this.cellSelection = cells;
265
+ const tracker = this.tracker;
266
+ tracker.reset();
267
+ // 칸 범위를 고른 동안에는 색 표식을 펼치지 않습니다. (선택이 표식을 덮어 모두 펼쳐짐)
268
+ const reveal = (from, to) => !cells && tracker.track(state.selection, from, to);
269
+ iterateVisibleSyntaxTree(view, {
270
+ enter(node) {
271
+ if (node.name === "Table") {
272
+ const table = tableBlockAt(state, node.from);
273
+ if (!table)
274
+ return false;
275
+ // 메뉴로 고른 행/열/칸이 있으면 그것을, 없으면 드래그로 고른 칸 범위를 표시합니다.
276
+ const picked = menu?.table === table.from
277
+ ? menu
278
+ : cells?.table === table.from
279
+ ? { table: table.from, kind: "cells", index: 0, cells }
280
+ : null;
281
+ let lastRow = -1;
282
+ for (let child = node.node.firstChild; child; child = child.nextSibling) {
283
+ if (child.name === "TableHeader" || child.name === "TableRow")
284
+ lastRow++;
285
+ }
286
+ let index = 0;
287
+ for (let child = node.node.firstChild; child; child = child.nextSibling) {
288
+ if (child.name === "TableHeader" || child.name === "TableRow") {
289
+ if (visible(child.from, child.to)) {
290
+ decorateRow(state, readTableRow(state.doc, child, index), table, picked, lastRow, reveal, out);
291
+ }
292
+ index++;
293
+ }
294
+ else if (child.name === "TableDelimiter" && child.from >= table.delimiterLine.from && child.to <= table.delimiterLine.to && index === 1) {
295
+ out.decorations.push(delimiterLine.range(table.delimiterLine.from));
296
+ }
297
+ }
298
+ return false;
299
+ }
300
+ if (!TABLE_CONTAINERS.has(node.name))
301
+ return false;
302
+ },
303
+ });
304
+ this.decorations = Decoration.set(out.decorations, true);
305
+ this.outer = Decoration.set(out.outer, true);
306
+ this.atomic = Decoration.set(out.atomic, true);
307
+ }
308
+ }
309
+ /**
310
+ * 마우스 위치 → 문서 위치. 마우스가 표 칸 위에 있으면 그 칸 안의 위치를 돌려줍니다.
311
+ * 칸의 빈 곳(글자 밖 여백, 오른쪽 정렬 칸의 왼쪽, 빈 칸 모양)에서는 CodeMirror가 가장 가까운 글자를 골라 옆 칸에 놓기도 하므로,
312
+ * 그 칸의 처음(왼쪽 절반)이나 끝(오른쪽 절반)으로 바로잡습니다. 표 칸 위가 아니면 null
313
+ */
314
+ function cellPosAt(view, event) {
315
+ const doc = view.dom.ownerDocument;
316
+ const under = doc.elementFromPoint?.(event.clientX, event.clientY) ?? (event.target instanceof Element ? event.target : null);
317
+ const cellEl = under?.closest(".cm-table-cell") ?? null;
318
+ const line = cellEl?.closest(".cm-table-row");
319
+ const col = cellEl ? /\bcm-table-col-(\d+)\b/.exec(cellEl.className) : null;
320
+ if (!cellEl || !line || !col || !view.contentDOM.contains(line))
321
+ return null;
322
+ let lineFrom;
323
+ try {
324
+ lineFrom = view.posAtDOM(line);
325
+ }
326
+ catch {
327
+ return null;
328
+ }
329
+ const found = tableRowAt(view.state, lineFrom);
330
+ const block = found && tableBlockAt(view.state, found.table.from);
331
+ if (!found || !block)
332
+ return null;
333
+ const { row } = found;
334
+ const cell = row.cells[Number(col[1])];
335
+ // 칸 수가 모자란 행의 빈 칸 모양: 행 끝
336
+ if (!cell)
337
+ return rowCursorRange(row, block.columns)?.end ?? row.lineTo;
338
+ let pos;
339
+ try {
340
+ pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
341
+ }
342
+ catch {
343
+ pos = null; // 레이아웃을 잴 수 없는 환경
344
+ }
345
+ if (pos !== null && pos >= cell.from && pos <= cell.to)
346
+ return pos;
347
+ if (pos !== null && row.cells.length > block.columns && pos >= extraCellsFrom(row, block.columns))
348
+ return pos;
349
+ const rect = cellEl.getBoundingClientRect();
350
+ return event.clientX > rect.left + rect.width / 2 ? cell.textTo : cell.textFrom;
351
+ }
352
+ /**
353
+ * 표 칸에서 시작하는 마우스 선택: 누를 때와 끄는 동안 모두 마우스 아래 칸 기준으로 위치를 정합니다.
354
+ * (두 칸 이상에 걸치면 칸 범위 선택이 됩니다. 두 번/세 번 누르기와 보조 키 선택은 기본 동작)
355
+ */
356
+ const tableMouseSelection = EditorView.mouseSelectionStyle.of((view, start) => {
357
+ if (start.button !== 0 || start.detail > 1 || start.metaKey || start.ctrlKey || start.altKey)
358
+ return null;
359
+ const first = cellPosAt(view, start);
360
+ if (first === null)
361
+ return null;
362
+ let anchor = start.shiftKey ? view.state.selection.main.anchor : first;
363
+ return {
364
+ get(event) {
365
+ let head = cellPosAt(view, event);
366
+ if (head === null) {
367
+ try {
368
+ head = view.posAtCoords({ x: event.clientX, y: event.clientY }, false);
369
+ }
370
+ catch {
371
+ head = anchor;
372
+ }
373
+ }
374
+ return EditorSelection.single(anchor, head);
375
+ },
376
+ update(update) {
377
+ if (update.docChanged)
378
+ anchor = update.changes.mapPos(anchor);
379
+ },
380
+ };
381
+ });
382
+ const tableRowsPlugin = ViewPlugin.fromClass(TableRowsPlugin, {
383
+ decorations: (plugin) => plugin.decorations,
384
+ provide: (plugin) => [
385
+ // 칸은 다른 꾸밈(굵게, 링크 등)보다 바깥에 있어야 격자의 칸이 됩니다.
386
+ EditorView.outerDecorations.of((view) => view.plugin(plugin)?.outer ?? Decoration.none),
387
+ EditorView.atomicRanges.of((view) => view.plugin(plugin)?.atomic ?? Decoration.none),
388
+ // 칸 범위를 고르는 동안에는 칸을 칠해 보여 주므로, 격자를 가로지르는 글자 선택 표시는 숨깁니다.
389
+ EditorView.editorAttributes.of((view) => (view.plugin(plugin)?.cellSelection ? { class: "cm-table-cells-selected" } : null)),
390
+ ],
391
+ });
392
+ /** 커서가 있는 칸 (단일 빈 커서일 때만) */
393
+ export function cellAt(state, pos) {
394
+ const table = tableNodeAt(state, pos);
395
+ if (!table)
396
+ return null;
397
+ const block = tableBlockAt(state, table.from);
398
+ if (!block)
399
+ return null;
400
+ const rows = readTableRows(state, table);
401
+ const line = state.doc.lineAt(pos);
402
+ if (line.from === block.delimiterLine.from)
403
+ return { table, block, rows, row: -1, cell: 0 };
404
+ const row = rows.findIndex((r) => r.lineFrom === line.from);
405
+ if (row < 0)
406
+ return null;
407
+ const { cells } = rows[row];
408
+ let cell = cells.findIndex((c) => pos <= c.to);
409
+ if (cell < 0)
410
+ cell = Math.max(0, cells.length - 1);
411
+ return { table, block, rows, row, cell };
412
+ }
413
+ function singleCursor(state) {
414
+ const { ranges } = state.selection;
415
+ return ranges.length === 1 ? ranges[0].head : null;
416
+ }
417
+ const isRowEmpty = (row) => row.cells.every((c) => c.contentFrom === c.contentTo);
418
+ /** index번째 행 아래에 빈 행을 넣는 트랜잭션 (머리글 줄이면 구분 줄 뒤). 커서는 새 행의 첫 칸, 행 색 번호도 옮김 */
419
+ function insertRowBelow(state, block, index) {
420
+ const spec = editTableRow(state, block, index, "insertBelow");
421
+ return spec && state.update(spec, { scrollIntoView: true, userEvent: "input" });
422
+ }
423
+ /** 행 줄의 글자를 지우거나(빈 줄로 남김) 줄째 지우는 트랜잭션. 행 색 번호도 함께 옮깁니다. */
424
+ function removeRow(state, block, index, change, cursor) {
425
+ const changes = state.changes([change, ...[rowRemovedChange(state, block, index)].filter((c) => c !== null)]);
426
+ return state.update({ changes, selection: EditorSelection.cursor(changes.mapPos(cursor, -1)), scrollIntoView: true, userEvent: "delete" });
427
+ }
428
+ /** Enter: 아래에 새 행. 비어 있는 마지막 행에서는 표를 끝냅니다(행을 빈 줄로). */
429
+ export const insertTableRow = ({ state, dispatch }) => {
430
+ const pos = singleCursor(state);
431
+ if (pos === null)
432
+ return false;
433
+ const at = cellAt(state, pos);
434
+ if (!at)
435
+ return false;
436
+ const { rows, row, block } = at;
437
+ if (row > 0 && row === rows.length - 1 && isRowEmpty(rows[row])) {
438
+ const r = rows[row];
439
+ dispatch(removeRow(state, block, row, { from: r.from, to: r.lineTo }, r.from));
440
+ return true;
441
+ }
442
+ // 머리글 줄/구분 줄이면 구분 줄 뒤에 넣습니다.
443
+ const tr = insertRowBelow(state, block, Math.max(0, row));
444
+ if (tr)
445
+ dispatch(tr);
446
+ return true;
447
+ };
448
+ function moveCell(forward) {
449
+ return ({ state, dispatch }) => {
450
+ const pos = singleCursor(state);
451
+ if (pos === null)
452
+ return false;
453
+ const at = cellAt(state, pos);
454
+ if (!at)
455
+ return false;
456
+ const { rows } = at;
457
+ // 구분 줄은 머리글 줄의 끝으로 봅니다.
458
+ let row = at.row < 0 ? 0 : at.row;
459
+ let cell = at.row < 0 ? rows[0].cells.length - 1 : at.cell;
460
+ const go = (target) => {
461
+ dispatch(state.update({ selection: EditorSelection.cursor(target), scrollIntoView: true, userEvent: "select" }));
462
+ return true;
463
+ };
464
+ if (forward) {
465
+ const current = rows[row];
466
+ if (cell + 1 < current.cells.length)
467
+ return go(current.cells[cell + 1].contentTo);
468
+ if (at.row >= 0 && current.cells.length < at.block.columns) {
469
+ // 칸이 모자란 행: 빈 칸을 하나 덧붙이고 그 칸으로
470
+ dispatch(state.update(appendCell(current), { scrollIntoView: true, userEvent: "input" }));
471
+ return true;
472
+ }
473
+ for (row++; row < rows.length; row++)
474
+ if (rows[row].cells.length > 0)
475
+ return go(rows[row].cells[0].contentTo);
476
+ // 마지막 칸: 새 행
477
+ const tr = insertRowBelow(state, at.block, rows.length - 1);
478
+ if (tr)
479
+ dispatch(tr);
480
+ return true;
481
+ }
482
+ if (cell > 0)
483
+ return go(rows[row].cells[cell - 1].contentTo);
484
+ for (row--; row >= 0; row--) {
485
+ const cells = rows[row].cells;
486
+ if (cells.length > 0)
487
+ return go(cells[cells.length - 1].contentTo);
488
+ }
489
+ return true; // 첫 칸: 표 밖으로 나가지 않음
490
+ };
491
+ }
492
+ /** 행 끝에 빈 칸 하나 (`| a |` → `| a | |`, `a | b` → `a | b | |`). 커서는 새 칸 */
493
+ function appendCell(row) {
494
+ const last = row.cells[row.cells.length - 1];
495
+ const pipe = row.pipes[row.pipes.length - 1];
496
+ if (pipe && (!last || pipe.from >= last.to)) {
497
+ return { changes: { from: pipe.to, to: row.lineTo, insert: " |" }, selection: EditorSelection.cursor(pipe.to + 1) };
498
+ }
499
+ // 뒤 "|"가 없는 행(`a | b`): 끝에 " | " 만 붙이면 GFM에서 칸이 아니므로 "|"로 닫습니다. (`a | b | |`)
500
+ return { changes: { from: row.lineTo, insert: " | |" }, selection: EditorSelection.cursor(row.lineTo + 3) };
501
+ }
502
+ export const nextTableCell = moveCell(true);
503
+ export const previousTableCell = moveCell(false);
504
+ /**
505
+ * 칸 경계에서 지우기: "|"를 지워 칸이 합쳐지지 않도록, 칸 처음에서 Backspace는 앞 칸 끝으로,
506
+ * 칸 끝에서 Delete는 다음 칸 처음으로 커서만 옮깁니다.
507
+ * 칸 안에서 단어/줄 단위로 지우면 칸 경계에서 멈춥니다.
508
+ * 비어 있는 마지막 행의 첫 칸에서 Backspace는 그 행을 지웁니다.
509
+ */
510
+ function deleteInCell(forward, by) {
511
+ return ({ state, dispatch }) => {
512
+ const { ranges } = state.selection;
513
+ if (ranges.length !== 1 || !ranges[0].empty)
514
+ return false;
515
+ const pos = ranges[0].head;
516
+ const at = cellAt(state, pos);
517
+ if (!at || at.row < 0)
518
+ return false;
519
+ const row = at.rows[at.row];
520
+ const cell = row.cells[at.cell];
521
+ if (!cell)
522
+ return false;
523
+ const atEdge = forward ? pos >= cell.textTo : pos <= cell.textFrom;
524
+ if (atEdge) {
525
+ if (!forward && at.cell === 0 && at.row > 0 && at.row === at.rows.length - 1 && isRowEmpty(row)) {
526
+ // 빈 마지막 행 지우기 → 윗행 마지막 칸 끝으로
527
+ const above = at.rows[at.row - 1];
528
+ const target = above.cells.length > 0 ? above.cells[above.cells.length - 1].textTo : above.lineTo;
529
+ dispatch(removeRow(state, at.block, at.row, { from: above.lineTo, to: row.lineTo }, target));
530
+ return true;
531
+ }
532
+ const next = forward ? row.cells[at.cell + 1] : row.cells[at.cell - 1];
533
+ if (next) {
534
+ const target = forward ? next.textFrom : next.textTo;
535
+ dispatch(state.update({ selection: EditorSelection.cursor(target), userEvent: "select" }));
536
+ }
537
+ return true; // 행의 처음/끝: 아무것도 지우지 않음
538
+ }
539
+ if (by === "char")
540
+ return false; // 칸 안의 한 글자는 기본 동작
541
+ const text = forward ? state.doc.sliceString(pos, cell.textTo) : state.doc.sliceString(cell.textFrom, pos);
542
+ const length = by === "line" ? text.length : wordLength(text, forward);
543
+ const from = forward ? pos : pos - length;
544
+ const to = forward ? pos + length : pos;
545
+ dispatch(state.update({ changes: { from, to }, selection: EditorSelection.cursor(from), userEvent: "delete" }));
546
+ return true;
547
+ };
548
+ }
549
+ /**
550
+ * 표 바로 바깥 줄에서 줄바꿈을 지워 표 줄과 합치지 않도록 합니다.
551
+ * - 표 윗줄 끝에서 Delete: 글자가 있으면 표 첫 칸으로 이동, 비었거나 공백뿐이면 그 줄을 지움(표에 들여쓰기가 붙지 않게)
552
+ * - 표 아랫줄 처음에서 Backspace: 글자가 있으면 마지막 행 끝으로 이동 (빈 줄이면 기본 동작으로 지움)
553
+ */
554
+ function guardTableEdge(forward) {
555
+ return ({ state, dispatch }) => {
556
+ const { ranges } = state.selection;
557
+ if (ranges.length !== 1 || !ranges[0].empty)
558
+ return false;
559
+ const pos = ranges[0].head;
560
+ const { doc } = state;
561
+ const line = doc.lineAt(pos);
562
+ const blank = line.text.trim() === "";
563
+ if (forward) {
564
+ if (pos !== line.to || line.number === doc.lines)
565
+ return false;
566
+ const next = doc.line(line.number + 1);
567
+ const table = state.field(tablesField, false)?.find((t) => (t.comment ? t.comment.lineFrom : t.lineFrom) === next.from);
568
+ if (!table)
569
+ return false;
570
+ if (blank) {
571
+ dispatch(state.update({ changes: { from: line.from, to: next.from }, selection: EditorSelection.cursor(line.from), userEvent: "delete" }));
572
+ return true;
573
+ }
574
+ const first = tableRowAt(state, table.lineFrom);
575
+ const start = first && rowCursorRange(first.row, table.columns)?.start;
576
+ dispatch(state.update({ selection: EditorSelection.cursor(start ?? table.lineFrom), userEvent: "select" }));
577
+ return true;
578
+ }
579
+ if (pos !== line.from || line.number === 1 || blank)
580
+ return false;
581
+ const above = tableRowAt(state, doc.line(line.number - 1).from);
582
+ if (!above)
583
+ return false;
584
+ const block = tableBlockAt(state, above.table.from);
585
+ const end = block && rowCursorRange(above.row, block.columns)?.end;
586
+ dispatch(state.update({ selection: EditorSelection.cursor(end ?? above.row.lineTo), userEvent: "select" }));
587
+ return true;
588
+ };
589
+ }
590
+ /** 칸 안의 지우기, 아니면 표 바로 바깥 줄의 줄바꿈 지우기 보호 */
591
+ function deleteKey(forward, by) {
592
+ const inCell = deleteInCell(forward, by);
593
+ const edge = guardTableEdge(forward);
594
+ return (target) => inCell(target) || edge(target);
595
+ }
596
+ /** 앞(또는 뒤)에서부터 지울 단어 길이: 공백 + 같은 종류(글자/기호) 연속 */
597
+ function wordLength(text, forward) {
598
+ const re = forward ? /^\s*(?:[\p{L}\p{N}_]+|[^\p{L}\p{N}_\s]+)?/u : /(?:[\p{L}\p{N}_]+|[^\p{L}\p{N}_\s]+)?\s*$/u;
599
+ const match = re.exec(text);
600
+ return Math.max(1, match ? match[0].length : 1);
601
+ }
602
+ // ---------------------------------------------------------------------------
603
+ // 커서 위치 보정: 빈 커서는 칸 내용 안에만
604
+ // ---------------------------------------------------------------------------
605
+ /**
606
+ * 빈 커서가 표 행의 앞 "|" 앞(접두어 포함)이나 뒤 "|" 뒤에 놓이면 첫 칸/마지막 칸 내용으로 옮깁니다.
607
+ * 키보드(←/→/Home/End/↑/↓), 클릭, 명령 등 어떤 경로든 같은 규칙을 적용합니다.
608
+ * (구분 줄과 선택 영역은 건드리지 않습니다)
609
+ */
610
+ export function clampTableCursor(state, pos) {
611
+ const found = tableRowAt(state, pos);
612
+ if (!found)
613
+ return pos;
614
+ const block = tableBlockAt(state, found.table.from);
615
+ if (!block)
616
+ return pos;
617
+ return clampToRow(found.row, block.columns, pos);
618
+ }
619
+ /** 선택을 보정한 결과. 바꿀 것이 없으면 null */
620
+ function clampSelection(state, selection) {
621
+ let changed = false;
622
+ const next = selection.ranges.map((range) => {
623
+ if (!range.empty)
624
+ return range;
625
+ const pos = clampTableCursor(state, range.head);
626
+ if (pos === range.head)
627
+ return range;
628
+ changed = true;
629
+ return EditorSelection.cursor(pos);
630
+ });
631
+ return changed ? EditorSelection.create(next, selection.mainIndex) : null;
632
+ }
633
+ const cursorGuard = State.transactionFilter.of((tr) => {
634
+ // 선택을 지정한 트랜잭션과, 문서가 바뀌어 선택이 옮겨진 트랜잭션
635
+ if ((!tr.selection && !tr.docChanged) || !tr.state.field(tablesField, false)?.length)
636
+ return tr;
637
+ const selection = clampSelection(tr.state, tr.newSelection);
638
+ return selection ? [tr, { selection, sequential: true }] : tr;
639
+ });
640
+ /** 처음 만든 상태의 커서(트랜잭션을 거치지 않음)도 보정합니다. 예: 표로 시작하는 문서의 커서 0 */
641
+ const initialCursorGuard = ViewPlugin.define((view) => {
642
+ let alive = true;
643
+ queueMicrotask(() => {
644
+ if (!alive)
645
+ return;
646
+ const selection = clampSelection(view.state, view.state.selection);
647
+ if (selection)
648
+ view.dispatch({ selection });
649
+ });
650
+ return {
651
+ destroy() {
652
+ alive = false;
653
+ },
654
+ };
655
+ });
656
+ /**
657
+ * ←/→로 행 경계를 넘습니다. 첫 칸 처음에서 ←는 윗행 마지막 칸 끝(머리글 줄이면 표 윗줄 끝),
658
+ * 마지막 칸 끝에서 →는 아랫행 첫 칸 처음(마지막 행이면 표 아랫줄 처음). 칸 사이는 기본 이동(숨긴 "|"를 한 번에)
659
+ */
660
+ function crossRow(forward) {
661
+ return ({ state, dispatch }) => {
662
+ const { ranges } = state.selection;
663
+ if (ranges.length !== 1 || !ranges[0].empty)
664
+ return false;
665
+ const pos = ranges[0].head;
666
+ const at = cellAt(state, pos);
667
+ if (!at || at.row < 0)
668
+ return false;
669
+ const { rows, block } = at;
670
+ const range = rowCursorRange(rows[at.row], block.columns);
671
+ if (!range)
672
+ return false;
673
+ if (forward ? pos < range.end : pos > range.start)
674
+ return false;
675
+ let target = null;
676
+ const neighbour = rows[at.row + (forward ? 1 : -1)];
677
+ const near = neighbour ? rowCursorRange(neighbour, block.columns) : null;
678
+ if (near) {
679
+ target = forward ? near.start : near.end;
680
+ }
681
+ else if (neighbour) {
682
+ target = neighbour.lineTo; // 칸이 없는 행("|")
683
+ }
684
+ else if (forward) {
685
+ if (block.lineTo < state.doc.length)
686
+ target = block.lineTo + 1;
687
+ }
688
+ else {
689
+ // 숨긴 설정 주석 줄은 건너뜁니다.
690
+ const top = block.comment ? block.comment.lineFrom : block.lineFrom;
691
+ if (top > 0)
692
+ target = top - 1;
693
+ }
694
+ if (target !== null)
695
+ dispatch(state.update({ selection: EditorSelection.cursor(target), scrollIntoView: true, userEvent: "select" }));
696
+ return true;
697
+ };
698
+ }
699
+ /**
700
+ * ↑/↓: 표에서는 칸 기준으로 움직입니다.
701
+ * CodeMirror의 위/아래 이동은 "줄 끝 위치의 좌표"로 줄의 위아래 여백을 판단하는데, 표 행의 줄 끝은 마지막 칸의 끝이라
702
+ * 그 칸이 다른 칸보다 짧으면 행의 나머지를 여백으로 보고 행 전체를 건너뜁니다. 그래서 직접 처리합니다.
703
+ * 1) 칸 안에서 줄바꿈된 줄이 더 있으면 그 줄로 2) 아니면 위/아래 행의 같은 열(가로 위치 유지)
704
+ * 3) 첫/마지막 행이면 표 위/아래 줄로 (숨긴 구분 줄·설정 주석은 건너뜀)
705
+ */
706
+ function moveTableLine(forward) {
707
+ return (view) => {
708
+ const { state } = view;
709
+ const { ranges } = state.selection;
710
+ if (ranges.length !== 1 || !ranges[0].empty)
711
+ return false;
712
+ const range = ranges[0];
713
+ const at = cellAt(state, range.head);
714
+ if (!at || at.row < 0)
715
+ return false;
716
+ const row = at.rows[at.row];
717
+ const cell = row.cells[at.cell];
718
+ // 커서를 그리는 쪽과 같게 잽니다. (CodeMirror 커서 층은 assoc 0을 1로 그림: 줄바꿈 지점이면 다음 줄 처음)
719
+ const caret = view.coordsAtPos(range.head, range.assoc || 1) ?? view.coordsAtPos(range.head, -1);
720
+ if (!cell || !caret)
721
+ return false;
722
+ const content = view.contentDOM.getBoundingClientRect();
723
+ const x = range.goalColumn != null ? content.left + range.goalColumn : caret.left;
724
+ const half = view.defaultLineHeight / 2;
725
+ /** 줄바꿈 지점(앞 줄 끝 = 다음 줄 처음)은 같은 위치이므로, 어느 줄에 그릴지(assoc)도 좌표에서 정합니다. */
726
+ let side = forward ? 1 : -1;
727
+ const go = (pos) => {
728
+ view.dispatch({
729
+ selection: EditorSelection.cursor(pos, side, undefined, x - content.left),
730
+ scrollIntoView: true,
731
+ userEvent: "select",
732
+ });
733
+ return true;
734
+ };
735
+ const posAt = (y) => {
736
+ try {
737
+ const found = view.posAndSideAtCoords({ x, y });
738
+ if (!found)
739
+ return null;
740
+ side = found.assoc < 0 ? -1 : 1;
741
+ return found.pos;
742
+ }
743
+ catch {
744
+ return null;
745
+ }
746
+ };
747
+ const clamp = (pos, c, fallback) => pos === null ? fallback : Math.min(c.textTo, Math.max(c.textFrom, pos));
748
+ // 1) 칸 안의 다음/이전 줄
749
+ const edge = forward ? view.coordsAtPos(cell.textTo, -1) : view.coordsAtPos(cell.textFrom, 1);
750
+ if (edge && (forward ? caret.bottom < edge.top + 1 : caret.top > edge.bottom - 1)) {
751
+ const pos = clamp(posAt(forward ? caret.bottom + half : caret.top - half), cell, forward ? cell.textTo : cell.textFrom);
752
+ if (pos !== range.head)
753
+ return go(pos);
754
+ }
755
+ // 2) 위/아래 행의 같은 열
756
+ const next = at.rows[at.row + (forward ? 1 : -1)];
757
+ if (next) {
758
+ const target = next.cells[Math.min(at.cell, next.cells.length - 1)];
759
+ if (!target)
760
+ return go(next.lineTo);
761
+ const line = forward ? view.coordsAtPos(target.textFrom, 1) : view.coordsAtPos(target.textTo, -1);
762
+ const pos = line ? posAt((line.top + line.bottom) / 2) : null;
763
+ return go(clamp(pos, target, target.textTo));
764
+ }
765
+ // 3) 표 밖
766
+ const { block } = at;
767
+ if (forward) {
768
+ if (block.lineTo >= state.doc.length)
769
+ return go(row.cells.length > 0 ? rowCursorRange(row, block.columns).end : range.head);
770
+ const pos = posAt(view.documentTop + view.lineBlockAt(block.lineTo).bottom + half);
771
+ return go(pos !== null && pos > block.lineTo ? pos : block.lineTo + 1);
772
+ }
773
+ const top = block.comment ? block.comment.lineFrom : block.lineFrom;
774
+ if (top === 0)
775
+ return go(rowCursorRange(row, block.columns)?.start ?? range.head);
776
+ const pos = posAt(view.documentTop + view.lineBlockAt(top).top - half);
777
+ return go(pos !== null && pos < top ? pos : top - 1);
778
+ };
779
+ }
780
+ const tableKeymap = Prec.highest(keymap.of([
781
+ { key: "ArrowUp", run: moveTableLine(false) },
782
+ { key: "ArrowDown", run: moveTableLine(true) },
783
+ { key: "ArrowLeft", run: crossRow(false) },
784
+ { key: "ArrowRight", run: crossRow(true) },
785
+ { key: "Enter", run: insertTableRow },
786
+ { key: "Tab", run: nextTableCell, shift: previousTableCell },
787
+ { key: "Backspace", run: deleteKey(false, "char") },
788
+ { key: "Delete", run: deleteKey(true, "char") },
789
+ { key: "Mod-Backspace", run: deleteKey(false, "line") },
790
+ { key: "Mod-Delete", run: deleteKey(true, "line") },
791
+ { key: "Alt-Backspace", run: deleteKey(false, "word") },
792
+ { key: "Alt-Delete", run: deleteKey(true, "word") },
793
+ { key: "Ctrl-Backspace", run: deleteKey(false, "word") },
794
+ { key: "Ctrl-Delete", run: deleteKey(true, "word") },
795
+ ]));
796
+ // ---------------------------------------------------------------------------
797
+ // 테마
798
+ // ---------------------------------------------------------------------------
799
+ const BORDER = "var(--cm-table-border)";
800
+ /** Notion과 같은 색 (글자, 배경) */
801
+ const PALETTE = {
802
+ light: {
803
+ gray: ["#787774", "#f1f1ef"],
804
+ brown: ["#9f6b53", "#f4eeee"],
805
+ orange: ["#d9730d", "#fbecdd"],
806
+ yellow: ["#cb912f", "#fbf3db"],
807
+ green: ["#448361", "#edf3ec"],
808
+ blue: ["#337ea9", "#e7f3f8"],
809
+ purple: ["#9065b0", "#f6f3f9"],
810
+ pink: ["#c14c8a", "#faf1f5"],
811
+ red: ["#d44c47", "#fdebec"],
812
+ },
813
+ dark: {
814
+ gray: ["#9b9b9b", "#2f2f2f"],
815
+ brown: ["#ba856f", "#4a3228"],
816
+ orange: ["#c77d48", "#5c3b23"],
817
+ yellow: ["#ca9849", "#564328"],
818
+ green: ["#529e72", "#243d30"],
819
+ blue: ["#5e87c9", "#143a4e"],
820
+ purple: ["#9d68d3", "#3c2d49"],
821
+ pink: ["#d15796", "#4e2c3c"],
822
+ red: ["#df5452", "#522e2a"],
823
+ },
824
+ };
825
+ /** 색 변수(테마별)와 행/열 색 클래스. 행 클래스를 뒤에 두어 행 색이 열 색보다 우선합니다. */
826
+ function colorTheme() {
827
+ const spec = {};
828
+ for (const mode of ["light", "dark"]) {
829
+ const vars = {};
830
+ for (const name of TABLE_COLORS) {
831
+ vars[`--cm-table-fg-${name}`] = PALETTE[mode][name][0];
832
+ vars[`--cm-table-bg-${name}`] = PALETTE[mode][name][1];
833
+ }
834
+ // 칸 범위 선택의 옅은 파랑
835
+ vars["--cm-table-picked"] = mode === "light" ? "rgba(35, 131, 226, 0.14)" : "rgba(78, 161, 243, 0.2)";
836
+ // 메뉴의 색 견본에도 쓰므로 에디터 전체에 둡니다.
837
+ spec[`&${mode}`] = vars;
838
+ }
839
+ // 칸 색 > 행 색 > 열 색 (뒤에 정의할수록 우선)
840
+ for (const axis of ["col", "row", "cell"]) {
841
+ for (const name of TABLE_COLORS) {
842
+ // 제목 행/열 배경보다 우선하도록 명시도를 높입니다.
843
+ spec[`.cm-table .cm-table-row .cm-table-cell.cm-table-${axis}-fg-${name}`] = { color: `var(--cm-table-fg-${name})` };
844
+ spec[`.cm-table .cm-table-row .cm-table-cell.cm-table-${axis}-bg-${name}`] = { backgroundColor: `var(--cm-table-bg-${name})` };
845
+ }
846
+ }
847
+ return spec;
848
+ }
849
+ const tableTheme = EditorView.baseTheme({
850
+ ".cm-table": {
851
+ display: "grid",
852
+ // 표의 열 + 남는 폭(줄이 에디터 너비를 다 쓰도록. 인용/콜아웃 배경이 끊기지 않음)
853
+ gridTemplateColumns: "repeat(var(--cm-table-columns), auto) minmax(0, 1fr)",
854
+ },
855
+ ".cm-table > *": { gridColumn: "1 / -1" },
856
+ "&light .cm-table": {
857
+ "--cm-table-border": "#d9dce1",
858
+ "--cm-table-head-bg": "rgba(0, 0, 0, 0.035)",
859
+ "--cm-table-select": "#2383e2",
860
+ },
861
+ "&dark .cm-table": {
862
+ "--cm-table-border": "#4b5563",
863
+ "--cm-table-head-bg": "rgba(255, 255, 255, 0.05)",
864
+ "--cm-table-select": "#4ea1f3",
865
+ },
866
+ ".cm-table > .cm-line.cm-table-row": {
867
+ display: "grid",
868
+ gridTemplateColumns: "subgrid",
869
+ position: "relative",
870
+ },
871
+ // 칸이 아닌 조각(위젯 버퍼 등)은 격자 칸을 차지하지 않게 합니다.
872
+ ".cm-table-row > :not(.cm-table-cell):not(.cm-table-extra):not(.cm-table-missing)": {
873
+ position: "absolute",
874
+ },
875
+ ".cm-table-missing": { display: "contents" },
876
+ // CodeMirror는 숨긴 조각(위젯) 양옆에 커서 위치용 <img> 버퍼를 넣는데, 이 버퍼가 줄바꿈 기회가 되어 긴 URL처럼
877
+ // 끊을 곳이 없는 내용이 칸의 둘째 줄부터 시작합니다. absolute(위치 지정 없음)는 흐름에서 빠지지만 정적 위치가
878
+ // 원래 자리와 같아, 버퍼로 재는 커서 좌표는 그대로입니다.
879
+ ".cm-table-cell .cm-widgetBuffer": { position: "absolute" },
880
+ ".cm-table-cell": {
881
+ boxSizing: "border-box",
882
+ // 글자가 없는 칸("||")만 있는 행도 한 줄 높이를 유지합니다. (위아래 padding 8px + 테두리 1px)
883
+ minHeight: "calc(1lh + 9px)",
884
+ padding: "4px 8px",
885
+ minWidth: "2.5em",
886
+ borderRight: `1px solid ${BORDER}`,
887
+ borderBottom: `1px solid ${BORDER}`,
888
+ whiteSpace: "pre-wrap",
889
+ overflowWrap: "anywhere",
890
+ wordBreak: "normal",
891
+ },
892
+ ".cm-table-col-0": { borderLeft: `1px solid ${BORDER}` },
893
+ ".cm-table-row-0 > .cm-table-cell, .cm-table-row-0 > .cm-table-missing > .cm-table-cell": {
894
+ borderTop: `1px solid ${BORDER}`,
895
+ },
896
+ ".cm-table-align-left": { textAlign: "left" },
897
+ ".cm-table-align-center": { textAlign: "center" },
898
+ ".cm-table-align-right": { textAlign: "right" },
899
+ // 제목 행/열
900
+ ".cm-table-header-row .cm-table-row-0 .cm-table-cell, .cm-table-header-column .cm-table-col-0": {
901
+ fontWeight: "600",
902
+ backgroundColor: "var(--cm-table-head-bg)",
903
+ },
904
+ ".cm-table-extra": { opacity: "0.45" },
905
+ ".cm-table-delimiter-row": { opacity: "0.6" },
906
+ // 목록 안의 표: 들여쓰기 공백 대신 왼쪽 여백 (인용/콜아웃 안 여백에 더함)
907
+ ".cm-table > .cm-line.cm-table-indent": {
908
+ paddingLeft: "calc(var(--cm-container-indent, 6px) + var(--cm-table-indent) * 1ch)",
909
+ },
910
+ ...Object.fromEntries(Array.from({ length: MAX_INDENT }, (_, i) => [`.cm-table-indent-${i + 1}`, { "--cm-table-indent": String(i + 1) }])),
911
+ ...colorTheme(),
912
+ // 칸 범위 선택: 칸 색 위에 옅은 파랑을 덮습니다.
913
+ ".cm-table .cm-table-row .cm-table-cell.cm-table-picked": {
914
+ backgroundImage: "linear-gradient(var(--cm-table-picked), var(--cm-table-picked))",
915
+ },
916
+ "&.cm-table-cells-selected .cm-selectionLayer": { display: "none" },
917
+ // 펼친 색 표식
918
+ ".cm-table-cell-marker": { opacity: "0.5", fontSize: "0.85em" },
919
+ // 메뉴로 고른 행/열 테두리: 칸마다 바깥쪽 변만 (selectedClass)
920
+ ".cm-table-sel": {
921
+ boxShadow: "var(--cm-table-sel-t, 0 0 transparent), var(--cm-table-sel-b, 0 0 transparent), var(--cm-table-sel-l, 0 0 transparent), var(--cm-table-sel-r, 0 0 transparent)",
922
+ },
923
+ ".cm-table-sel-t": { "--cm-table-sel-t": "inset 0 2px 0 var(--cm-table-select)" },
924
+ ".cm-table-sel-b": { "--cm-table-sel-b": "inset 0 -2px 0 var(--cm-table-select)" },
925
+ ".cm-table-sel-l": { "--cm-table-sel-l": "inset 2px 0 0 var(--cm-table-select)" },
926
+ ".cm-table-sel-r": { "--cm-table-sel-r": "inset -2px 0 0 var(--cm-table-select)" },
927
+ });
928
+ // ---------------------------------------------------------------------------
929
+ // 확장
930
+ // ---------------------------------------------------------------------------
931
+ /** table()이 켜져 있는지 (다른 확장이 역할을 나눌 때) */
932
+ export const tablesHandled = Facet.define({ combine: (values) => values.some(Boolean) });
933
+ /**
934
+ * GFM 표를 표 모양으로 보여주고 편집하는 확장입니다.
935
+ * - 원문은 그대로 두고 화면에서만 표로 그립니다. (`|` 숨김, 칸 테두리, 열 너비는 내용에 맞춤, 칸 안 줄바꿈)
936
+ * - 칸 안의 굵게/링크/수식 등은 다른 확장이 그대로 그립니다.
937
+ * - 구분 줄(`|---|`)은 숨기고, 열 정렬(`:-:`)은 칸 정렬로 보여줍니다.
938
+ * - 제목 행/열: 표 바로 윗줄의 `<!-- table: header-column no-header-row -->` 주석 (다른 렌더러에서는 보이지 않음).
939
+ * 첫 행/첫 열에 마우스를 올리면 나타나는 손잡이 메뉴로 켜고 끕니다.
940
+ * - Enter: 아래에 새 행 (비어 있는 마지막 행에서는 표 끝내기), Tab/Shift+Tab: 칸 이동 (마지막 칸에서 Tab은 새 행)
941
+ * - 칸 처음/끝에서 Backspace/Delete는 `|`를 지우지 않고 옆 칸으로 이동합니다.
942
+ * - 인용, 목록, 콜아웃 안의 표도 지원합니다.
943
+ */
944
+ export function table() {
945
+ return [
946
+ tablesHandled.of(true),
947
+ tablesField,
948
+ tableSelectionField,
949
+ hiddenLinesField,
950
+ tableWrappers,
951
+ tableRowsPlugin,
952
+ tableKeymap,
953
+ tableMouseSelection,
954
+ cursorGuard,
955
+ initialCursorGuard,
956
+ tableHandles,
957
+ tableTheme,
958
+ tableMenuTheme,
959
+ ];
960
+ }