@ssobig/writer-cli 0.3.0 → 0.3.3

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 (30) hide show
  1. package/README.md +11 -4
  2. package/asset-repository.js +7 -2
  3. package/package.json +1 -1
  4. package/templates/mystery-v1/authoring-view-preference.js +25 -2
  5. package/templates/mystery-v1/codemirror6-runtime.min.js +1 -1
  6. package/templates/mystery-v1/component-asset-operations.js +19 -6
  7. package/templates/mystery-v1/component-catalog-contract.js +2 -2
  8. package/templates/mystery-v1/component-field-contracts.js +98 -2
  9. package/templates/mystery-v1/component-navigation-counts.js +6 -1
  10. package/templates/mystery-v1/component-renderers.js +1 -1
  11. package/templates/mystery-v1/component-storage-contract.js +28 -9
  12. package/templates/mystery-v1/markdown-document-model.js +458 -0
  13. package/templates/mystery-v1/markdown-image-editor.js +336 -0
  14. package/templates/mystery-v1/markdown-live-editor.js +1246 -110
  15. package/templates/mystery-v1/markdown-toolbar.js +514 -0
  16. package/templates/mystery-v1/timeline-model.js +235 -0
  17. package/tools/writer-cli/package-lock.json +2 -2
  18. package/tools/writer-cli/package.json +1 -1
  19. package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +12 -13
  20. package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -1
  21. package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +4 -1
  22. package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +3 -3
  23. package/tools/writer-cli/skills/ssobig-writer-cli/references/investigation-board.md +9 -0
  24. package/tools/writer-cli/skills/ssobig-writer-cli/references/layout-spec.md +130 -0
  25. package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +7 -1
  26. package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +12 -2
  27. package/tools/writer-cli/src/command-registry.cjs +24 -23
  28. package/tools/writer-cli/src/commands.cjs +22 -1
  29. package/tools/writer-cli/src/domain.cjs +46 -0
  30. package/tools/writer-cli/src/project-import.cjs +15 -1
@@ -1,25 +1,99 @@
1
1
  (function (root, factory) {
2
- const api = factory(root?.WriterCodeMirror6);
3
- if (typeof module === "object" && module.exports) module.exports = api;
2
+ const commonJs = typeof module === "object" && module.exports;
3
+ const CodeMirror6 = commonJs ? null : root?.WriterCodeMirror6;
4
+ const markdownDocumentModel = commonJs ? require("./markdown-document-model.js") : root?.WriterMarkdownDocumentModel;
5
+ const toolbarApi = commonJs ? require("./markdown-toolbar.js") : root?.WriterMarkdownToolbar;
6
+ const api = factory(CodeMirror6, markdownDocumentModel, toolbarApi);
7
+ if (commonJs) module.exports = api;
4
8
  if (root) root.WriterMarkdownLiveEditor = api;
5
- })(typeof globalThis !== "undefined" ? globalThis : this, function (CodeMirror6) {
9
+ })(typeof globalThis !== "undefined" ? globalThis : this, function (CodeMirror6, markdownDocumentModel, toolbarApi) {
6
10
  "use strict";
7
11
 
8
12
  const INLINE_MARKERS = Object.freeze([
13
+ { marker: "<u>", close: "</u>", type: "underline" },
14
+ { marker: "***", type: "strong-emphasis" },
9
15
  { marker: "++", type: "brand" },
10
16
  { marker: "**", type: "strong" },
11
17
  { marker: "~~", type: "strikethrough" },
12
18
  { marker: "==", type: "highlight" },
13
- { marker: "`", type: "code" }
19
+ { marker: "*", type: "emphasis" },
20
+ { marker: "_", type: "emphasis" }
14
21
  ]);
15
- function lineStarts(source) {
16
- const starts = [0];
17
- for (let index = 0; index < source.length; index += 1) {
18
- if (source[index] === "\n") starts.push(index + 1);
22
+ function isEscapedAt(source, index) {
23
+ let backslashes = 0;
24
+ for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) backslashes += 1;
25
+ return backslashes % 2 === 1;
26
+ }
27
+ function backtickRunLength(source, index) {
28
+ let cursor = index;
29
+ while (source[cursor] === "`") cursor += 1;
30
+ return cursor - index;
31
+ }
32
+ function codeTokenAt(source, index) {
33
+ if (source[index] !== "`" || isEscapedAt(source, index)) return null;
34
+ const markerLength = backtickRunLength(source, index);
35
+ const contentFrom = index + markerLength;
36
+ let cursor = contentFrom;
37
+ while (cursor < source.length) {
38
+ if (source[cursor] !== "`") {
39
+ cursor += 1;
40
+ continue;
41
+ }
42
+ const closeLength = backtickRunLength(source, cursor);
43
+ if (isEscapedAt(source, cursor)) {
44
+ cursor += closeLength;
45
+ continue;
46
+ }
47
+ if (closeLength === markerLength) {
48
+ if (cursor <= contentFrom || /\n[ \t]*\n/.test(source.slice(contentFrom, cursor))) return null;
49
+ return {
50
+ type: "code",
51
+ marker: "`".repeat(markerLength),
52
+ from: index,
53
+ openTo: contentFrom,
54
+ contentFrom,
55
+ contentTo: cursor,
56
+ closeFrom: cursor,
57
+ to: cursor + markerLength
58
+ };
59
+ }
60
+ cursor += closeLength;
19
61
  }
20
- return starts;
62
+ return null;
63
+ }
64
+ function tableEscapedPipeBackslashes(cell) {
65
+ const raw = String(cell?.raw ?? "");
66
+ const rawFrom = Number(cell?.rawFrom);
67
+ if (!Number.isSafeInteger(rawFrom) || !raw) return [];
68
+ const ranges = [];
69
+ let index = 0;
70
+ while (index < raw.length) {
71
+ if (raw[index] !== "\\") {
72
+ index += 1;
73
+ continue;
74
+ }
75
+ let cursor = index;
76
+ while (raw[cursor] === "\\") cursor += 1;
77
+ const count = cursor - index;
78
+ if (raw[cursor] === "|" && count % 2 === 1) {
79
+ const from = rawFrom + cursor - 1;
80
+ ranges.push({ from, to: from + 1 });
81
+ }
82
+ index = cursor + (raw[cursor] === "|" ? 1 : 0);
83
+ }
84
+ return ranges;
85
+ }
86
+ function tableCellEdgeWhitespaceRanges(cell) {
87
+ const rawFrom = Number(cell?.rawFrom);
88
+ const from = Number(cell?.from);
89
+ const to = Number(cell?.to);
90
+ const rawTo = Number(cell?.rawTo);
91
+ if (![rawFrom, from, to, rawTo].every(Number.isSafeInteger)) return [];
92
+ const ranges = [];
93
+ if (rawFrom < from) ranges.push({ from: rawFrom, to: from });
94
+ if (to < rawTo) ranges.push({ from: to, to: rawTo });
95
+ return ranges;
21
96
  }
22
-
23
97
  function lineAt(starts, offset) {
24
98
  let low = 0;
25
99
  let high = starts.length - 1;
@@ -35,20 +109,34 @@
35
109
  const tokens = [];
36
110
  let index = 0;
37
111
  while (index < source.length) {
112
+ if (source[index] === "`" && !isEscapedAt(source, index)) {
113
+ const token = codeTokenAt(source, index);
114
+ if (token) {
115
+ tokens.push(token);
116
+ index = token.to;
117
+ } else index += backtickRunLength(source, index);
118
+ continue;
119
+ }
38
120
  const definition = INLINE_MARKERS.find(item => source.startsWith(item.marker, index));
39
- if (!definition || (index > 0 && source[index - 1] === "\\")) {
121
+ if (definition?.marker === "_" && /[\p{L}\p{N}_]/u.test(source[index - 1] || "")) { index++; continue; }
122
+ if (definition?.marker === "*" && (source[index-1] === "*" || source[index+1] === "*")) { index++; continue; }
123
+ if (!definition || isEscapedAt(source, index)) {
40
124
  index += 1;
41
125
  continue;
42
126
  }
43
127
  const contentFrom = index + definition.marker.length;
44
- const closeFrom = source.indexOf(definition.marker, contentFrom);
128
+ const closeMarker = definition.close || definition.marker;
129
+ let closeFrom = source.indexOf(closeMarker, contentFrom);
130
+ while (closeFrom >= 0 && (isEscapedAt(source, closeFrom) || (definition.marker === "_" && /[\p{L}\p{N}_]/u.test(source[closeFrom + 1] || "")))) {
131
+ closeFrom = source.indexOf(closeMarker, closeFrom + closeMarker.length);
132
+ }
45
133
  const crossesBlockBoundary = closeFrom > contentFrom && /\n[ \t]*\n/.test(source.slice(contentFrom, closeFrom));
46
134
  if (closeFrom <= contentFrom || crossesBlockBoundary || (closeFrom > 0 && source[closeFrom - 1] === "\\")) {
47
135
  index += definition.marker.length;
48
136
  continue;
49
137
  }
50
- const to = closeFrom + definition.marker.length;
51
- tokens.push({
138
+ const to = closeFrom + closeMarker.length;
139
+ const token = {
52
140
  type: definition.type,
53
141
  marker: definition.marker,
54
142
  from: index,
@@ -57,7 +145,11 @@
57
145
  contentTo: closeFrom,
58
146
  closeFrom,
59
147
  to
60
- });
148
+ };
149
+ if (definition.type === "strong-emphasis") {
150
+ tokens.push({ ...token, type: "strong", marker: "**", openTo: index + 2, contentFrom: index + 2, contentTo: to - 2, closeFrom: to - 2 });
151
+ tokens.push({ ...token, type: "emphasis", marker: "*", from: index + 2, to: to - 2 });
152
+ } else tokens.push(token);
61
153
  index = to;
62
154
  }
63
155
  return tokens;
@@ -72,35 +164,94 @@
72
164
  });
73
165
  }
74
166
 
167
+ const characterSegmenter = typeof Intl.Segmenter === "function"
168
+ ? new Intl.Segmenter("ko", { granularity: "grapheme" }) : null;
169
+
170
+ // Count the document model, never CodeMirror's virtualized/selected DOM.
171
+ function characterCount(value) {
172
+ const documentModel = markdownDocumentModel.parseDocument(value, { preserveBlankLines: true });
173
+ const fragments = [];
174
+ const visit = blocks => blocks.forEach(block => {
175
+ if (block.type === "paragraph" || block.type === "quote") fragments.push(block.lines.join("\n"));
176
+ else if (block.type === "heading") fragments.push(block.text);
177
+ else if (block.type === "blank") fragments.push(documentModel.lines[block.lineIndex].text);
178
+ else if (block.type === "list") block.items.forEach(item => {
179
+ fragments.push(item.body);
180
+ visit(item.children);
181
+ });
182
+ else if (block.type === "table") [block.header, ...block.rows].forEach(row => {
183
+ row.cells.forEach(cell => fragments.push(cell.text));
184
+ });
185
+ // Images and horizontal rules contain no manuscript characters.
186
+ });
187
+ visit(documentModel.blocks);
188
+ return fragments.reduce((total, text) => {
189
+ const hidden = inlineTokens(text).flatMap(token => [
190
+ { from: token.from, to: token.openTo },
191
+ { from: token.closeFrom, to: token.to }
192
+ ]).sort((a, b) => a.from - b.from);
193
+ let cursor = 0;
194
+ let visible = "";
195
+ hidden.forEach(range => {
196
+ if (range.from > cursor) visible += text.slice(cursor, range.from);
197
+ cursor = Math.max(cursor, range.to);
198
+ });
199
+ visible = (visible + text.slice(cursor)).replace(/\r?\n/g, "");
200
+ let count = 0;
201
+ for (const ignored of characterSegmenter ? characterSegmenter.segment(visible) : visible) count++;
202
+ return total + count;
203
+ }, 0);
204
+ }
205
+
75
206
  function selectionTouchesRange(selection, from, to) {
76
207
  return selection.from === selection.to
77
208
  ? selection.head >= from && selection.head <= to
78
209
  : selection.to >= from && selection.from <= to;
79
210
  }
80
211
 
81
- function lineSyntax(text) {
82
- const heading = text.match(/^( {0,3})(#{1,4})(?:[ \t]+|$)/);
83
- const quote = text.match(/^( {0,3})>[ \t]?/);
84
- const unordered = text.match(/^([-*])[ \t]+(.+)$/);
85
- const ordered = text.match(/^(\d+[.)])[ \t]+(.+)$/);
212
+ function offsetInlineToken(token, offset) {
86
213
  return {
87
- headingLevel: heading ? heading[2].length : 0,
88
- headingMarkerFrom: heading ? heading[1].length : -1,
89
- headingMarkerTo: heading ? heading[0].length : -1,
90
- quoteMarkerFrom: quote ? quote[1].length : -1,
91
- quoteMarkerTo: quote ? quote[0].length : -1,
92
- listType: unordered ? "unordered" : ordered ? "ordered" : "",
93
- empty: !text.trim()
214
+ ...token,
215
+ from: token.from + offset,
216
+ openTo: token.openTo + offset,
217
+ contentFrom: token.contentFrom + offset,
218
+ contentTo: token.contentTo + offset,
219
+ closeFrom: token.closeFrom + offset,
220
+ to: token.to + offset
94
221
  };
95
222
  }
96
223
 
97
- function layoutLines(source, starts) {
98
- const lines = source.split("\n").map((text, index) => ({
99
- index,
100
- text,
101
- from: starts[index],
102
- to: starts[index] + text.length,
103
- ...lineSyntax(text),
224
+ function tableAwareInlineTokens(source, tableContexts) {
225
+ const tokens = [];
226
+ let cursor = 0;
227
+ [...tableContexts].sort((left, right) => left.from - right.from).forEach(table => {
228
+ if (cursor < table.from) {
229
+ tokens.push(...inlineTokens(source.slice(cursor, table.from)).map(token => offsetInlineToken(token, cursor)));
230
+ }
231
+ [...table.cells].sort((left, right) => left.from - right.from).forEach(cell => {
232
+ tokens.push(...inlineTokens(source.slice(cell.from, cell.to)).map(token => ({
233
+ ...offsetInlineToken(token, cell.from),
234
+ tableCell: true
235
+ })));
236
+ });
237
+ cursor = Math.max(cursor, table.to);
238
+ });
239
+ if (cursor < source.length) {
240
+ tokens.push(...inlineTokens(source.slice(cursor)).map(token => offsetInlineToken(token, cursor)));
241
+ }
242
+ return tokens.sort((left, right) => left.from - right.from || left.to - right.to);
243
+ }
244
+
245
+ function lineSyntax(text) {
246
+ if (!markdownDocumentModel?.parseLine) throw new Error("Markdown 문서 모델을 초기화하지 못했습니다.");
247
+ return markdownDocumentModel.parseLine(text);
248
+ }
249
+
250
+ function layoutLines(source) {
251
+ if (!markdownDocumentModel?.parseDocument) throw new Error("Markdown 문서 모델을 초기화하지 못했습니다.");
252
+ const documentModel = markdownDocumentModel.parseDocument(source, { preserveBlankLines: true });
253
+ const lines = documentModel.lines.map(line => ({
254
+ ...line,
104
255
  blockId: -1,
105
256
  blockType: "",
106
257
  blockStart: false,
@@ -109,83 +260,271 @@
109
260
  firstListItem: false
110
261
  }));
111
262
  const blocks = [];
112
- let paragraph = null;
113
- let quote = null;
114
- let listType = "";
115
- let listGroup = 0;
116
263
  let nextBlockId = 0;
117
- const openBlock = (type, line, extra = {}) => {
118
- const block = { id: nextBlockId++, type, lines: [], ...extra };
264
+ let nextListGroup = 0;
265
+ const assignBlock = (type, lineIndices, extra = {}) => {
266
+ const validLineIndices = lineIndices.filter(index => lines[index]);
267
+ if (!validLineIndices.length) return null;
268
+ const block = { id: nextBlockId++, type, lines: validLineIndices, ...extra };
119
269
  blocks.push(block);
120
- line.blockId = block.id;
121
- line.blockType = type;
122
- line.blockStart = true;
123
- block.lines.push(line.index);
270
+ validLineIndices.forEach((lineIndex, index) => {
271
+ const line = lines[lineIndex];
272
+ line.blockId = block.id;
273
+ line.blockType = type;
274
+ line.blockStart = index === 0;
275
+ line.blockEnd = index === validLineIndices.length - 1;
276
+ Object.assign(line, extra);
277
+ });
124
278
  return block;
125
279
  };
126
- const appendBlock = (block, line) => {
127
- line.blockId = block.id;
128
- line.blockType = block.type;
129
- block.lines.push(line.index);
130
- };
131
-
132
- lines.forEach(line => {
133
- if (line.quoteMarkerFrom >= 0) {
134
- paragraph = null;
135
- const nestedQuote = Boolean(listType);
136
- if (!quote || quote.nestedQuote !== nestedQuote || quote.listGroup !== listGroup) {
137
- quote = openBlock("quote", line, { nestedQuote, listGroup: nestedQuote ? listGroup : 0 });
138
- } else appendBlock(quote, line);
139
- line.nestedQuote = nestedQuote;
280
+ documentModel.blocks.forEach(sourceBlock => {
281
+ if (sourceBlock.type === "blank") return;
282
+ if (sourceBlock.type === "horizontalRule") {
283
+ assignBlock("horizontalRule", [sourceBlock.lineIndex]);
140
284
  return;
141
285
  }
142
- quote = null;
143
- if (line.empty) {
144
- paragraph = null;
145
- listType = "";
286
+ if (sourceBlock.type === "image") {
287
+ assignBlock("image", [sourceBlock.lineIndex], { assetId: sourceBlock.assetId });
146
288
  return;
147
289
  }
148
- if (line.headingLevel) {
149
- paragraph = null;
150
- listType = "";
151
- openBlock(`heading${line.headingLevel}`, line);
290
+ if (sourceBlock.type === "heading") {
291
+ assignBlock(`heading${sourceBlock.level}`, [sourceBlock.lineIndex]);
152
292
  return;
153
293
  }
154
- if (line.listType) {
155
- paragraph = null;
156
- const firstListItem = line.listType !== listType;
157
- if (firstListItem) listGroup += 1;
158
- listType = line.listType;
159
- line.firstListItem = firstListItem;
160
- openBlock("listItem", line, { listGroup, firstListItem });
294
+ if (sourceBlock.type === "paragraph" || sourceBlock.type === "quote") {
295
+ assignBlock(sourceBlock.type, sourceBlock.lineIndices);
161
296
  return;
162
297
  }
163
- listType = "";
164
- if (!paragraph) paragraph = openBlock("paragraph", line);
165
- else appendBlock(paragraph, line);
298
+ if (sourceBlock.type === "table") {
299
+ const tableBlock = assignBlock("table", sourceBlock.lineIndices, {
300
+ tableColumnCount: sourceBlock.columnCount,
301
+ tableAlignments: sourceBlock.alignments,
302
+ tableFrom: sourceBlock.from,
303
+ tableTo: sourceBlock.to
304
+ });
305
+ if (!tableBlock) return;
306
+ Object.assign(tableBlock, {
307
+ from: sourceBlock.from,
308
+ to: sourceBlock.to,
309
+ columnCount: sourceBlock.columnCount,
310
+ alignments: sourceBlock.alignments
311
+ });
312
+ [sourceBlock.header, sourceBlock.delimiter, ...sourceBlock.rows].forEach(row => {
313
+ const line = lines[row.lineIndex];
314
+ if (!line) return;
315
+ line.tableRole = row.role;
316
+ line.tableCells = row.cells;
317
+ line.tablePipes = row.pipes;
318
+ line.tableRaw = row.raw;
319
+ line.tableHasLeadingPipe = row.hasLeadingPipe;
320
+ line.tableHasTrailingPipe = row.hasTrailingPipe;
321
+ line.tableEscapedPipeBackslashes = row.cells.flatMap(tableEscapedPipeBackslashes);
322
+ line.tableEmptyCellColumns = row.cells.filter(cell => !cell.synthetic && !cell.text).map(cell => cell.columnIndex);
323
+ line.tableSyntheticCellColumns = row.cells.filter(cell => cell.synthetic).map(cell => cell.columnIndex);
324
+ });
325
+ return;
326
+ }
327
+ if (sourceBlock.type !== "list") return;
328
+ const listGroup = ++nextListGroup;
329
+ const markerWidth = Math.max(1, Number(sourceBlock.markerWidth) || 1);
330
+ sourceBlock.items.forEach((item, itemIndex) => {
331
+ const itemBlock = assignBlock("listItem", [item.lineIndex], {
332
+ listGroup,
333
+ listMarkerWidth: markerWidth,
334
+ firstListItem: itemIndex === 0,
335
+ listEnd: itemIndex === sourceBlock.items.length - 1
336
+ });
337
+ item.children.forEach(child => {
338
+ if (child.type !== "quote") return;
339
+ assignBlock("quote", child.lineIndices, { nestedQuote: true, listGroup });
340
+ });
341
+ if (itemBlock) itemBlock.children = item.children;
342
+ });
166
343
  });
167
344
 
168
- blocks.forEach(block => {
169
- const lastLine = lines[block.lines.at(-1)];
170
- if (lastLine) lastLine.blockEnd = true;
171
- });
172
- const listGroups = new Map();
173
- blocks.forEach(block => {
174
- if (!block.listGroup) return;
175
- if (!listGroups.has(block.listGroup)) listGroups.set(block.listGroup, []);
176
- listGroups.get(block.listGroup).push(block);
177
- });
178
- listGroups.forEach(group => {
179
- const last = group.at(-1);
180
- if (last) last.listEnd = true;
345
+ return { lines, blocks };
346
+ }
347
+
348
+ function tableCellContexts(value) {
349
+ const source = String(value ?? "").replace(/\r\n?/g, "\n");
350
+ const layout = layoutLines(source);
351
+ return layout.blocks.filter(block => block.type === "table").map(block => {
352
+ const rows = block.lines
353
+ .map(lineIndex => layout.lines[lineIndex])
354
+ .filter(line => line && line.tableRole !== "delimiter")
355
+ .map((line, rowIndex) => ({
356
+ line,
357
+ rowIndex,
358
+ cells: line.tableCells.map(cell => ({ cell, line, rowIndex, columnIndex: cell.columnIndex }))
359
+ }));
360
+ const cells = rows.flatMap(row => row.cells);
361
+ const lines = block.lines.map(lineIndex => layout.lines[lineIndex]).filter(Boolean);
362
+ return { block, lines, rows, cells };
181
363
  });
364
+ }
182
365
 
183
- return { lines, blocks };
366
+ function tableCellContextFromTables(tables, position) {
367
+ const offset = Math.max(0, Number(position) || 0);
368
+ for (const table of tables) {
369
+ for (const context of table.cells) {
370
+ const { cell, line, columnIndex } = context;
371
+ const firstCell = columnIndex === 0;
372
+ const lastCell = columnIndex === line.tableCells.length - 1;
373
+ const touchesContent = offset >= cell.rawFrom && offset <= cell.rawTo;
374
+ const touchesLeadingPipe = firstCell && line.tablePipes?.[0]?.from === offset;
375
+ const trailingPipe = line.tablePipes?.at(-1);
376
+ const touchesTrailingPipe = lastCell && (
377
+ trailingPipe?.from === offset || trailingPipe?.to === offset
378
+ );
379
+ if (touchesContent || touchesLeadingPipe || touchesTrailingPipe) return { ...context, table };
380
+ }
381
+ }
382
+ return null;
383
+ }
384
+
385
+ function tableCellContextAt(value, position) {
386
+ return tableCellContextFromTables(tableCellContexts(value), position);
387
+ }
388
+
389
+ function tableSelectionCrossesStructure(value, selection) {
390
+ const source = String(value ?? "").replace(/\r\n?/g, "\n");
391
+ const anchor = Math.max(0, Math.min(source.length, Number(selection?.anchor) || 0));
392
+ const head = Math.max(0, Math.min(source.length, Number(selection?.head) || 0));
393
+ const from = Math.min(anchor, head);
394
+ const to = Math.max(anchor, head);
395
+ if (from === to) return false;
396
+ const tables = tableCellContexts(source);
397
+ const start = tableCellContextFromTables(tables, from);
398
+ const end = tableCellContextFromTables(tables, to);
399
+ for (const table of tables) {
400
+ if (to <= table.block.from || from >= table.block.to) continue;
401
+ if (!start || !end) return true;
402
+ const sameTable = start.table.block.from === table.block.from && end.table.block.from === table.block.from;
403
+ const sameCell = start.line.index === end.line.index && start.columnIndex === end.columnIndex;
404
+ const insideRawCell = from >= start.cell.rawFrom && to <= start.cell.rawTo;
405
+ if (!sameTable || !sameCell || !insideRawCell) return true;
406
+ }
407
+ return false;
408
+ }
409
+
410
+ function tableCellNavigationDestination(value, selection, direction) {
411
+ const anchor = Math.max(0, Number(selection?.anchor) || 0);
412
+ const head = Math.max(0, Number(selection?.head) || 0);
413
+ if (anchor !== head) return null;
414
+ const tables = tableCellContexts(value);
415
+ const current = tableCellContextFromTables(tables, head);
416
+ if (!current) return null;
417
+ const { table, cell, rowIndex, columnIndex } = current;
418
+ const cellLength = Math.max(0, cell.to - cell.from);
419
+ const contentOffset = Math.max(0, Math.min(cellLength, head - cell.from));
420
+ const flatIndex = table.cells.findIndex(context => (
421
+ context.line.index === current.line.index && context.columnIndex === columnIndex
422
+ ));
423
+ const destination = (targetContext, position) => targetContext
424
+ ? { position, context: { ...targetContext, table } }
425
+ : null;
426
+ let target = null;
427
+
428
+ if (direction === "left") {
429
+ if (head > cell.from) return null;
430
+ target = table.cells[flatIndex - 1] || null;
431
+ return destination(target, target?.cell.to);
432
+ }
433
+ if (direction === "right") {
434
+ if (head < cell.to) return null;
435
+ target = table.cells[flatIndex + 1] || null;
436
+ return destination(target, target?.cell.from);
437
+ }
438
+ if (direction === "previous") {
439
+ target = table.cells[flatIndex - 1] || null;
440
+ return destination(target, target?.cell.to);
441
+ }
442
+ if (direction === "next") {
443
+ target = table.cells[flatIndex + 1] || null;
444
+ return destination(target, target?.cell.from);
445
+ }
446
+ if (direction === "up" || direction === "down") {
447
+ const rowStep = direction === "up" ? -1 : 1;
448
+ for (let targetRow = rowIndex + rowStep; targetRow >= 0 && targetRow < table.rows.length; targetRow += rowStep) {
449
+ target = table.rows[targetRow].cells.find(context => context.columnIndex === columnIndex) || null;
450
+ if (!target) continue;
451
+ return destination(target, Math.min(target.cell.to, target.cell.from + contentOffset));
452
+ }
453
+ }
454
+ return null;
455
+ }
456
+
457
+ function tableCellNavigationTarget(value, selection, direction) {
458
+ return tableCellNavigationDestination(value, selection, direction)?.position ?? null;
459
+ }
460
+
461
+ function syntheticTableCellMaterialization(context) {
462
+ if (!context?.cell?.synthetic || !context.line?.tableCells) return null;
463
+ const firstSyntheticColumn = context.line.tableCells.findIndex(cell => cell.synthetic);
464
+ if (firstSyntheticColumn < 0 || context.columnIndex < firstSyntheticColumn) return null;
465
+ const pipeCount = context.columnIndex - firstSyntheticColumn + (context.line.tableHasTrailingPipe ? 0 : 1);
466
+ const insert = "|".repeat(Math.max(0, pipeCount));
467
+ return {
468
+ from: context.line.to,
469
+ insert,
470
+ position: context.line.to + insert.length
471
+ };
472
+ }
473
+
474
+ function safeTableCellInput(value, precedingBackslashes = 0) {
475
+ const source = String(value ?? "");
476
+ let result = "";
477
+ let trailingBackslashes = Math.max(0, Number(precedingBackslashes) || 0);
478
+ for (let index = 0; index < source.length; index += 1) {
479
+ const character = source[index];
480
+ if (character === "\r") {
481
+ if (source[index + 1] === "\n") index += 1;
482
+ result += " ";
483
+ trailingBackslashes = 0;
484
+ continue;
485
+ }
486
+ if (character === "\n") {
487
+ result += " ";
488
+ trailingBackslashes = 0;
489
+ continue;
490
+ }
491
+ if (character === "|") {
492
+ if (trailingBackslashes % 2 === 0) result += "\\";
493
+ result += character;
494
+ trailingBackslashes = 0;
495
+ continue;
496
+ }
497
+ result += character;
498
+ trailingBackslashes = character === "\\" ? trailingBackslashes + 1 : 0;
499
+ }
500
+ return result;
501
+ }
502
+
503
+ function safeTableCellReplacement(value, from, to, insertedValue) {
504
+ const source = String(value ?? "");
505
+ const changeFrom = Math.max(0, Math.min(source.length, Number(from) || 0));
506
+ const changeTo = Math.max(changeFrom, Math.min(source.length, Number(to) || 0));
507
+ let precedingBackslashes = 0;
508
+ for (let cursor = changeFrom - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) precedingBackslashes += 1;
509
+ let safeText = safeTableCellInput(insertedValue, precedingBackslashes);
510
+ let suffixBackslashes = 0;
511
+ for (let cursor = changeTo; cursor < source.length && source[cursor] === "\\"; cursor += 1) suffixBackslashes += 1;
512
+ const suffixPipe = changeTo + suffixBackslashes;
513
+ if (source[suffixPipe] !== "|") return safeText;
514
+
515
+ let originalBackslashes = 0;
516
+ for (let cursor = suffixPipe - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) originalBackslashes += 1;
517
+ let insertedBackslashes = 0;
518
+ for (let cursor = safeText.length - 1; cursor >= 0 && safeText[cursor] === "\\"; cursor -= 1) insertedBackslashes += 1;
519
+ const keepsPrecedingRun = safeText.length === insertedBackslashes;
520
+ const resultingBackslashes = suffixBackslashes + insertedBackslashes + (keepsPrecedingRun ? precedingBackslashes : 0);
521
+ if (resultingBackslashes % 2 !== originalBackslashes % 2) safeText += "\\";
522
+ return safeText;
184
523
  }
185
524
 
186
525
  function previewModel(value, selections = [{ anchor: 0, head: 0 }]) {
187
526
  const source = String(value ?? "").replace(/\r\n?/g, "\n");
188
- const starts = lineStarts(source);
527
+ const starts = markdownDocumentModel.lineStarts(source);
189
528
  const normalizedSelections = normalizeSelections(selections, source.length);
190
529
  const activeLines = new Set();
191
530
  normalizedSelections.forEach(selection => {
@@ -193,12 +532,34 @@
193
532
  const last = lineAt(starts, selection.to);
194
533
  for (let line = first; line <= last; line += 1) activeLines.add(line);
195
534
  });
196
- const layout = layoutLines(source, starts);
535
+ const layout = layoutLines(source);
197
536
  layout.lines.forEach(line => { line.active = activeLines.has(line.index); });
198
- const tokens = inlineTokens(source).map(token => ({
199
- ...token,
200
- active: normalizedSelections.some(selection => selectionTouchesRange(selection, token.from, token.to))
537
+ const tableContexts = layout.blocks.filter(block => block.type === "table").map(block => ({
538
+ block,
539
+ from: block.from,
540
+ to: block.to,
541
+ cells: block.lines.flatMap(lineIndex => (
542
+ layout.lines[lineIndex]?.tableCells?.filter(cell => !cell.synthetic) || []
543
+ ))
201
544
  }));
545
+ tableContexts.forEach(({ block }) => {
546
+ block.active = normalizedSelections.some(selection => selectionTouchesRange(selection, block.from, block.to));
547
+ block.lines.forEach(lineIndex => {
548
+ const line = layout.lines[lineIndex];
549
+ if (!line) return;
550
+ line.tableActiveCellColumns = line.tableCells
551
+ .filter(cell => !cell.synthetic && normalizedSelections.some(selection => (
552
+ selectionTouchesRange(selection, cell.rawFrom, cell.rawTo)
553
+ )))
554
+ .map(cell => cell.columnIndex);
555
+ });
556
+ });
557
+ const tokens = tableAwareInlineTokens(source, tableContexts)
558
+ .filter(token => !layout.lines.some(line => line.blockType === "image" && token.from < line.to && token.to > line.from))
559
+ .map(token => ({
560
+ ...token,
561
+ active: token.tableCell !== true && normalizedSelections.some(selection => selectionTouchesRange(selection, token.from, token.to))
562
+ }));
202
563
  return { source, starts, lines: layout.lines, blocks: layout.blocks, tokens };
203
564
  }
204
565
 
@@ -215,11 +576,44 @@
215
576
  .toLowerCase();
216
577
  values.push(`writer-md-${blockClass}`);
217
578
  }
579
+ if (line.listType) values.push(`writer-md-list-${line.listType}`);
218
580
  if (line.nestedQuote) values.push("writer-md-nested-quote");
219
581
  if (line.firstListItem) values.push("writer-md-first-list-item");
582
+ if (line.blockType === "table") {
583
+ values.push("writer-md-table-preview-row");
584
+ if (line.tableActiveCellColumns?.length) values.push("writer-md-table-has-active-cell");
585
+ if (line.tableRole) values.push(`writer-md-table-${line.tableRole}-row`);
586
+ if (line.tableColumnCount) values.push(`writer-md-table-columns-${line.tableColumnCount}`);
587
+ if (line.tableEmptyCellColumns?.length) values.push("writer-md-table-has-empty-cells");
588
+ if (line.tableSyntheticCellColumns?.length) values.push("writer-md-table-has-synthetic-cells");
589
+ }
220
590
  return values.join(" ");
221
591
  }
222
592
 
593
+ function tableOperation(value, selection, command, dimensions = {}) {
594
+ const source = String(value);
595
+ const from = Math.min(selection.anchor, selection.head);
596
+ const to = Math.max(selection.anchor, selection.head);
597
+ const blocks = markdownDocumentModel.parseDocument(source).blocks;
598
+ const table = blocks.find(block => block.type === "table" && from >= block.from && to <= block.to);
599
+ if (command === "table-delete") {
600
+ if (!table) return null;
601
+ // Replace only the table, never adjacent paragraphs or blank lines.
602
+ return { changes: { from: table.from, to: table.to, insert: "" }, selection: { anchor: table.from } };
603
+ }
604
+ if (command !== "table") return null;
605
+ if (!table && blocks.some(block => ["table", "code", "image"].includes(block.type) && from <= block.to && to >= block.from)) return null;
606
+ const columns = dimensions.columns ?? 2;
607
+ const rows = dimensions.rows ?? 3;
608
+ if (!Number.isInteger(columns) || columns < 1 || columns > 12 || !Number.isInteger(rows) || rows < 1 || rows > 30) return null;
609
+ const row = values => `| ${values.join(" | ")} |`;
610
+ const text = [row(Array.from({ length: columns }, (_, index) => `제목 ${index + 1}`)), row(Array(columns).fill("---")), ...Array.from({ length: rows }, () => row(Array(columns).fill("")))].join("\n");
611
+ const position = table ? table.to : to;
612
+ const prefix = position && !source.slice(0, position).endsWith("\n\n") ? (source[position - 1] === "\n" ? "\n" : "\n\n") : "";
613
+ const suffix = source.slice(position).startsWith("\n\n") ? "" : source[position] === "\n" ? "\n" : "\n\n";
614
+ return { changes: { from: position, to: position, insert: prefix + text + suffix }, selection: { anchor: position + prefix.length + 2 } };
615
+ }
616
+
223
617
  function mount(options = {}) {
224
618
  const cm = CodeMirror6;
225
619
  if (!cm?.EditorState || !cm?.StateEffect || !cm?.EditorView || !cm?.Decoration || !cm?.StateField) {
@@ -227,8 +621,40 @@
227
621
  }
228
622
  if (!options.parent) throw new Error("Markdown 편집기 표시 영역이 없습니다.");
229
623
  let destroyed = false;
624
+ let toolbar = null;
230
625
  const ownerDocument = options.parent.ownerDocument || document;
626
+ const counter = ownerDocument.createElement("div");
627
+ counter.className = "writer-markdown-character-count";
628
+ const updateCounter = doc => {
629
+ const label = `${characterCount(doc.toString()).toLocaleString("ko-KR")}자`;
630
+ if (counter.textContent !== label) counter.textContent = label;
631
+ };
632
+ const editableEffect = cm.StateEffect.define();
633
+ const tableOperationEffect = cm.StateEffect.define();
634
+ const tableHistoryBoundary = cm.StateField.define({
635
+ create: () => false,
636
+ update(value, transaction) {
637
+ return transaction.docChanged ? transaction.effects.some(effect => effect.is(tableOperationEffect)) : value;
638
+ }
639
+ });
640
+ const editableField = cm.StateField.define({
641
+ create: () => options.readOnly !== true,
642
+ update(value, transaction) {
643
+ for (const effect of transaction.effects) if (effect.is(editableEffect)) value = effect.value;
644
+ return value;
645
+ }
646
+ });
647
+ let replacingValue = false;
648
+ const imageEditor = typeof globalThis !== "undefined" ? globalThis.WriterMarkdownImageEditor : null;
649
+ const images = imageEditor?.create({
650
+ cm, model: markdownDocumentModel, assets: options.assets, parent: options.parent,
651
+ onIdle: () => setTimeout(() => {
652
+ if (!destroyed && !images?.hasPending() && !toolbar?.ownsFocus() && !options.parent.contains(ownerDocument.activeElement)) options.onBlur?.();
653
+ }, 0),
654
+ canInsert: (source, from, to) => !markdownDocumentModel.parseDocument(source).blocks.some(block => block.type === "table" && from <= block.to && to >= block.from)
655
+ });
231
656
  const focusEffect = cm.StateEffect.define();
657
+ const syntheticCellMaterializationEffect = cm.StateEffect.define();
232
658
  const focusField = cm.StateField.define({
233
659
  create() { return false; },
234
660
  update(focused, transaction) {
@@ -238,6 +664,35 @@
238
664
  return focused;
239
665
  }
240
666
  });
667
+ class TaskCheckbox {
668
+ constructor(from, checked, disabled) { this.from = from; this.checked = checked; this.disabled = disabled; }
669
+ eq(other) { return this.from === other.from && this.checked === other.checked && this.disabled === other.disabled; }
670
+ compare(other) { return this === other || this.eq(other); }
671
+ get estimatedHeight() { return -1; }
672
+ get lineBreaks() { return 0; }
673
+ updateDOM() { return false; }
674
+ coordsAt() { return null; }
675
+ destroy() {}
676
+ toDOM(view) {
677
+ const marker = ownerDocument.createElement("span");
678
+ marker.className = "writer-md-list-marker";
679
+ const input = ownerDocument.createElement("input");
680
+ input.type = "checkbox";
681
+ input.className = "writer-md-task-checkbox";
682
+ input.setAttribute("aria-label", "할 일 완료");
683
+ input.checked = this.checked;
684
+ input.disabled = this.disabled;
685
+ input.addEventListener("mousedown", event => event.preventDefault());
686
+ input.addEventListener("change", () => {
687
+ if (view.state.readOnly || view.composing || view.compositionStarted) { input.checked = this.checked; return; }
688
+ view.dispatch({ changes: { from: this.from, to: this.from + 1, insert: input.checked ? "x" : " " }, userEvent: "input.task" });
689
+ view.focus();
690
+ });
691
+ marker.append(input);
692
+ return marker;
693
+ }
694
+ ignoreEvent() { return true; }
695
+ }
241
696
  const buildDecorations = state => {
242
697
  const selections = state.field(focusField)
243
698
  ? state.selection.ranges.map(range => ({ anchor: range.anchor, head: range.head }))
@@ -248,7 +703,61 @@
248
703
  class: active ? "writer-md-syntax" : "writer-md-syntax-hidden"
249
704
  });
250
705
  model.lines.forEach(line => {
251
- ranges.push(cm.Decoration.line({ attributes: { class: classNames(line) } }).range(line.from));
706
+ if (line.blockType === "image" && images) return;
707
+ const attributes = { class: classNames(line) };
708
+ if (!state.doc.length && options.placeholder) attributes["data-placeholder"] = String(options.placeholder);
709
+ if (line.listType) attributes.style = `--writer-md-list-marker-width:${line.listMarkerWidth || 1}ch`;
710
+ if (line.blockType === "table") {
711
+ const columnCount = line.tableColumnCount || 1;
712
+ const minimumWidth = Math.max(14, Math.min(72, columnCount * 7));
713
+ const columnWidth = Number((100 / columnCount).toFixed(6));
714
+ attributes.style = `--writer-md-table-column-count:${columnCount};--writer-md-table-column-width:${columnWidth}%;--writer-md-table-min-width:${minimumWidth}em`;
715
+ }
716
+ ranges.push(cm.Decoration.line({ attributes }).range(line.from));
717
+ if (line.blockType === "horizontalRule") {
718
+ ranges.push(syntaxDecoration(line.active).range(line.from, line.to));
719
+ return;
720
+ }
721
+ if (line.blockType === "table") {
722
+ line.tableCells.forEach((cell, columnIndex) => {
723
+ if (cell.rawFrom >= cell.rawTo) return;
724
+ const alignment = ["left", "center", "right"].includes(line.tableAlignments?.[columnIndex])
725
+ ? line.tableAlignments[columnIndex]
726
+ : "left";
727
+ const positionClasses = [
728
+ columnIndex === 0 ? "writer-md-table-cell-first" : "",
729
+ columnIndex === line.tableCells.length - 1 ? "writer-md-table-cell-last" : "",
730
+ line.tableActiveCellColumns?.includes(columnIndex) ? "writer-md-table-cell-active" : ""
731
+ ].filter(Boolean).join(" ");
732
+ ranges.push(cm.Decoration.mark({
733
+ class: `writer-md-table-cell writer-md-table-align-${alignment}${positionClasses ? ` ${positionClasses}` : ""}`,
734
+ attributes: {
735
+ style: `--writer-md-table-cell-column:${columnIndex + 1}`,
736
+ "data-writer-md-table-cell-from": String(cell.from),
737
+ "data-writer-md-table-cell-to": String(cell.to)
738
+ },
739
+ inclusiveEnd: false
740
+ }).range(cell.rawFrom, cell.rawTo));
741
+ tableCellEdgeWhitespaceRanges(cell).forEach(whitespace => {
742
+ ranges.push(cm.Decoration.mark({
743
+ class: "writer-md-table-cell-edge-whitespace",
744
+ inclusiveEnd: false
745
+ }).range(whitespace.from, whitespace.to));
746
+ });
747
+ });
748
+ line.tablePipes.forEach(pipe => {
749
+ ranges.push(cm.Decoration.mark({
750
+ class: "writer-md-table-pipe",
751
+ inclusiveEnd: false
752
+ }).range(pipe.from, pipe.to));
753
+ });
754
+ line.tableEscapedPipeBackslashes.forEach(backslash => {
755
+ ranges.push(cm.Decoration.mark({
756
+ class: "writer-md-table-escaped-pipe-backslash",
757
+ inclusiveEnd: false
758
+ }).range(backslash.from, backslash.to));
759
+ });
760
+ }
252
761
  if (line.headingMarkerFrom >= 0) {
253
762
  const from = line.from + line.headingMarkerFrom;
254
763
  const to = line.from + line.headingMarkerTo;
@@ -258,6 +767,25 @@
258
767
  const to = line.from + line.quoteMarkerTo;
259
768
  ranges.push(syntaxDecoration(line.active).range(from, to));
260
769
  }
770
+ if (line.listIndentTo > 0) {
771
+ const from = line.from;
772
+ const to = line.from + line.listIndentTo;
773
+ ranges.push(cm.Decoration.mark({
774
+ class: "writer-md-list-leading-indent",
775
+ inclusiveEnd: false
776
+ }).range(from, to));
777
+ }
778
+ if (line.taskStateFrom >= 0 && line.blockType === "listItem") {
779
+ ranges.push(cm.Decoration.widget({ widget: new TaskCheckbox(line.from + line.taskStateFrom, line.taskChecked, state.readOnly), side: -1 }).range(line.from + line.listMarkerFrom));
780
+ ranges.push(syntaxDecoration(false).range(line.from + line.listMarkerFrom, line.from + line.taskMarkerTo));
781
+ } else if (line.listMarkerFrom >= 0) {
782
+ const from = line.from + line.listMarkerFrom;
783
+ const to = line.from + line.listMarkerTo;
784
+ ranges.push(cm.Decoration.mark({
785
+ class: "writer-md-list-marker",
786
+ inclusiveEnd: false
787
+ }).range(from, to));
788
+ }
261
789
  });
262
790
  model.tokens.forEach(token => {
263
791
  ranges.push(cm.Decoration.mark({ class: `writer-md-${token.type}` }).range(token.contentFrom, token.contentTo));
@@ -273,50 +801,621 @@
273
801
  },
274
802
  update(decorations, transaction) {
275
803
  const focusChanged = transaction.effects.some(effect => effect.is(focusEffect));
276
- return transaction.docChanged || transaction.selection || focusChanged ? buildDecorations(transaction.state) : decorations;
804
+ const editableChanged = transaction.effects.some(effect => effect.is(editableEffect));
805
+ return transaction.docChanged || transaction.selection || focusChanged || editableChanged ? buildDecorations(transaction.state) : decorations;
277
806
  },
278
807
  provide: field => cm.EditorView.decorations.from(field)
279
808
  });
809
+ const buildAtomicRanges = state => {
810
+ const model = previewModel(state.doc.toString(), []);
811
+ const ranges = [];
812
+ const atomic = cm.Decoration.mark({});
813
+ model.lines.filter(line => line.blockType === "table").forEach(line => {
814
+ if (line.tableRole === "delimiter") {
815
+ if (line.to > line.from) ranges.push(atomic.range(line.from, line.to));
816
+ return;
817
+ }
818
+ line.tablePipes.forEach(pipe => ranges.push(atomic.range(pipe.from, pipe.to)));
819
+ line.tableCells.forEach(cell => {
820
+ tableCellEdgeWhitespaceRanges(cell).forEach(whitespace => {
821
+ ranges.push(atomic.range(whitespace.from, whitespace.to));
822
+ });
823
+ });
824
+ line.tableEscapedPipeBackslashes.forEach(backslash => ranges.push(atomic.range(backslash.from, backslash.to)));
825
+ });
826
+ model.tokens.filter(token => token.tableCell === true).forEach(token => {
827
+ ranges.push(atomic.range(token.from, token.openTo));
828
+ ranges.push(atomic.range(token.closeFrom, token.to));
829
+ });
830
+ return cm.Decoration.set(ranges, true);
831
+ };
832
+ const tableAtomicField = cm.StateField.define({
833
+ create(state) {
834
+ return buildAtomicRanges(state);
835
+ },
836
+ update(ranges, transaction) {
837
+ return transaction.docChanged ? buildAtomicRanges(transaction.state) : ranges;
838
+ },
839
+ provide: field => cm.EditorView.atomicRanges.from(field, ranges => () => ranges)
840
+ });
841
+ const tableCellPointerPosition = (view, cellElement, event, from, to) => {
842
+ const nativePosition = view.posAtCoords({ x: event.clientX, y: event.clientY });
843
+ const documentRef = cellElement.ownerDocument;
844
+ const walker = documentRef.createTreeWalker(cellElement, 4);
845
+ let closestPosition = null;
846
+ let closestDistance = Number.POSITIVE_INFINITY;
847
+ for (let textNode = walker.nextNode(); textNode; textNode = walker.nextNode()) {
848
+ const parent = textNode.parentElement;
849
+ if (parent?.closest?.(".writer-md-syntax-hidden,.writer-md-table-cell-edge-whitespace,.writer-md-table-escaped-pipe-backslash")) continue;
850
+ for (let offset = 0; offset <= textNode.nodeValue.length; offset += 1) {
851
+ let position = null;
852
+ try {
853
+ position = view.posAtDOM(textNode, offset);
854
+ } catch {
855
+ continue;
856
+ }
857
+ if (!Number.isSafeInteger(position) || position < from || position > to) continue;
858
+ const range = documentRef.createRange();
859
+ range.setStart(textNode, offset);
860
+ range.collapse(true);
861
+ const rect = range.getBoundingClientRect();
862
+ const verticalDistance = event.clientY < rect.top
863
+ ? rect.top - event.clientY
864
+ : event.clientY > rect.bottom
865
+ ? event.clientY - rect.bottom
866
+ : 0;
867
+ const distance = (verticalDistance * 10000) + Math.abs(event.clientX - rect.left);
868
+ if (distance >= closestDistance) continue;
869
+ closestDistance = distance;
870
+ closestPosition = position;
871
+ }
872
+ }
873
+ if (Number.isSafeInteger(closestPosition)) return closestPosition;
874
+ if (Number.isSafeInteger(nativePosition) && nativePosition >= from && nativePosition <= to) {
875
+ return nativePosition;
876
+ }
877
+
878
+ const rect = cellElement.getBoundingClientRect();
879
+ const ratio = rect.width > 0
880
+ ? Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))
881
+ : 0;
882
+ return Math.max(from, Math.min(to, from + Math.round((to - from) * ratio)));
883
+ };
884
+ let tablePointerStart = null;
885
+ const handleTableCellMouseDown = (event, view) => {
886
+ tablePointerStart = null;
887
+ if (event.button !== 0 || event.detail !== 1 || event.shiftKey) return false;
888
+ const cellElement = event.target?.closest?.(".writer-md-table-cell");
889
+ if (!cellElement || !view.dom.contains(cellElement)) return false;
890
+ const from = Number(cellElement.getAttribute("data-writer-md-table-cell-from"));
891
+ const to = Number(cellElement.getAttribute("data-writer-md-table-cell-to"));
892
+ if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from > to) return false;
893
+ const position = tableCellPointerPosition(view, cellElement, event, from, to);
894
+ view.focus();
895
+ tablePointerStart = { clientX: event.clientX, clientY: event.clientY, from, to, position };
896
+ return false;
897
+ };
898
+ const tableCellWordSelection = (source, from, to, position) => {
899
+ const text = source.slice(from, to);
900
+ const relativePosition = Math.max(0, Math.min(text.length, position - from));
901
+ if (typeof Intl?.Segmenter === "function") {
902
+ const segments = new Intl.Segmenter(undefined, { granularity: "word" }).segment(text);
903
+ for (const segment of segments) {
904
+ const segmentFrom = segment.index;
905
+ const segmentTo = segment.index + segment.segment.length;
906
+ if (relativePosition < segmentFrom || relativePosition > segmentTo || !segment.segment.trim()) continue;
907
+ return { anchor: from + segmentFrom, head: from + segmentTo };
908
+ }
909
+ }
910
+ let wordFrom = relativePosition;
911
+ let wordTo = relativePosition;
912
+ while (wordFrom > 0 && !/\s/u.test(text[wordFrom - 1])) wordFrom -= 1;
913
+ while (wordTo < text.length && !/\s/u.test(text[wordTo])) wordTo += 1;
914
+ return wordFrom === wordTo
915
+ ? { anchor: Math.max(from, Math.min(to, position)), head: Math.max(from, Math.min(to, position)) }
916
+ : { anchor: from + wordFrom, head: from + wordTo };
917
+ };
918
+ const handleTableCellMouseUp = (event, view) => {
919
+ const start = tablePointerStart;
920
+ tablePointerStart = null;
921
+ if (event.button !== 0 || event.shiftKey) return false;
922
+ if (event.detail === 2) {
923
+ const cellElement = event.target?.closest?.(".writer-md-table-cell");
924
+ if (!cellElement || !view.dom.contains(cellElement)) return false;
925
+ const from = Number(cellElement.getAttribute("data-writer-md-table-cell-from"));
926
+ const to = Number(cellElement.getAttribute("data-writer-md-table-cell-to"));
927
+ if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from > to) return false;
928
+ const position = tableCellPointerPosition(view, cellElement, event, from, to);
929
+ event.preventDefault();
930
+ view.dispatch({
931
+ selection: tableCellWordSelection(view.state.doc.toString(), from, to, position),
932
+ scrollIntoView: true
933
+ });
934
+ return true;
935
+ }
936
+ if (!start || event.detail !== 1) return false;
937
+ if (Math.hypot(event.clientX - start.clientX, event.clientY - start.clientY) > 4) return false;
938
+ const cellElement = event.target?.closest?.(".writer-md-table-cell");
939
+ if (!cellElement || !view.dom.contains(cellElement)) return false;
940
+ const from = Number(cellElement.getAttribute("data-writer-md-table-cell-from"));
941
+ const to = Number(cellElement.getAttribute("data-writer-md-table-cell-to"));
942
+ if (from !== start.from || to !== start.to) return false;
943
+ event.preventDefault();
944
+ view.dispatch({ selection: { anchor: start.position }, scrollIntoView: true });
945
+ return true;
946
+ };
947
+ const tableViewIsComposing = view => Boolean(view.composing || view.compositionStarted);
948
+ const continueListMarkup = cm.insertNewlineContinueMarkupCommand({ nonTightLists: false });
949
+ const moveToTableDestination = (view, destination) => {
950
+ if (!destination || !Number.isSafeInteger(destination.position)) return false;
951
+ const materialization = syntheticTableCellMaterialization(destination.context);
952
+ if (materialization?.insert) {
953
+ view.dispatch({
954
+ changes: {
955
+ from: materialization.from,
956
+ to: materialization.from,
957
+ insert: materialization.insert
958
+ },
959
+ selection: { anchor: materialization.position },
960
+ effects: syntheticCellMaterializationEffect.of({
961
+ tableFrom: destination.context.table.block.from,
962
+ lineIndex: destination.context.line.index,
963
+ columnIndex: destination.context.columnIndex,
964
+ ...materialization
965
+ }),
966
+ userEvent: "input.type",
967
+ scrollIntoView: true
968
+ });
969
+ return true;
970
+ }
971
+ view.dispatch({ selection: { anchor: destination.position }, scrollIntoView: true });
972
+ return true;
973
+ };
974
+ const moveTableCell = direction => view => {
975
+ if (tableViewIsComposing(view)) return false;
976
+ const selection = view.state.selection.main;
977
+ if (!selection.empty) return false;
978
+ const source = view.state.doc.toString();
979
+ const current = tableCellContextAt(source, selection.head);
980
+ const destination = tableCellNavigationDestination(
981
+ source,
982
+ { anchor: selection.anchor, head: selection.head },
983
+ direction
984
+ );
985
+ if (!destination || (destination.position === selection.head && !destination.context.cell.synthetic)) {
986
+ const atProtectedOuterBoundary = current && (
987
+ (direction === "left" && selection.head <= current.cell.from && current.columnIndex === 0 && current.rowIndex === 0)
988
+ || (
989
+ direction === "right"
990
+ && selection.head >= current.cell.to
991
+ && current.rowIndex === current.table.rows.length - 1
992
+ && current.columnIndex === current.table.rows.at(-1)?.cells.at(-1)?.columnIndex
993
+ )
994
+ );
995
+ return Boolean(atProtectedOuterBoundary);
996
+ }
997
+ return moveToTableDestination(view, destination);
998
+ };
999
+ const protectTableBoundary = direction => view => {
1000
+ if (tableViewIsComposing(view)) return false;
1001
+ const selection = view.state.selection.main;
1002
+ const source = view.state.doc.toString();
1003
+ if (!selection.empty) {
1004
+ return tableSelectionCrossesStructure(source, { anchor: selection.anchor, head: selection.head });
1005
+ }
1006
+ const current = tableCellContextAt(source, selection.head);
1007
+ if (!current) return false;
1008
+ const pipeOffset = direction === "backward" ? selection.head - 1 : selection.head;
1009
+ const atVisibleBoundary = direction === "backward"
1010
+ ? selection.head <= current.cell.from
1011
+ : selection.head >= current.cell.to;
1012
+ const atStructuralPipe = current.line.tablePipes.some(pipe => pipe.from === pipeOffset);
1013
+ if (!atVisibleBoundary && !atStructuralPipe) return false;
1014
+ const destination = tableCellNavigationDestination(
1015
+ source,
1016
+ { anchor: selection.anchor, head: selection.head },
1017
+ direction === "backward" ? "previous" : "next"
1018
+ );
1019
+ if (destination && (
1020
+ destination.position !== selection.head || destination.context.cell.synthetic
1021
+ )) {
1022
+ moveToTableDestination(view, destination);
1023
+ }
1024
+ return true;
1025
+ };
1026
+ const enterTableCell = view => {
1027
+ if (tableViewIsComposing(view)) return false;
1028
+ const selection = view.state.selection.main;
1029
+ if (!selection.empty || !tableCellContextAt(view.state.doc.toString(), selection.head)) return false;
1030
+ const destination = tableCellNavigationDestination(
1031
+ view.state.doc.toString(),
1032
+ { anchor: selection.anchor, head: selection.head },
1033
+ "down"
1034
+ );
1035
+ if (destination && (destination.position !== selection.head || destination.context.cell.synthetic)) {
1036
+ moveToTableDestination(view, destination);
1037
+ }
1038
+ return true;
1039
+ };
1040
+ const handleEnter = view => enterTableCell(view) || continueListMarkup(view);
1041
+ const replaceWithSafeTableCellInput = (view, from, to, text, userEvent = "input.type") => {
1042
+ if (tableViewIsComposing(view)) return false;
1043
+ const source = view.state.doc.toString();
1044
+ if (tableSelectionCrossesStructure(source, { anchor: from, head: to })) return true;
1045
+ const tables = tableCellContexts(source);
1046
+ const start = tableCellContextFromTables(tables, from);
1047
+ const end = tableCellContextFromTables(tables, to);
1048
+ if (!start || !end || start.line.index !== end.line.index || start.columnIndex !== end.columnIndex) return false;
1049
+ const safeText = safeTableCellReplacement(source, from, to, text);
1050
+ if (safeText === text) return false;
1051
+ view.dispatch({
1052
+ changes: { from, to, insert: safeText },
1053
+ selection: { anchor: from + safeText.length },
1054
+ userEvent,
1055
+ scrollIntoView: true
1056
+ });
1057
+ return true;
1058
+ };
1059
+ const tableInputHandler = cm.EditorView.inputHandler.of((view, from, to, text) => (
1060
+ replaceWithSafeTableCellInput(view, from, to, text)
1061
+ ));
1062
+ const handleTableBeforeInput = (event, view) => {
1063
+ if (event.isComposing || tableViewIsComposing(view)) return false;
1064
+ const selection = view.state.selection.main;
1065
+ const crossesStructure = tableSelectionCrossesStructure(
1066
+ view.state.doc.toString(),
1067
+ { anchor: selection.anchor, head: selection.head }
1068
+ );
1069
+ if (crossesStructure && /^(?:insert|delete)/.test(event.inputType)) {
1070
+ event.preventDefault();
1071
+ return true;
1072
+ }
1073
+ if (!["insertText", "insertReplacementText"].includes(event.inputType) || typeof event.data !== "string") {
1074
+ return false;
1075
+ }
1076
+ if (!replaceWithSafeTableCellInput(view, selection.from, selection.to, event.data, "input.type")) return false;
1077
+ event.preventDefault();
1078
+ return true;
1079
+ };
1080
+ const handleTablePaste = (event, view) => {
1081
+ if (tableViewIsComposing(view)) return false;
1082
+ const text = event.clipboardData?.getData("text/plain");
1083
+ if (typeof text !== "string") return false;
1084
+ const selection = view.state.selection.main;
1085
+ if (!replaceWithSafeTableCellInput(view, selection.from, selection.to, text, "input.paste")) return false;
1086
+ event.preventDefault();
1087
+ return true;
1088
+ };
1089
+ const validatesSyntheticCellMaterialization = (transaction, tables, request, changes) => {
1090
+ if (!request || changes.length !== 1) return false;
1091
+ const table = tables.find(candidate => candidate.block.from === request.tableFrom);
1092
+ const context = table?.cells.find(candidate => (
1093
+ candidate.line.index === request.lineIndex && candidate.columnIndex === request.columnIndex
1094
+ ));
1095
+ const expected = syntheticTableCellMaterialization(context && { ...context, table });
1096
+ const change = changes[0];
1097
+ if (
1098
+ !expected?.insert
1099
+ || request.from !== expected.from
1100
+ || request.insert !== expected.insert
1101
+ || request.position !== expected.position
1102
+ || change.fromA !== expected.from
1103
+ || change.toA !== expected.from
1104
+ || change.insert !== expected.insert
1105
+ ) return false;
1106
+
1107
+ const nextTable = tableCellContexts(transaction.newDoc.toString())
1108
+ .find(candidate => candidate.block.from === table.block.from);
1109
+ const nextLine = nextTable?.lines.find(line => line.index === context.line.index);
1110
+ const nextTarget = nextLine?.tableCells?.[context.columnIndex];
1111
+ if (
1112
+ !nextTable
1113
+ || nextTable.block.columnCount !== table.block.columnCount
1114
+ || nextTable.rows.length !== table.rows.length
1115
+ || nextTable.lines.length !== table.lines.length
1116
+ || nextTable.block.alignments.join("|") !== table.block.alignments.join("|")
1117
+ || !nextTarget?.synthetic
1118
+ || nextTarget.from !== expected.position
1119
+ || nextLine.tableHasLeadingPipe !== context.line.tableHasLeadingPipe
1120
+ || !nextLine.tableHasTrailingPipe
1121
+ ) return false;
1122
+
1123
+ const existingText = context.line.tableCells.filter(cell => !cell.synthetic).map(cell => cell.text);
1124
+ const nextText = nextLine.tableCells.slice(0, existingText.length).map(cell => cell.text);
1125
+ if (existingText.join("\u0000") !== nextText.join("\u0000")) return false;
1126
+ if (nextLine.tableCells.slice(0, context.columnIndex).some(cell => cell.synthetic)) return false;
1127
+ const oldPipes = context.line.tablePipes.map(pipe => pipe.from);
1128
+ const nextPipes = nextLine.tablePipes.map(pipe => pipe.from);
1129
+ const addedPipes = Array.from({ length: expected.insert.length }, (_, index) => expected.from + index);
1130
+ return nextPipes.length === oldPipes.length + addedPipes.length
1131
+ && oldPipes.every((position, index) => nextPipes[index] === position)
1132
+ && addedPipes.every((position, index) => nextPipes[oldPipes.length + index] === position);
1133
+ };
1134
+ const tableStructureChangeFilter = cm.EditorState.changeFilter.of(transaction => {
1135
+ if (!transaction.docChanged) return true;
1136
+ if (transaction.isUserEvent?.("undo") || transaction.isUserEvent?.("redo")) return true;
1137
+ const source = transaction.startState.doc.toString();
1138
+ const tableOperations = transaction.effects.filter(effect => effect.is(tableOperationEffect));
1139
+ if (tableOperations.length) {
1140
+ if (tableOperations.length !== 1 || transaction.startState.readOnly) return false;
1141
+ const request = tableOperations[0].value;
1142
+ const expected = tableOperation(source, transaction.startState.selection.main, request.command, request.dimensions);
1143
+ if (!expected) return false;
1144
+ const actual = [];
1145
+ transaction.changes.iterChanges((from, to, _fromB, _toB, inserted) => actual.push({ from, to, insert: inserted.toString() }));
1146
+ return actual.length === 1 && actual[0].from === expected.changes.from && actual[0].to === expected.changes.to && actual[0].insert === expected.changes.insert;
1147
+ }
1148
+ const tables = tableCellContexts(source);
1149
+ if (!tables.length) return true;
1150
+ const changes = [];
1151
+ transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
1152
+ changes.push({ fromA, toA, fromB, toB, insert: inserted.toString() });
1153
+ });
1154
+ const materializationEffects = transaction.effects.filter(effect => (
1155
+ effect.is(syntheticCellMaterializationEffect)
1156
+ ));
1157
+ if (materializationEffects.length) {
1158
+ return materializationEffects.length === 1 && validatesSyntheticCellMaterialization(
1159
+ transaction,
1160
+ tables,
1161
+ materializationEffects[0].value,
1162
+ changes
1163
+ );
1164
+ }
1165
+ const touchedTables = tables.filter(table => {
1166
+ const guardFrom = Math.max(0, table.block.from - 1);
1167
+ let guardTo = Math.min(source.length, table.block.to + 1);
1168
+ const nextLineStart = table.block.to + 1;
1169
+ if (source[table.block.to] === "\n" && nextLineStart < source.length && source[nextLineStart] !== "\n") {
1170
+ const nextBreak = source.indexOf("\n", nextLineStart);
1171
+ guardTo = nextBreak < 0 ? source.length : nextBreak;
1172
+ }
1173
+ return changes.some(change => (
1174
+ change.fromA === change.toA
1175
+ ? change.fromA >= guardFrom && change.fromA <= guardTo
1176
+ : change.fromA < guardTo && change.toA > guardFrom
1177
+ ));
1178
+ });
1179
+ if (!touchedTables.length) return true;
1180
+
1181
+ for (const table of touchedTables) {
1182
+ const delimiter = table.lines.find(line => line.tableRole === "delimiter");
1183
+ for (const change of changes) {
1184
+ if (
1185
+ change.fromA === change.toA
1186
+ && delimiter
1187
+ && change.fromA >= delimiter.from
1188
+ && change.fromA <= delimiter.to
1189
+ ) return false;
1190
+ if (change.fromA === change.toA) continue;
1191
+ const structuralRanges = table.lines.flatMap((line, lineIndex) => {
1192
+ const ranges = line.tableRole === "delimiter"
1193
+ ? [{ from: line.from, to: line.to }]
1194
+ : [...line.tablePipes];
1195
+ if (lineIndex < table.lines.length - 1) ranges.push({ from: line.to, to: line.to + 1 });
1196
+ return ranges;
1197
+ });
1198
+ if (table.block.from > 0 && source[table.block.from - 1] === "\n") {
1199
+ structuralRanges.push({ from: table.block.from - 1, to: table.block.from });
1200
+ }
1201
+ if (table.block.to < source.length && source[table.block.to] === "\n") {
1202
+ structuralRanges.push({ from: table.block.to, to: table.block.to + 1 });
1203
+ }
1204
+ if (structuralRanges.some(range => change.fromA < range.to && change.toA > range.from)) {
1205
+ return false;
1206
+ }
1207
+ }
1208
+ }
1209
+
1210
+ const nextTables = tableCellContexts(transaction.newDoc.toString());
1211
+ return touchedTables.every(table => {
1212
+ const mappedFrom = transaction.changes.mapPos(table.block.from, -1);
1213
+ const mappedTo = transaction.changes.mapPos(table.block.to, 1);
1214
+ const nextTable = nextTables.find(candidate => candidate.block.from === mappedFrom);
1215
+ if (!nextTable) return false;
1216
+ if (nextTable.block.to !== mappedTo) return false;
1217
+ if (nextTable.block.columnCount !== table.block.columnCount) return false;
1218
+ if (nextTable.rows.length !== table.rows.length) return false;
1219
+ if (nextTable.lines.length !== table.lines.length) return false;
1220
+ if (nextTable.block.alignments.join("|") !== table.block.alignments.join("|")) return false;
1221
+ return nextTable.lines.every((line, index) => {
1222
+ const previousLine = table.lines[index];
1223
+ if (line.tableRole !== previousLine?.tableRole) return false;
1224
+ if (line.from !== transaction.changes.mapPos(previousLine.from, -1)) return false;
1225
+ if (line.to !== transaction.changes.mapPos(previousLine.to, 1)) return false;
1226
+ if (line.tableHasLeadingPipe !== previousLine.tableHasLeadingPipe) return false;
1227
+ if (line.tableRole === "delimiter" && line.tableRaw !== previousLine.tableRaw) return false;
1228
+ const mappedPipes = previousLine.tablePipes.map(pipe => transaction.changes.mapPos(pipe.from, 1));
1229
+ return mappedPipes.length === line.tablePipes.length
1230
+ && mappedPipes.every((position, pipeIndex) => position === line.tablePipes[pipeIndex]?.from);
1231
+ });
1232
+ });
1233
+ });
280
1234
  const state = cm.EditorState.create({
281
1235
  doc: String(options.value ?? "").replace(/\r\n?/g, "\n"),
282
1236
  extensions: [
283
- cm.EditorView.editorAttributes.of({
284
- class: "writer-markdown-live-editor",
285
- ...(options.placeholder ? { "data-placeholder": String(options.placeholder) } : {})
1237
+ editableField,
1238
+ cm.EditorState.readOnly.compute([editableField], state => !state.field(editableField)),
1239
+ cm.EditorView.editable.compute([editableField], state => state.field(editableField)),
1240
+ cm.EditorState.changeFilter.of(transaction => !transaction.startState.readOnly),
1241
+ ...(images?.extensions || []),
1242
+ cm.EditorView.editorAttributes.of({ class: "writer-markdown-live-editor" }),
1243
+ tableHistoryBoundary,
1244
+ cm.history({
1245
+ joinToEvent: (transaction, adjacent) => adjacent
1246
+ && !transaction.startState.field(tableHistoryBoundary)
1247
+ && !transaction.effects.some(effect => effect.is(tableOperationEffect))
286
1248
  }),
287
- cm.history(),
288
1249
  cm.keymap.of([
289
1250
  { key: "Escape", run() { options.onEscape?.(); return true; } },
1251
+ { key: "Enter", run: handleEnter },
1252
+ { key: "Shift-Enter", run: enterTableCell },
1253
+ { key: "Backspace", run: protectTableBoundary("backward") },
1254
+ { key: "Backspace", run: cm.deleteMarkupBackward },
1255
+ { key: "Delete", run: protectTableBoundary("forward") },
1256
+ { key: "ArrowLeft", run: moveTableCell("left") },
1257
+ { key: "ArrowRight", run: moveTableCell("right") },
1258
+ { key: "ArrowUp", run: moveTableCell("up") },
1259
+ { key: "ArrowDown", run: moveTableCell("down") },
1260
+ { key: "Tab", run: moveTableCell("next"), shift: moveTableCell("previous") },
290
1261
  ...cm.defaultKeymap,
291
1262
  ...cm.historyKeymap
292
1263
  ]),
293
- cm.markdown(),
1264
+ cm.markdown({ addKeymap: false }),
294
1265
  cm.EditorView.lineWrapping,
1266
+ tableInputHandler,
1267
+ tableStructureChangeFilter,
295
1268
  focusField,
296
1269
  previewField,
1270
+ tableAtomicField,
297
1271
  cm.EditorView.contentAttributes.of({
298
1272
  role: "textbox",
299
1273
  "aria-multiline": "true",
300
1274
  "aria-label": String(options.ariaLabel || "Markdown 편집")
301
1275
  }),
302
1276
  cm.EditorView.updateListener.of(update => {
303
- if (update.docChanged) options.onChange?.(update.state.doc.toString());
1277
+ if (update.docChanged) updateCounter(update.state.doc);
1278
+ if (update.docChanged && !replacingValue) options.onChange?.(update.state.doc.toString());
1279
+ toolbar?.refresh();
304
1280
  }),
305
1281
  cm.EditorView.domEventHandlers({
1282
+ mousedown: handleTableCellMouseDown,
1283
+ mouseup: handleTableCellMouseUp,
1284
+ beforeinput: handleTableBeforeInput,
1285
+ paste: handleTablePaste,
306
1286
  focus() {
307
- view.dispatch({ effects: focusEffect.of(true) });
1287
+ if (!view.state.readOnly) view.dispatch({ effects: focusEffect.of(true) });
308
1288
  },
309
1289
  blur() {
310
1290
  view.dispatch({ effects: focusEffect.of(false) });
311
1291
  setTimeout(() => {
312
- if (!destroyed && !view.dom.contains(ownerDocument.activeElement)) options.onBlur?.();
1292
+ if (!destroyed && !images?.hasPending() && !toolbar?.ownsFocus() && !options.parent.contains(ownerDocument.activeElement)) options.onBlur?.();
313
1293
  }, 0);
314
1294
  }
315
1295
  })
316
1296
  ]
317
1297
  });
318
1298
  const view = new cm.EditorView({ state, parent: options.parent });
1299
+ updateCounter(state.doc);
1300
+ options.parent.appendChild(counter);
1301
+ images?.attach(view);
1302
+ let toolbarDocument = null;
1303
+ let toolbarParsed = null;
1304
+ let toolbarState = null;
1305
+ let toolbarContext = null;
1306
+ const historyCommands = {
1307
+ undo: cm.historyKeymap.find(binding => binding.key === "Mod-z")?.run,
1308
+ redo: cm.historyKeymap.find(binding => binding.key === "Mod-y")?.run
1309
+ || cm.historyKeymap.find(binding => binding.key === "Mod-z")?.shift
1310
+ };
1311
+ const formatContext = () => {
1312
+ if (destroyed || view.state.readOnly) return null;
1313
+ if (toolbarState === view.state) return toolbarContext;
1314
+ // Share one parse across buttons and reuse it while only the selection moves.
1315
+ if (toolbarDocument !== view.state.doc) {
1316
+ toolbarDocument = view.state.doc;
1317
+ const source = toolbarDocument.toString();
1318
+ toolbarParsed = { source, tokens: inlineTokens(source), blocks: markdownDocumentModel.parseDocument(source).blocks };
1319
+ }
1320
+ const selection = view.state.selection.main;
1321
+ const multiline = toolbarParsed.source.slice(selection.from, selection.to).includes("\n");
1322
+ const parts = multiline ? toolbarApi.inlineSelectionParts(toolbarParsed.source, selection.from, selection.to, toolbarParsed.tokens) : [];
1323
+ toolbarState = view.state;
1324
+ toolbarContext = {
1325
+ ...toolbarParsed,
1326
+ selection,
1327
+ hasInlineText: !multiline || parts.length > 0,
1328
+ // Probe public state commands without dispatching their transactions.
1329
+ // Cache per EditorState so rendering button states never changes history.
1330
+ history: Object.fromEntries(Object.entries(historyCommands).map(([command, run]) => [
1331
+ command, Boolean(run?.({ state: view.state, dispatch() {} }))
1332
+ ])),
1333
+ cell: tableCellContextAt(toolbarParsed.source, selection.head),
1334
+ label: options.ariaLabel || "본문 편집",
1335
+ marks: Object.entries(toolbarApi?.marks || {})
1336
+ .filter(([name, marker]) => multiline
1337
+ ? parts.length > 0 && parts.every(part => toolbarApi.partMark(part, name, toolbarParsed.tokens))
1338
+ : toolbarParsed.tokens.some(token => (token.marker === marker || (name === "italic" && token.marker === "_")) && selection.from >= token.contentFrom && selection.to <= token.contentTo))
1339
+ .map(([name]) => name)
1340
+ };
1341
+ return toolbarContext;
1342
+ };
1343
+ const formatDisabledReason = command => {
1344
+ const context = formatContext();
1345
+ if (!context) return "본문을 먼저 선택하세요";
1346
+ if (view.composing || view.compositionStarted) return "한글 입력을 마친 뒤 사용하세요";
1347
+ if (view.state.selection.ranges.length !== 1) return "하나의 영역을 선택하세요";
1348
+ if (command === "undo" || command === "redo") return context.history[command]
1349
+ ? "" : command === "undo" ? "실행 취소할 변경이 없습니다" : "다시 실행할 변경이 없습니다";
1350
+ const { source, selection, tokens, blocks, cell } = context;
1351
+ if (toolbarApi.marks[command] && selection.empty) return "꾸밀 텍스트를 먼저 선택하세요";
1352
+ if (toolbarApi.marks[command] && !context.hasInlineText) return "꾸밀 텍스트를 먼저 선택하세요";
1353
+ if (command === "table-delete") return tableOperation(source, selection, command) ? "" : "삭제할 표 안을 먼저 선택하세요";
1354
+ if (command === "table") return tableOperation(source, selection, command) ? "" : "한 표 안이나 일반 본문을 선택하세요";
1355
+ if (tokens.some(token => token.type === "code" && selection.from >= token.contentFrom && selection.to <= token.contentTo)) return "코드 밖에서 사용하세요";
1356
+ const touches = block => selection.from <= block.to && selection.to >= block.from;
1357
+ if (blocks.some(block => ["image", "code"].includes(block.type) && touches(block))) return "이미지·코드 블록 밖에서 사용하세요";
1358
+ const tableTouched = blocks.some(block => block.type === "table" && touches(block));
1359
+ if (tableTouched && (!toolbarApi.marks[command] || !cell || selection.from < cell.cell.from || selection.to > cell.cell.to || cell.cell.synthetic)) return "표 안에서는 한 셀의 글자 서식만 바꿀 수 있습니다";
1360
+ if (command === "image") return options.assets?.upload ? "" : "이 입력란에는 이미지 에셋 연결이 아직 없습니다";
1361
+ return "";
1362
+ };
1363
+ toolbar = toolbarApi?.register(ownerDocument, {
1364
+ contains: node => options.parent.contains(node),
1365
+ context: formatContext,
1366
+ disabledReason: formatDisabledReason,
1367
+ focus: () => view.focus(),
1368
+ blur: () => { if (!destroyed && !images?.hasPending()) options.onBlur?.(); },
1369
+ run(command, dimensions) {
1370
+ if (formatDisabledReason(command)) return false;
1371
+ if (command === "table" || command === "table-delete") {
1372
+ const { source, selection } = formatContext();
1373
+ const edit = tableOperation(source, selection, command, dimensions);
1374
+ if (!edit) return false;
1375
+ view.dispatch({ ...edit, effects: tableOperationEffect.of({ command, dimensions }), userEvent: command === "table" ? "input.table" : "delete.table", scrollIntoView: true });
1376
+ view.focus();
1377
+ return true;
1378
+ }
1379
+ if (command === "image") {
1380
+ images?.chooseAsset();
1381
+ return true;
1382
+ }
1383
+ if (command === "undo" || command === "redo") {
1384
+ const run = historyCommands[command];
1385
+ run?.(view);
1386
+ view.focus();
1387
+ return true;
1388
+ }
1389
+ const { source, selection, tokens } = formatContext();
1390
+ const edit = toolbarApi.change(source, selection.anchor, selection.head, command, tokens);
1391
+ if (!edit) return false;
1392
+ view.dispatch({ ...edit, userEvent: "input.format" });
1393
+ view.focus();
1394
+ return true;
1395
+ }
1396
+ });
1397
+ const onToolbarKey = event => {
1398
+ if (event.altKey && event.key === "F10") { event.preventDefault(); toolbar?.focus(); }
1399
+ };
1400
+ const onComposition = () => { toolbar?.refresh(); };
1401
+ const onCompositionEnd = () => { setTimeout(onComposition, 0); };
1402
+ options.parent.addEventListener("keydown", onToolbarKey);
1403
+ options.parent.addEventListener("compositionstart", onComposition);
1404
+ options.parent.addEventListener("compositionend", onCompositionEnd);
1405
+ options.parent.toggleAttribute("data-markdown-readonly", view.state.readOnly);
319
1406
  return Object.freeze({
1407
+ setEditable(editable) {
1408
+ view.dispatch({ effects: [editableEffect.of(Boolean(editable)), focusEffect.of(Boolean(editable) && view.hasFocus)] });
1409
+ options.parent.toggleAttribute("data-markdown-readonly", !editable);
1410
+ },
1411
+ setValue(value) {
1412
+ const text = String(value ?? "").replace(/\r\n?/g, "\n");
1413
+ if (text === view.state.doc.toString()) return;
1414
+ replacingValue = true;
1415
+ try { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text }, filter: false }); }
1416
+ finally { replacingValue = false; }
1417
+ },
1418
+ hasPendingImages: () => images?.hasPending() || false,
320
1419
  getValue: () => view.state.doc.toString(),
321
1420
  getSelection: () => view.state.selection.ranges.map(range => ({ anchor: range.anchor, head: range.head })),
322
1421
  focusAtPosition(position, options = {}) {
@@ -330,21 +1429,58 @@
330
1429
  view.focus();
331
1430
  },
332
1431
  focusAtClientPoint(clientX, clientY) {
333
- const position = Number.isFinite(clientX) && Number.isFinite(clientY)
334
- ? view.posAtCoords({ x: clientX, y: clientY }, false)
335
- : null;
336
- const anchor = position == null ? view.state.doc.length : position;
1432
+ const anchor = this.positionAtClientPoint(clientX, clientY);
337
1433
  view.dispatch({ selection: { anchor } });
338
1434
  view.focus();
339
1435
  return anchor;
340
1436
  },
1437
+ positionAtClientPoint(clientX, clientY) {
1438
+ let position = null;
1439
+ if (Number.isFinite(clientX) && Number.isFinite(clientY)) {
1440
+ const cellElement = ownerDocument.elementFromPoint?.(clientX, clientY)?.closest?.(".writer-md-table-cell");
1441
+ if (cellElement && view.dom.contains(cellElement)) {
1442
+ const from = Number(cellElement.getAttribute("data-writer-md-table-cell-from"));
1443
+ const to = Number(cellElement.getAttribute("data-writer-md-table-cell-to"));
1444
+ if (Number.isSafeInteger(from) && Number.isSafeInteger(to) && from <= to) {
1445
+ position = tableCellPointerPosition(view, cellElement, { clientX, clientY }, from, to);
1446
+ }
1447
+ }
1448
+ if (position == null) position = view.posAtCoords({ x: clientX, y: clientY }, false);
1449
+ }
1450
+ const anchor = position == null ? view.state.doc.length : position;
1451
+ return anchor;
1452
+ },
341
1453
  destroy() {
342
1454
  if (destroyed) return;
343
1455
  destroyed = true;
1456
+ toolbar?.destroy();
1457
+ options.parent.removeEventListener("keydown",onToolbarKey);
1458
+ options.parent.removeEventListener("compositionstart",onComposition);
1459
+ options.parent.removeEventListener("compositionend",onCompositionEnd);
1460
+ options.parent.removeAttribute("data-markdown-readonly");
1461
+ images?.destroy();
344
1462
  view.destroy();
1463
+ counter.remove();
345
1464
  }
346
1465
  });
347
1466
  }
348
1467
 
349
- return Object.freeze({ inlineTokens, lineSyntax, layoutLines, previewModel, mount });
1468
+ return Object.freeze({
1469
+ characterCount,
1470
+ inlineTokens,
1471
+ tableEscapedPipeBackslashes,
1472
+ tableCellEdgeWhitespaceRanges,
1473
+ lineSyntax,
1474
+ layoutLines,
1475
+ tableCellContexts,
1476
+ tableCellContextAt,
1477
+ tableSelectionCrossesStructure,
1478
+ tableCellNavigationTarget,
1479
+ syntheticTableCellMaterialization,
1480
+ safeTableCellInput,
1481
+ safeTableCellReplacement,
1482
+ tableOperation,
1483
+ previewModel,
1484
+ mount
1485
+ });
350
1486
  });