@ssobig/writer-cli 0.3.0 → 0.3.2

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 (26) 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/codemirror6-runtime.min.js +1 -1
  5. package/templates/mystery-v1/component-asset-operations.js +14 -2
  6. package/templates/mystery-v1/component-field-contracts.js +98 -2
  7. package/templates/mystery-v1/component-navigation-counts.js +6 -1
  8. package/templates/mystery-v1/component-storage-contract.js +28 -9
  9. package/templates/mystery-v1/markdown-document-model.js +444 -0
  10. package/templates/mystery-v1/markdown-image-editor.js +320 -0
  11. package/templates/mystery-v1/markdown-live-editor.js +979 -105
  12. package/templates/mystery-v1/timeline-model.js +235 -0
  13. package/tools/writer-cli/package-lock.json +2 -2
  14. package/tools/writer-cli/package.json +1 -1
  15. package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +12 -13
  16. package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -1
  17. package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +4 -1
  18. package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +3 -3
  19. package/tools/writer-cli/skills/ssobig-writer-cli/references/investigation-board.md +9 -0
  20. package/tools/writer-cli/skills/ssobig-writer-cli/references/layout-spec.md +130 -0
  21. package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +7 -1
  22. package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +12 -2
  23. package/tools/writer-cli/src/command-registry.cjs +24 -23
  24. package/tools/writer-cli/src/commands.cjs +22 -1
  25. package/tools/writer-cli/src/domain.cjs +46 -0
  26. package/tools/writer-cli/src/project-import.cjs +15 -1
@@ -1,25 +1,94 @@
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 api = factory(CodeMirror6, markdownDocumentModel);
6
+ if (commonJs) module.exports = api;
4
7
  if (root) root.WriterMarkdownLiveEditor = api;
5
- })(typeof globalThis !== "undefined" ? globalThis : this, function (CodeMirror6) {
8
+ })(typeof globalThis !== "undefined" ? globalThis : this, function (CodeMirror6, markdownDocumentModel) {
6
9
  "use strict";
7
10
 
8
11
  const INLINE_MARKERS = Object.freeze([
9
12
  { marker: "++", type: "brand" },
10
13
  { marker: "**", type: "strong" },
11
14
  { marker: "~~", type: "strikethrough" },
12
- { marker: "==", type: "highlight" },
13
- { marker: "`", type: "code" }
15
+ { marker: "==", type: "highlight" }
14
16
  ]);
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);
17
+ function isEscapedAt(source, index) {
18
+ let backslashes = 0;
19
+ for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) backslashes += 1;
20
+ return backslashes % 2 === 1;
21
+ }
22
+ function backtickRunLength(source, index) {
23
+ let cursor = index;
24
+ while (source[cursor] === "`") cursor += 1;
25
+ return cursor - index;
26
+ }
27
+ function codeTokenAt(source, index) {
28
+ if (source[index] !== "`" || isEscapedAt(source, index)) return null;
29
+ const markerLength = backtickRunLength(source, index);
30
+ const contentFrom = index + markerLength;
31
+ let cursor = contentFrom;
32
+ while (cursor < source.length) {
33
+ if (source[cursor] !== "`") {
34
+ cursor += 1;
35
+ continue;
36
+ }
37
+ const closeLength = backtickRunLength(source, cursor);
38
+ if (isEscapedAt(source, cursor)) {
39
+ cursor += closeLength;
40
+ continue;
41
+ }
42
+ if (closeLength === markerLength) {
43
+ if (cursor <= contentFrom || /\n[ \t]*\n/.test(source.slice(contentFrom, cursor))) return null;
44
+ return {
45
+ type: "code",
46
+ marker: "`".repeat(markerLength),
47
+ from: index,
48
+ openTo: contentFrom,
49
+ contentFrom,
50
+ contentTo: cursor,
51
+ closeFrom: cursor,
52
+ to: cursor + markerLength
53
+ };
54
+ }
55
+ cursor += closeLength;
19
56
  }
20
- return starts;
57
+ return null;
58
+ }
59
+ function tableEscapedPipeBackslashes(cell) {
60
+ const raw = String(cell?.raw ?? "");
61
+ const rawFrom = Number(cell?.rawFrom);
62
+ if (!Number.isSafeInteger(rawFrom) || !raw) return [];
63
+ const ranges = [];
64
+ let index = 0;
65
+ while (index < raw.length) {
66
+ if (raw[index] !== "\\") {
67
+ index += 1;
68
+ continue;
69
+ }
70
+ let cursor = index;
71
+ while (raw[cursor] === "\\") cursor += 1;
72
+ const count = cursor - index;
73
+ if (raw[cursor] === "|" && count % 2 === 1) {
74
+ const from = rawFrom + cursor - 1;
75
+ ranges.push({ from, to: from + 1 });
76
+ }
77
+ index = cursor + (raw[cursor] === "|" ? 1 : 0);
78
+ }
79
+ return ranges;
80
+ }
81
+ function tableCellEdgeWhitespaceRanges(cell) {
82
+ const rawFrom = Number(cell?.rawFrom);
83
+ const from = Number(cell?.from);
84
+ const to = Number(cell?.to);
85
+ const rawTo = Number(cell?.rawTo);
86
+ if (![rawFrom, from, to, rawTo].every(Number.isSafeInteger)) return [];
87
+ const ranges = [];
88
+ if (rawFrom < from) ranges.push({ from: rawFrom, to: from });
89
+ if (to < rawTo) ranges.push({ from: to, to: rawTo });
90
+ return ranges;
21
91
  }
22
-
23
92
  function lineAt(starts, offset) {
24
93
  let low = 0;
25
94
  let high = starts.length - 1;
@@ -35,6 +104,14 @@
35
104
  const tokens = [];
36
105
  let index = 0;
37
106
  while (index < source.length) {
107
+ if (source[index] === "`" && !isEscapedAt(source, index)) {
108
+ const token = codeTokenAt(source, index);
109
+ if (token) {
110
+ tokens.push(token);
111
+ index = token.to;
112
+ } else index += backtickRunLength(source, index);
113
+ continue;
114
+ }
38
115
  const definition = INLINE_MARKERS.find(item => source.startsWith(item.marker, index));
39
116
  if (!definition || (index > 0 && source[index - 1] === "\\")) {
40
117
  index += 1;
@@ -78,29 +155,49 @@
78
155
  : selection.to >= from && selection.from <= to;
79
156
  }
80
157
 
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]+(.+)$/);
158
+ function offsetInlineToken(token, offset) {
86
159
  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()
160
+ ...token,
161
+ from: token.from + offset,
162
+ openTo: token.openTo + offset,
163
+ contentFrom: token.contentFrom + offset,
164
+ contentTo: token.contentTo + offset,
165
+ closeFrom: token.closeFrom + offset,
166
+ to: token.to + offset
94
167
  };
95
168
  }
96
169
 
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),
170
+ function tableAwareInlineTokens(source, tableContexts) {
171
+ const tokens = [];
172
+ let cursor = 0;
173
+ [...tableContexts].sort((left, right) => left.from - right.from).forEach(table => {
174
+ if (cursor < table.from) {
175
+ tokens.push(...inlineTokens(source.slice(cursor, table.from)).map(token => offsetInlineToken(token, cursor)));
176
+ }
177
+ [...table.cells].sort((left, right) => left.from - right.from).forEach(cell => {
178
+ tokens.push(...inlineTokens(source.slice(cell.from, cell.to)).map(token => ({
179
+ ...offsetInlineToken(token, cell.from),
180
+ tableCell: true
181
+ })));
182
+ });
183
+ cursor = Math.max(cursor, table.to);
184
+ });
185
+ if (cursor < source.length) {
186
+ tokens.push(...inlineTokens(source.slice(cursor)).map(token => offsetInlineToken(token, cursor)));
187
+ }
188
+ return tokens.sort((left, right) => left.from - right.from || left.to - right.to);
189
+ }
190
+
191
+ function lineSyntax(text) {
192
+ if (!markdownDocumentModel?.parseLine) throw new Error("Markdown 문서 모델을 초기화하지 못했습니다.");
193
+ return markdownDocumentModel.parseLine(text);
194
+ }
195
+
196
+ function layoutLines(source) {
197
+ if (!markdownDocumentModel?.parseDocument) throw new Error("Markdown 문서 모델을 초기화하지 못했습니다.");
198
+ const documentModel = markdownDocumentModel.parseDocument(source, { preserveBlankLines: true });
199
+ const lines = documentModel.lines.map(line => ({
200
+ ...line,
104
201
  blockId: -1,
105
202
  blockType: "",
106
203
  blockStart: false,
@@ -109,83 +206,267 @@
109
206
  firstListItem: false
110
207
  }));
111
208
  const blocks = [];
112
- let paragraph = null;
113
- let quote = null;
114
- let listType = "";
115
- let listGroup = 0;
116
209
  let nextBlockId = 0;
117
- const openBlock = (type, line, extra = {}) => {
118
- const block = { id: nextBlockId++, type, lines: [], ...extra };
210
+ let nextListGroup = 0;
211
+ const assignBlock = (type, lineIndices, extra = {}) => {
212
+ const validLineIndices = lineIndices.filter(index => lines[index]);
213
+ if (!validLineIndices.length) return null;
214
+ const block = { id: nextBlockId++, type, lines: validLineIndices, ...extra };
119
215
  blocks.push(block);
120
- line.blockId = block.id;
121
- line.blockType = type;
122
- line.blockStart = true;
123
- block.lines.push(line.index);
216
+ validLineIndices.forEach((lineIndex, index) => {
217
+ const line = lines[lineIndex];
218
+ line.blockId = block.id;
219
+ line.blockType = type;
220
+ line.blockStart = index === 0;
221
+ line.blockEnd = index === validLineIndices.length - 1;
222
+ Object.assign(line, extra);
223
+ });
124
224
  return block;
125
225
  };
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;
226
+ documentModel.blocks.forEach(sourceBlock => {
227
+ if (sourceBlock.type === "blank") return;
228
+ if (sourceBlock.type === "image") {
229
+ assignBlock("image", [sourceBlock.lineIndex], { assetId: sourceBlock.assetId });
140
230
  return;
141
231
  }
142
- quote = null;
143
- if (line.empty) {
144
- paragraph = null;
145
- listType = "";
232
+ if (sourceBlock.type === "heading") {
233
+ assignBlock(`heading${sourceBlock.level}`, [sourceBlock.lineIndex]);
146
234
  return;
147
235
  }
148
- if (line.headingLevel) {
149
- paragraph = null;
150
- listType = "";
151
- openBlock(`heading${line.headingLevel}`, line);
236
+ if (sourceBlock.type === "paragraph" || sourceBlock.type === "quote") {
237
+ assignBlock(sourceBlock.type, sourceBlock.lineIndices);
152
238
  return;
153
239
  }
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 });
240
+ if (sourceBlock.type === "table") {
241
+ const tableBlock = assignBlock("table", sourceBlock.lineIndices, {
242
+ tableColumnCount: sourceBlock.columnCount,
243
+ tableAlignments: sourceBlock.alignments,
244
+ tableFrom: sourceBlock.from,
245
+ tableTo: sourceBlock.to
246
+ });
247
+ if (!tableBlock) return;
248
+ Object.assign(tableBlock, {
249
+ from: sourceBlock.from,
250
+ to: sourceBlock.to,
251
+ columnCount: sourceBlock.columnCount,
252
+ alignments: sourceBlock.alignments
253
+ });
254
+ [sourceBlock.header, sourceBlock.delimiter, ...sourceBlock.rows].forEach(row => {
255
+ const line = lines[row.lineIndex];
256
+ if (!line) return;
257
+ line.tableRole = row.role;
258
+ line.tableCells = row.cells;
259
+ line.tablePipes = row.pipes;
260
+ line.tableRaw = row.raw;
261
+ line.tableHasLeadingPipe = row.hasLeadingPipe;
262
+ line.tableHasTrailingPipe = row.hasTrailingPipe;
263
+ line.tableEscapedPipeBackslashes = row.cells.flatMap(tableEscapedPipeBackslashes);
264
+ line.tableEmptyCellColumns = row.cells.filter(cell => !cell.synthetic && !cell.text).map(cell => cell.columnIndex);
265
+ line.tableSyntheticCellColumns = row.cells.filter(cell => cell.synthetic).map(cell => cell.columnIndex);
266
+ });
161
267
  return;
162
268
  }
163
- listType = "";
164
- if (!paragraph) paragraph = openBlock("paragraph", line);
165
- else appendBlock(paragraph, line);
269
+ if (sourceBlock.type !== "list") return;
270
+ const listGroup = ++nextListGroup;
271
+ const markerWidth = Math.max(1, Number(sourceBlock.markerWidth) || 1);
272
+ sourceBlock.items.forEach((item, itemIndex) => {
273
+ const itemBlock = assignBlock("listItem", [item.lineIndex], {
274
+ listGroup,
275
+ listMarkerWidth: markerWidth,
276
+ firstListItem: itemIndex === 0,
277
+ listEnd: itemIndex === sourceBlock.items.length - 1
278
+ });
279
+ item.children.forEach(child => {
280
+ if (child.type !== "quote") return;
281
+ assignBlock("quote", child.lineIndices, { nestedQuote: true, listGroup });
282
+ });
283
+ if (itemBlock) itemBlock.children = item.children;
284
+ });
166
285
  });
167
286
 
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;
287
+ return { lines, blocks };
288
+ }
289
+
290
+ function tableCellContexts(value) {
291
+ const source = String(value ?? "").replace(/\r\n?/g, "\n");
292
+ const layout = layoutLines(source);
293
+ return layout.blocks.filter(block => block.type === "table").map(block => {
294
+ const rows = block.lines
295
+ .map(lineIndex => layout.lines[lineIndex])
296
+ .filter(line => line && line.tableRole !== "delimiter")
297
+ .map((line, rowIndex) => ({
298
+ line,
299
+ rowIndex,
300
+ cells: line.tableCells.map(cell => ({ cell, line, rowIndex, columnIndex: cell.columnIndex }))
301
+ }));
302
+ const cells = rows.flatMap(row => row.cells);
303
+ const lines = block.lines.map(lineIndex => layout.lines[lineIndex]).filter(Boolean);
304
+ return { block, lines, rows, cells };
181
305
  });
306
+ }
182
307
 
183
- return { lines, blocks };
308
+ function tableCellContextFromTables(tables, position) {
309
+ const offset = Math.max(0, Number(position) || 0);
310
+ for (const table of tables) {
311
+ for (const context of table.cells) {
312
+ const { cell, line, columnIndex } = context;
313
+ const firstCell = columnIndex === 0;
314
+ const lastCell = columnIndex === line.tableCells.length - 1;
315
+ const touchesContent = offset >= cell.rawFrom && offset <= cell.rawTo;
316
+ const touchesLeadingPipe = firstCell && line.tablePipes?.[0]?.from === offset;
317
+ const trailingPipe = line.tablePipes?.at(-1);
318
+ const touchesTrailingPipe = lastCell && (
319
+ trailingPipe?.from === offset || trailingPipe?.to === offset
320
+ );
321
+ if (touchesContent || touchesLeadingPipe || touchesTrailingPipe) return { ...context, table };
322
+ }
323
+ }
324
+ return null;
325
+ }
326
+
327
+ function tableCellContextAt(value, position) {
328
+ return tableCellContextFromTables(tableCellContexts(value), position);
329
+ }
330
+
331
+ function tableSelectionCrossesStructure(value, selection) {
332
+ const source = String(value ?? "").replace(/\r\n?/g, "\n");
333
+ const anchor = Math.max(0, Math.min(source.length, Number(selection?.anchor) || 0));
334
+ const head = Math.max(0, Math.min(source.length, Number(selection?.head) || 0));
335
+ const from = Math.min(anchor, head);
336
+ const to = Math.max(anchor, head);
337
+ if (from === to) return false;
338
+ const tables = tableCellContexts(source);
339
+ const start = tableCellContextFromTables(tables, from);
340
+ const end = tableCellContextFromTables(tables, to);
341
+ for (const table of tables) {
342
+ if (to <= table.block.from || from >= table.block.to) continue;
343
+ if (!start || !end) return true;
344
+ const sameTable = start.table.block.from === table.block.from && end.table.block.from === table.block.from;
345
+ const sameCell = start.line.index === end.line.index && start.columnIndex === end.columnIndex;
346
+ const insideRawCell = from >= start.cell.rawFrom && to <= start.cell.rawTo;
347
+ if (!sameTable || !sameCell || !insideRawCell) return true;
348
+ }
349
+ return false;
350
+ }
351
+
352
+ function tableCellNavigationDestination(value, selection, direction) {
353
+ const anchor = Math.max(0, Number(selection?.anchor) || 0);
354
+ const head = Math.max(0, Number(selection?.head) || 0);
355
+ if (anchor !== head) return null;
356
+ const tables = tableCellContexts(value);
357
+ const current = tableCellContextFromTables(tables, head);
358
+ if (!current) return null;
359
+ const { table, cell, rowIndex, columnIndex } = current;
360
+ const cellLength = Math.max(0, cell.to - cell.from);
361
+ const contentOffset = Math.max(0, Math.min(cellLength, head - cell.from));
362
+ const flatIndex = table.cells.findIndex(context => (
363
+ context.line.index === current.line.index && context.columnIndex === columnIndex
364
+ ));
365
+ const destination = (targetContext, position) => targetContext
366
+ ? { position, context: { ...targetContext, table } }
367
+ : null;
368
+ let target = null;
369
+
370
+ if (direction === "left") {
371
+ if (head > cell.from) return null;
372
+ target = table.cells[flatIndex - 1] || null;
373
+ return destination(target, target?.cell.to);
374
+ }
375
+ if (direction === "right") {
376
+ if (head < cell.to) return null;
377
+ target = table.cells[flatIndex + 1] || null;
378
+ return destination(target, target?.cell.from);
379
+ }
380
+ if (direction === "previous") {
381
+ target = table.cells[flatIndex - 1] || null;
382
+ return destination(target, target?.cell.to);
383
+ }
384
+ if (direction === "next") {
385
+ target = table.cells[flatIndex + 1] || null;
386
+ return destination(target, target?.cell.from);
387
+ }
388
+ if (direction === "up" || direction === "down") {
389
+ const rowStep = direction === "up" ? -1 : 1;
390
+ for (let targetRow = rowIndex + rowStep; targetRow >= 0 && targetRow < table.rows.length; targetRow += rowStep) {
391
+ target = table.rows[targetRow].cells.find(context => context.columnIndex === columnIndex) || null;
392
+ if (!target) continue;
393
+ return destination(target, Math.min(target.cell.to, target.cell.from + contentOffset));
394
+ }
395
+ }
396
+ return null;
397
+ }
398
+
399
+ function tableCellNavigationTarget(value, selection, direction) {
400
+ return tableCellNavigationDestination(value, selection, direction)?.position ?? null;
401
+ }
402
+
403
+ function syntheticTableCellMaterialization(context) {
404
+ if (!context?.cell?.synthetic || !context.line?.tableCells) return null;
405
+ const firstSyntheticColumn = context.line.tableCells.findIndex(cell => cell.synthetic);
406
+ if (firstSyntheticColumn < 0 || context.columnIndex < firstSyntheticColumn) return null;
407
+ const pipeCount = context.columnIndex - firstSyntheticColumn + (context.line.tableHasTrailingPipe ? 0 : 1);
408
+ const insert = "|".repeat(Math.max(0, pipeCount));
409
+ return {
410
+ from: context.line.to,
411
+ insert,
412
+ position: context.line.to + insert.length
413
+ };
414
+ }
415
+
416
+ function safeTableCellInput(value, precedingBackslashes = 0) {
417
+ const source = String(value ?? "");
418
+ let result = "";
419
+ let trailingBackslashes = Math.max(0, Number(precedingBackslashes) || 0);
420
+ for (let index = 0; index < source.length; index += 1) {
421
+ const character = source[index];
422
+ if (character === "\r") {
423
+ if (source[index + 1] === "\n") index += 1;
424
+ result += " ";
425
+ trailingBackslashes = 0;
426
+ continue;
427
+ }
428
+ if (character === "\n") {
429
+ result += " ";
430
+ trailingBackslashes = 0;
431
+ continue;
432
+ }
433
+ if (character === "|") {
434
+ if (trailingBackslashes % 2 === 0) result += "\\";
435
+ result += character;
436
+ trailingBackslashes = 0;
437
+ continue;
438
+ }
439
+ result += character;
440
+ trailingBackslashes = character === "\\" ? trailingBackslashes + 1 : 0;
441
+ }
442
+ return result;
443
+ }
444
+
445
+ function safeTableCellReplacement(value, from, to, insertedValue) {
446
+ const source = String(value ?? "");
447
+ const changeFrom = Math.max(0, Math.min(source.length, Number(from) || 0));
448
+ const changeTo = Math.max(changeFrom, Math.min(source.length, Number(to) || 0));
449
+ let precedingBackslashes = 0;
450
+ for (let cursor = changeFrom - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) precedingBackslashes += 1;
451
+ let safeText = safeTableCellInput(insertedValue, precedingBackslashes);
452
+ let suffixBackslashes = 0;
453
+ for (let cursor = changeTo; cursor < source.length && source[cursor] === "\\"; cursor += 1) suffixBackslashes += 1;
454
+ const suffixPipe = changeTo + suffixBackslashes;
455
+ if (source[suffixPipe] !== "|") return safeText;
456
+
457
+ let originalBackslashes = 0;
458
+ for (let cursor = suffixPipe - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) originalBackslashes += 1;
459
+ let insertedBackslashes = 0;
460
+ for (let cursor = safeText.length - 1; cursor >= 0 && safeText[cursor] === "\\"; cursor -= 1) insertedBackslashes += 1;
461
+ const keepsPrecedingRun = safeText.length === insertedBackslashes;
462
+ const resultingBackslashes = suffixBackslashes + insertedBackslashes + (keepsPrecedingRun ? precedingBackslashes : 0);
463
+ if (resultingBackslashes % 2 !== originalBackslashes % 2) safeText += "\\";
464
+ return safeText;
184
465
  }
185
466
 
186
467
  function previewModel(value, selections = [{ anchor: 0, head: 0 }]) {
187
468
  const source = String(value ?? "").replace(/\r\n?/g, "\n");
188
- const starts = lineStarts(source);
469
+ const starts = markdownDocumentModel.lineStarts(source);
189
470
  const normalizedSelections = normalizeSelections(selections, source.length);
190
471
  const activeLines = new Set();
191
472
  normalizedSelections.forEach(selection => {
@@ -193,12 +474,34 @@
193
474
  const last = lineAt(starts, selection.to);
194
475
  for (let line = first; line <= last; line += 1) activeLines.add(line);
195
476
  });
196
- const layout = layoutLines(source, starts);
477
+ const layout = layoutLines(source);
197
478
  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))
479
+ const tableContexts = layout.blocks.filter(block => block.type === "table").map(block => ({
480
+ block,
481
+ from: block.from,
482
+ to: block.to,
483
+ cells: block.lines.flatMap(lineIndex => (
484
+ layout.lines[lineIndex]?.tableCells?.filter(cell => !cell.synthetic) || []
485
+ ))
201
486
  }));
487
+ tableContexts.forEach(({ block }) => {
488
+ block.active = normalizedSelections.some(selection => selectionTouchesRange(selection, block.from, block.to));
489
+ block.lines.forEach(lineIndex => {
490
+ const line = layout.lines[lineIndex];
491
+ if (!line) return;
492
+ line.tableActiveCellColumns = line.tableCells
493
+ .filter(cell => !cell.synthetic && normalizedSelections.some(selection => (
494
+ selectionTouchesRange(selection, cell.rawFrom, cell.rawTo)
495
+ )))
496
+ .map(cell => cell.columnIndex);
497
+ });
498
+ });
499
+ const tokens = tableAwareInlineTokens(source, tableContexts)
500
+ .filter(token => !layout.lines.some(line => line.blockType === "image" && token.from < line.to && token.to > line.from))
501
+ .map(token => ({
502
+ ...token,
503
+ active: token.tableCell !== true && normalizedSelections.some(selection => selectionTouchesRange(selection, token.from, token.to))
504
+ }));
202
505
  return { source, starts, lines: layout.lines, blocks: layout.blocks, tokens };
203
506
  }
204
507
 
@@ -215,8 +518,17 @@
215
518
  .toLowerCase();
216
519
  values.push(`writer-md-${blockClass}`);
217
520
  }
521
+ if (line.listType) values.push(`writer-md-list-${line.listType}`);
218
522
  if (line.nestedQuote) values.push("writer-md-nested-quote");
219
523
  if (line.firstListItem) values.push("writer-md-first-list-item");
524
+ if (line.blockType === "table") {
525
+ values.push("writer-md-table-preview-row");
526
+ if (line.tableActiveCellColumns?.length) values.push("writer-md-table-has-active-cell");
527
+ if (line.tableRole) values.push(`writer-md-table-${line.tableRole}-row`);
528
+ if (line.tableColumnCount) values.push(`writer-md-table-columns-${line.tableColumnCount}`);
529
+ if (line.tableEmptyCellColumns?.length) values.push("writer-md-table-has-empty-cells");
530
+ if (line.tableSyntheticCellColumns?.length) values.push("writer-md-table-has-synthetic-cells");
531
+ }
220
532
  return values.join(" ");
221
533
  }
222
534
 
@@ -228,7 +540,25 @@
228
540
  if (!options.parent) throw new Error("Markdown 편집기 표시 영역이 없습니다.");
229
541
  let destroyed = false;
230
542
  const ownerDocument = options.parent.ownerDocument || document;
543
+ const editableEffect = cm.StateEffect.define();
544
+ const editableField = cm.StateField.define({
545
+ create: () => options.readOnly !== true,
546
+ update(value, transaction) {
547
+ for (const effect of transaction.effects) if (effect.is(editableEffect)) value = effect.value;
548
+ return value;
549
+ }
550
+ });
551
+ let replacingValue = false;
552
+ const imageEditor = typeof globalThis !== "undefined" ? globalThis.WriterMarkdownImageEditor : null;
553
+ const images = imageEditor?.create({
554
+ cm, model: markdownDocumentModel, assets: options.assets, parent: options.parent,
555
+ onIdle: () => setTimeout(() => {
556
+ if (!destroyed && !images?.hasPending() && !options.parent.contains(ownerDocument.activeElement)) options.onBlur?.();
557
+ }, 0),
558
+ canInsert: (source, from, to) => !markdownDocumentModel.parseDocument(source).blocks.some(block => block.type === "table" && from <= block.to && to >= block.from)
559
+ });
231
560
  const focusEffect = cm.StateEffect.define();
561
+ const syntheticCellMaterializationEffect = cm.StateEffect.define();
232
562
  const focusField = cm.StateField.define({
233
563
  create() { return false; },
234
564
  update(focused, transaction) {
@@ -248,7 +578,57 @@
248
578
  class: active ? "writer-md-syntax" : "writer-md-syntax-hidden"
249
579
  });
250
580
  model.lines.forEach(line => {
251
- ranges.push(cm.Decoration.line({ attributes: { class: classNames(line) } }).range(line.from));
581
+ if (line.blockType === "image" && images) return;
582
+ const attributes = { class: classNames(line) };
583
+ if (!state.doc.length && options.placeholder) attributes["data-placeholder"] = String(options.placeholder);
584
+ if (line.listType) attributes.style = `--writer-md-list-marker-width:${line.listMarkerWidth || 1}ch`;
585
+ if (line.blockType === "table") {
586
+ const columnCount = line.tableColumnCount || 1;
587
+ const minimumWidth = Math.max(14, Math.min(72, columnCount * 7));
588
+ const columnWidth = Number((100 / columnCount).toFixed(6));
589
+ attributes.style = `--writer-md-table-column-count:${columnCount};--writer-md-table-column-width:${columnWidth}%;--writer-md-table-min-width:${minimumWidth}em`;
590
+ }
591
+ ranges.push(cm.Decoration.line({ attributes }).range(line.from));
592
+ if (line.blockType === "table") {
593
+ line.tableCells.forEach((cell, columnIndex) => {
594
+ if (cell.rawFrom >= cell.rawTo) return;
595
+ const alignment = ["left", "center", "right"].includes(line.tableAlignments?.[columnIndex])
596
+ ? line.tableAlignments[columnIndex]
597
+ : "left";
598
+ const positionClasses = [
599
+ columnIndex === 0 ? "writer-md-table-cell-first" : "",
600
+ columnIndex === line.tableCells.length - 1 ? "writer-md-table-cell-last" : "",
601
+ line.tableActiveCellColumns?.includes(columnIndex) ? "writer-md-table-cell-active" : ""
602
+ ].filter(Boolean).join(" ");
603
+ ranges.push(cm.Decoration.mark({
604
+ class: `writer-md-table-cell writer-md-table-align-${alignment}${positionClasses ? ` ${positionClasses}` : ""}`,
605
+ attributes: {
606
+ style: `--writer-md-table-cell-column:${columnIndex + 1}`,
607
+ "data-writer-md-table-cell-from": String(cell.from),
608
+ "data-writer-md-table-cell-to": String(cell.to)
609
+ },
610
+ inclusiveEnd: false
611
+ }).range(cell.rawFrom, cell.rawTo));
612
+ tableCellEdgeWhitespaceRanges(cell).forEach(whitespace => {
613
+ ranges.push(cm.Decoration.mark({
614
+ class: "writer-md-table-cell-edge-whitespace",
615
+ inclusiveEnd: false
616
+ }).range(whitespace.from, whitespace.to));
617
+ });
618
+ });
619
+ line.tablePipes.forEach(pipe => {
620
+ ranges.push(cm.Decoration.mark({
621
+ class: "writer-md-table-pipe",
622
+ inclusiveEnd: false
623
+ }).range(pipe.from, pipe.to));
624
+ });
625
+ line.tableEscapedPipeBackslashes.forEach(backslash => {
626
+ ranges.push(cm.Decoration.mark({
627
+ class: "writer-md-table-escaped-pipe-backslash",
628
+ inclusiveEnd: false
629
+ }).range(backslash.from, backslash.to));
630
+ });
631
+ }
252
632
  if (line.headingMarkerFrom >= 0) {
253
633
  const from = line.from + line.headingMarkerFrom;
254
634
  const to = line.from + line.headingMarkerTo;
@@ -258,6 +638,22 @@
258
638
  const to = line.from + line.quoteMarkerTo;
259
639
  ranges.push(syntaxDecoration(line.active).range(from, to));
260
640
  }
641
+ if (line.listIndentTo > 0) {
642
+ const from = line.from;
643
+ const to = line.from + line.listIndentTo;
644
+ ranges.push(cm.Decoration.mark({
645
+ class: "writer-md-list-leading-indent",
646
+ inclusiveEnd: false
647
+ }).range(from, to));
648
+ }
649
+ if (line.listMarkerFrom >= 0) {
650
+ const from = line.from + line.listMarkerFrom;
651
+ const to = line.from + line.listMarkerTo;
652
+ ranges.push(cm.Decoration.mark({
653
+ class: "writer-md-list-marker",
654
+ inclusiveEnd: false
655
+ }).range(from, to));
656
+ }
261
657
  });
262
658
  model.tokens.forEach(token => {
263
659
  ranges.push(cm.Decoration.mark({ class: `writer-md-${token.type}` }).range(token.contentFrom, token.contentTo));
@@ -277,46 +673,494 @@
277
673
  },
278
674
  provide: field => cm.EditorView.decorations.from(field)
279
675
  });
676
+ const buildAtomicRanges = state => {
677
+ const model = previewModel(state.doc.toString(), []);
678
+ const ranges = [];
679
+ const atomic = cm.Decoration.mark({});
680
+ model.lines.filter(line => line.blockType === "table").forEach(line => {
681
+ if (line.tableRole === "delimiter") {
682
+ if (line.to > line.from) ranges.push(atomic.range(line.from, line.to));
683
+ return;
684
+ }
685
+ line.tablePipes.forEach(pipe => ranges.push(atomic.range(pipe.from, pipe.to)));
686
+ line.tableCells.forEach(cell => {
687
+ tableCellEdgeWhitespaceRanges(cell).forEach(whitespace => {
688
+ ranges.push(atomic.range(whitespace.from, whitespace.to));
689
+ });
690
+ });
691
+ line.tableEscapedPipeBackslashes.forEach(backslash => ranges.push(atomic.range(backslash.from, backslash.to)));
692
+ });
693
+ model.tokens.filter(token => token.tableCell === true).forEach(token => {
694
+ ranges.push(atomic.range(token.from, token.openTo));
695
+ ranges.push(atomic.range(token.closeFrom, token.to));
696
+ });
697
+ return cm.Decoration.set(ranges, true);
698
+ };
699
+ const tableAtomicField = cm.StateField.define({
700
+ create(state) {
701
+ return buildAtomicRanges(state);
702
+ },
703
+ update(ranges, transaction) {
704
+ return transaction.docChanged ? buildAtomicRanges(transaction.state) : ranges;
705
+ },
706
+ provide: field => cm.EditorView.atomicRanges.from(field, ranges => () => ranges)
707
+ });
708
+ const tableCellPointerPosition = (view, cellElement, event, from, to) => {
709
+ const nativePosition = view.posAtCoords({ x: event.clientX, y: event.clientY });
710
+ const documentRef = cellElement.ownerDocument;
711
+ const walker = documentRef.createTreeWalker(cellElement, 4);
712
+ let closestPosition = null;
713
+ let closestDistance = Number.POSITIVE_INFINITY;
714
+ for (let textNode = walker.nextNode(); textNode; textNode = walker.nextNode()) {
715
+ const parent = textNode.parentElement;
716
+ if (parent?.closest?.(".writer-md-syntax-hidden,.writer-md-table-cell-edge-whitespace,.writer-md-table-escaped-pipe-backslash")) continue;
717
+ for (let offset = 0; offset <= textNode.nodeValue.length; offset += 1) {
718
+ let position = null;
719
+ try {
720
+ position = view.posAtDOM(textNode, offset);
721
+ } catch {
722
+ continue;
723
+ }
724
+ if (!Number.isSafeInteger(position) || position < from || position > to) continue;
725
+ const range = documentRef.createRange();
726
+ range.setStart(textNode, offset);
727
+ range.collapse(true);
728
+ const rect = range.getBoundingClientRect();
729
+ const verticalDistance = event.clientY < rect.top
730
+ ? rect.top - event.clientY
731
+ : event.clientY > rect.bottom
732
+ ? event.clientY - rect.bottom
733
+ : 0;
734
+ const distance = (verticalDistance * 10000) + Math.abs(event.clientX - rect.left);
735
+ if (distance >= closestDistance) continue;
736
+ closestDistance = distance;
737
+ closestPosition = position;
738
+ }
739
+ }
740
+ if (Number.isSafeInteger(closestPosition)) return closestPosition;
741
+ if (Number.isSafeInteger(nativePosition) && nativePosition >= from && nativePosition <= to) {
742
+ return nativePosition;
743
+ }
744
+
745
+ const rect = cellElement.getBoundingClientRect();
746
+ const ratio = rect.width > 0
747
+ ? Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))
748
+ : 0;
749
+ return Math.max(from, Math.min(to, from + Math.round((to - from) * ratio)));
750
+ };
751
+ let tablePointerStart = null;
752
+ const handleTableCellMouseDown = (event, view) => {
753
+ tablePointerStart = null;
754
+ if (event.button !== 0 || event.detail !== 1 || event.shiftKey) return false;
755
+ const cellElement = event.target?.closest?.(".writer-md-table-cell");
756
+ if (!cellElement || !view.dom.contains(cellElement)) return false;
757
+ const from = Number(cellElement.getAttribute("data-writer-md-table-cell-from"));
758
+ const to = Number(cellElement.getAttribute("data-writer-md-table-cell-to"));
759
+ if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from > to) return false;
760
+ const position = tableCellPointerPosition(view, cellElement, event, from, to);
761
+ view.focus();
762
+ tablePointerStart = { clientX: event.clientX, clientY: event.clientY, from, to, position };
763
+ return false;
764
+ };
765
+ const tableCellWordSelection = (source, from, to, position) => {
766
+ const text = source.slice(from, to);
767
+ const relativePosition = Math.max(0, Math.min(text.length, position - from));
768
+ if (typeof Intl?.Segmenter === "function") {
769
+ const segments = new Intl.Segmenter(undefined, { granularity: "word" }).segment(text);
770
+ for (const segment of segments) {
771
+ const segmentFrom = segment.index;
772
+ const segmentTo = segment.index + segment.segment.length;
773
+ if (relativePosition < segmentFrom || relativePosition > segmentTo || !segment.segment.trim()) continue;
774
+ return { anchor: from + segmentFrom, head: from + segmentTo };
775
+ }
776
+ }
777
+ let wordFrom = relativePosition;
778
+ let wordTo = relativePosition;
779
+ while (wordFrom > 0 && !/\s/u.test(text[wordFrom - 1])) wordFrom -= 1;
780
+ while (wordTo < text.length && !/\s/u.test(text[wordTo])) wordTo += 1;
781
+ return wordFrom === wordTo
782
+ ? { anchor: Math.max(from, Math.min(to, position)), head: Math.max(from, Math.min(to, position)) }
783
+ : { anchor: from + wordFrom, head: from + wordTo };
784
+ };
785
+ const handleTableCellMouseUp = (event, view) => {
786
+ const start = tablePointerStart;
787
+ tablePointerStart = null;
788
+ if (event.button !== 0 || event.shiftKey) return false;
789
+ if (event.detail === 2) {
790
+ const cellElement = event.target?.closest?.(".writer-md-table-cell");
791
+ if (!cellElement || !view.dom.contains(cellElement)) return false;
792
+ const from = Number(cellElement.getAttribute("data-writer-md-table-cell-from"));
793
+ const to = Number(cellElement.getAttribute("data-writer-md-table-cell-to"));
794
+ if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from > to) return false;
795
+ const position = tableCellPointerPosition(view, cellElement, event, from, to);
796
+ event.preventDefault();
797
+ view.dispatch({
798
+ selection: tableCellWordSelection(view.state.doc.toString(), from, to, position),
799
+ scrollIntoView: true
800
+ });
801
+ return true;
802
+ }
803
+ if (!start || event.detail !== 1) return false;
804
+ if (Math.hypot(event.clientX - start.clientX, event.clientY - start.clientY) > 4) return false;
805
+ const cellElement = event.target?.closest?.(".writer-md-table-cell");
806
+ if (!cellElement || !view.dom.contains(cellElement)) return false;
807
+ const from = Number(cellElement.getAttribute("data-writer-md-table-cell-from"));
808
+ const to = Number(cellElement.getAttribute("data-writer-md-table-cell-to"));
809
+ if (from !== start.from || to !== start.to) return false;
810
+ event.preventDefault();
811
+ view.dispatch({ selection: { anchor: start.position }, scrollIntoView: true });
812
+ return true;
813
+ };
814
+ const tableViewIsComposing = view => Boolean(view.composing || view.compositionStarted);
815
+ const continueListMarkup = cm.insertNewlineContinueMarkupCommand({ nonTightLists: false });
816
+ const moveToTableDestination = (view, destination) => {
817
+ if (!destination || !Number.isSafeInteger(destination.position)) return false;
818
+ const materialization = syntheticTableCellMaterialization(destination.context);
819
+ if (materialization?.insert) {
820
+ view.dispatch({
821
+ changes: {
822
+ from: materialization.from,
823
+ to: materialization.from,
824
+ insert: materialization.insert
825
+ },
826
+ selection: { anchor: materialization.position },
827
+ effects: syntheticCellMaterializationEffect.of({
828
+ tableFrom: destination.context.table.block.from,
829
+ lineIndex: destination.context.line.index,
830
+ columnIndex: destination.context.columnIndex,
831
+ ...materialization
832
+ }),
833
+ userEvent: "input.type",
834
+ scrollIntoView: true
835
+ });
836
+ return true;
837
+ }
838
+ view.dispatch({ selection: { anchor: destination.position }, scrollIntoView: true });
839
+ return true;
840
+ };
841
+ const moveTableCell = direction => view => {
842
+ if (tableViewIsComposing(view)) return false;
843
+ const selection = view.state.selection.main;
844
+ if (!selection.empty) return false;
845
+ const source = view.state.doc.toString();
846
+ const current = tableCellContextAt(source, selection.head);
847
+ const destination = tableCellNavigationDestination(
848
+ source,
849
+ { anchor: selection.anchor, head: selection.head },
850
+ direction
851
+ );
852
+ if (!destination || (destination.position === selection.head && !destination.context.cell.synthetic)) {
853
+ const atProtectedOuterBoundary = current && (
854
+ (direction === "left" && selection.head <= current.cell.from && current.columnIndex === 0 && current.rowIndex === 0)
855
+ || (
856
+ direction === "right"
857
+ && selection.head >= current.cell.to
858
+ && current.rowIndex === current.table.rows.length - 1
859
+ && current.columnIndex === current.table.rows.at(-1)?.cells.at(-1)?.columnIndex
860
+ )
861
+ );
862
+ return Boolean(atProtectedOuterBoundary);
863
+ }
864
+ return moveToTableDestination(view, destination);
865
+ };
866
+ const protectTableBoundary = direction => view => {
867
+ if (tableViewIsComposing(view)) return false;
868
+ const selection = view.state.selection.main;
869
+ const source = view.state.doc.toString();
870
+ if (!selection.empty) {
871
+ return tableSelectionCrossesStructure(source, { anchor: selection.anchor, head: selection.head });
872
+ }
873
+ const current = tableCellContextAt(source, selection.head);
874
+ if (!current) return false;
875
+ const pipeOffset = direction === "backward" ? selection.head - 1 : selection.head;
876
+ const atVisibleBoundary = direction === "backward"
877
+ ? selection.head <= current.cell.from
878
+ : selection.head >= current.cell.to;
879
+ const atStructuralPipe = current.line.tablePipes.some(pipe => pipe.from === pipeOffset);
880
+ if (!atVisibleBoundary && !atStructuralPipe) return false;
881
+ const destination = tableCellNavigationDestination(
882
+ source,
883
+ { anchor: selection.anchor, head: selection.head },
884
+ direction === "backward" ? "previous" : "next"
885
+ );
886
+ if (destination && (
887
+ destination.position !== selection.head || destination.context.cell.synthetic
888
+ )) {
889
+ moveToTableDestination(view, destination);
890
+ }
891
+ return true;
892
+ };
893
+ const enterTableCell = view => {
894
+ if (tableViewIsComposing(view)) return false;
895
+ const selection = view.state.selection.main;
896
+ if (!selection.empty || !tableCellContextAt(view.state.doc.toString(), selection.head)) return false;
897
+ const destination = tableCellNavigationDestination(
898
+ view.state.doc.toString(),
899
+ { anchor: selection.anchor, head: selection.head },
900
+ "down"
901
+ );
902
+ if (destination && (destination.position !== selection.head || destination.context.cell.synthetic)) {
903
+ moveToTableDestination(view, destination);
904
+ }
905
+ return true;
906
+ };
907
+ const handleEnter = view => enterTableCell(view) || continueListMarkup(view);
908
+ const replaceWithSafeTableCellInput = (view, from, to, text, userEvent = "input.type") => {
909
+ if (tableViewIsComposing(view)) return false;
910
+ const source = view.state.doc.toString();
911
+ if (tableSelectionCrossesStructure(source, { anchor: from, head: to })) return true;
912
+ const tables = tableCellContexts(source);
913
+ const start = tableCellContextFromTables(tables, from);
914
+ const end = tableCellContextFromTables(tables, to);
915
+ if (!start || !end || start.line.index !== end.line.index || start.columnIndex !== end.columnIndex) return false;
916
+ const safeText = safeTableCellReplacement(source, from, to, text);
917
+ if (safeText === text) return false;
918
+ view.dispatch({
919
+ changes: { from, to, insert: safeText },
920
+ selection: { anchor: from + safeText.length },
921
+ userEvent,
922
+ scrollIntoView: true
923
+ });
924
+ return true;
925
+ };
926
+ const tableInputHandler = cm.EditorView.inputHandler.of((view, from, to, text) => (
927
+ replaceWithSafeTableCellInput(view, from, to, text)
928
+ ));
929
+ const handleTableBeforeInput = (event, view) => {
930
+ if (event.isComposing || tableViewIsComposing(view)) return false;
931
+ const selection = view.state.selection.main;
932
+ const crossesStructure = tableSelectionCrossesStructure(
933
+ view.state.doc.toString(),
934
+ { anchor: selection.anchor, head: selection.head }
935
+ );
936
+ if (crossesStructure && /^(?:insert|delete)/.test(event.inputType)) {
937
+ event.preventDefault();
938
+ return true;
939
+ }
940
+ if (!["insertText", "insertReplacementText"].includes(event.inputType) || typeof event.data !== "string") {
941
+ return false;
942
+ }
943
+ if (!replaceWithSafeTableCellInput(view, selection.from, selection.to, event.data, "input.type")) return false;
944
+ event.preventDefault();
945
+ return true;
946
+ };
947
+ const handleTablePaste = (event, view) => {
948
+ if (tableViewIsComposing(view)) return false;
949
+ const text = event.clipboardData?.getData("text/plain");
950
+ if (typeof text !== "string") return false;
951
+ const selection = view.state.selection.main;
952
+ if (!replaceWithSafeTableCellInput(view, selection.from, selection.to, text, "input.paste")) return false;
953
+ event.preventDefault();
954
+ return true;
955
+ };
956
+ const validatesSyntheticCellMaterialization = (transaction, tables, request, changes) => {
957
+ if (!request || changes.length !== 1) return false;
958
+ const table = tables.find(candidate => candidate.block.from === request.tableFrom);
959
+ const context = table?.cells.find(candidate => (
960
+ candidate.line.index === request.lineIndex && candidate.columnIndex === request.columnIndex
961
+ ));
962
+ const expected = syntheticTableCellMaterialization(context && { ...context, table });
963
+ const change = changes[0];
964
+ if (
965
+ !expected?.insert
966
+ || request.from !== expected.from
967
+ || request.insert !== expected.insert
968
+ || request.position !== expected.position
969
+ || change.fromA !== expected.from
970
+ || change.toA !== expected.from
971
+ || change.insert !== expected.insert
972
+ ) return false;
973
+
974
+ const nextTable = tableCellContexts(transaction.newDoc.toString())
975
+ .find(candidate => candidate.block.from === table.block.from);
976
+ const nextLine = nextTable?.lines.find(line => line.index === context.line.index);
977
+ const nextTarget = nextLine?.tableCells?.[context.columnIndex];
978
+ if (
979
+ !nextTable
980
+ || nextTable.block.columnCount !== table.block.columnCount
981
+ || nextTable.rows.length !== table.rows.length
982
+ || nextTable.lines.length !== table.lines.length
983
+ || nextTable.block.alignments.join("|") !== table.block.alignments.join("|")
984
+ || !nextTarget?.synthetic
985
+ || nextTarget.from !== expected.position
986
+ || nextLine.tableHasLeadingPipe !== context.line.tableHasLeadingPipe
987
+ || !nextLine.tableHasTrailingPipe
988
+ ) return false;
989
+
990
+ const existingText = context.line.tableCells.filter(cell => !cell.synthetic).map(cell => cell.text);
991
+ const nextText = nextLine.tableCells.slice(0, existingText.length).map(cell => cell.text);
992
+ if (existingText.join("\u0000") !== nextText.join("\u0000")) return false;
993
+ if (nextLine.tableCells.slice(0, context.columnIndex).some(cell => cell.synthetic)) return false;
994
+ const oldPipes = context.line.tablePipes.map(pipe => pipe.from);
995
+ const nextPipes = nextLine.tablePipes.map(pipe => pipe.from);
996
+ const addedPipes = Array.from({ length: expected.insert.length }, (_, index) => expected.from + index);
997
+ return nextPipes.length === oldPipes.length + addedPipes.length
998
+ && oldPipes.every((position, index) => nextPipes[index] === position)
999
+ && addedPipes.every((position, index) => nextPipes[oldPipes.length + index] === position);
1000
+ };
1001
+ const tableStructureChangeFilter = cm.EditorState.changeFilter.of(transaction => {
1002
+ if (!transaction.docChanged) return true;
1003
+ if (transaction.isUserEvent?.("undo") || transaction.isUserEvent?.("redo")) return true;
1004
+ const source = transaction.startState.doc.toString();
1005
+ const tables = tableCellContexts(source);
1006
+ if (!tables.length) return true;
1007
+ const changes = [];
1008
+ transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
1009
+ changes.push({ fromA, toA, fromB, toB, insert: inserted.toString() });
1010
+ });
1011
+ const materializationEffects = transaction.effects.filter(effect => (
1012
+ effect.is(syntheticCellMaterializationEffect)
1013
+ ));
1014
+ if (materializationEffects.length) {
1015
+ return materializationEffects.length === 1 && validatesSyntheticCellMaterialization(
1016
+ transaction,
1017
+ tables,
1018
+ materializationEffects[0].value,
1019
+ changes
1020
+ );
1021
+ }
1022
+ const touchedTables = tables.filter(table => {
1023
+ const guardFrom = Math.max(0, table.block.from - 1);
1024
+ let guardTo = Math.min(source.length, table.block.to + 1);
1025
+ const nextLineStart = table.block.to + 1;
1026
+ if (source[table.block.to] === "\n" && nextLineStart < source.length && source[nextLineStart] !== "\n") {
1027
+ const nextBreak = source.indexOf("\n", nextLineStart);
1028
+ guardTo = nextBreak < 0 ? source.length : nextBreak;
1029
+ }
1030
+ return changes.some(change => (
1031
+ change.fromA === change.toA
1032
+ ? change.fromA >= guardFrom && change.fromA <= guardTo
1033
+ : change.fromA < guardTo && change.toA > guardFrom
1034
+ ));
1035
+ });
1036
+ if (!touchedTables.length) return true;
1037
+
1038
+ for (const table of touchedTables) {
1039
+ const delimiter = table.lines.find(line => line.tableRole === "delimiter");
1040
+ for (const change of changes) {
1041
+ if (
1042
+ change.fromA === change.toA
1043
+ && delimiter
1044
+ && change.fromA >= delimiter.from
1045
+ && change.fromA <= delimiter.to
1046
+ ) return false;
1047
+ if (change.fromA === change.toA) continue;
1048
+ const structuralRanges = table.lines.flatMap((line, lineIndex) => {
1049
+ const ranges = line.tableRole === "delimiter"
1050
+ ? [{ from: line.from, to: line.to }]
1051
+ : [...line.tablePipes];
1052
+ if (lineIndex < table.lines.length - 1) ranges.push({ from: line.to, to: line.to + 1 });
1053
+ return ranges;
1054
+ });
1055
+ if (table.block.from > 0 && source[table.block.from - 1] === "\n") {
1056
+ structuralRanges.push({ from: table.block.from - 1, to: table.block.from });
1057
+ }
1058
+ if (table.block.to < source.length && source[table.block.to] === "\n") {
1059
+ structuralRanges.push({ from: table.block.to, to: table.block.to + 1 });
1060
+ }
1061
+ if (structuralRanges.some(range => change.fromA < range.to && change.toA > range.from)) {
1062
+ return false;
1063
+ }
1064
+ }
1065
+ }
1066
+
1067
+ const nextTables = tableCellContexts(transaction.newDoc.toString());
1068
+ return touchedTables.every(table => {
1069
+ const mappedFrom = transaction.changes.mapPos(table.block.from, -1);
1070
+ const mappedTo = transaction.changes.mapPos(table.block.to, 1);
1071
+ const nextTable = nextTables.find(candidate => candidate.block.from === mappedFrom);
1072
+ if (!nextTable) return false;
1073
+ if (nextTable.block.to !== mappedTo) return false;
1074
+ if (nextTable.block.columnCount !== table.block.columnCount) return false;
1075
+ if (nextTable.rows.length !== table.rows.length) return false;
1076
+ if (nextTable.lines.length !== table.lines.length) return false;
1077
+ if (nextTable.block.alignments.join("|") !== table.block.alignments.join("|")) return false;
1078
+ return nextTable.lines.every((line, index) => {
1079
+ const previousLine = table.lines[index];
1080
+ if (line.tableRole !== previousLine?.tableRole) return false;
1081
+ if (line.from !== transaction.changes.mapPos(previousLine.from, -1)) return false;
1082
+ if (line.to !== transaction.changes.mapPos(previousLine.to, 1)) return false;
1083
+ if (line.tableHasLeadingPipe !== previousLine.tableHasLeadingPipe) return false;
1084
+ if (line.tableRole === "delimiter" && line.tableRaw !== previousLine.tableRaw) return false;
1085
+ const mappedPipes = previousLine.tablePipes.map(pipe => transaction.changes.mapPos(pipe.from, 1));
1086
+ return mappedPipes.length === line.tablePipes.length
1087
+ && mappedPipes.every((position, pipeIndex) => position === line.tablePipes[pipeIndex]?.from);
1088
+ });
1089
+ });
1090
+ });
280
1091
  const state = cm.EditorState.create({
281
1092
  doc: String(options.value ?? "").replace(/\r\n?/g, "\n"),
282
1093
  extensions: [
283
- cm.EditorView.editorAttributes.of({
284
- class: "writer-markdown-live-editor",
285
- ...(options.placeholder ? { "data-placeholder": String(options.placeholder) } : {})
286
- }),
1094
+ editableField,
1095
+ cm.EditorState.readOnly.compute([editableField], state => !state.field(editableField)),
1096
+ cm.EditorView.editable.compute([editableField], state => state.field(editableField)),
1097
+ cm.EditorState.changeFilter.of(transaction => !transaction.startState.readOnly),
1098
+ ...(images?.extensions || []),
1099
+ cm.EditorView.editorAttributes.of({ class: "writer-markdown-live-editor" }),
287
1100
  cm.history(),
288
1101
  cm.keymap.of([
289
1102
  { key: "Escape", run() { options.onEscape?.(); return true; } },
1103
+ { key: "Enter", run: handleEnter },
1104
+ { key: "Shift-Enter", run: enterTableCell },
1105
+ { key: "Backspace", run: protectTableBoundary("backward") },
1106
+ { key: "Backspace", run: cm.deleteMarkupBackward },
1107
+ { key: "Delete", run: protectTableBoundary("forward") },
1108
+ { key: "ArrowLeft", run: moveTableCell("left") },
1109
+ { key: "ArrowRight", run: moveTableCell("right") },
1110
+ { key: "ArrowUp", run: moveTableCell("up") },
1111
+ { key: "ArrowDown", run: moveTableCell("down") },
1112
+ { key: "Tab", run: moveTableCell("next"), shift: moveTableCell("previous") },
290
1113
  ...cm.defaultKeymap,
291
1114
  ...cm.historyKeymap
292
1115
  ]),
293
- cm.markdown(),
1116
+ cm.markdown({ addKeymap: false }),
294
1117
  cm.EditorView.lineWrapping,
1118
+ tableInputHandler,
1119
+ tableStructureChangeFilter,
295
1120
  focusField,
296
1121
  previewField,
1122
+ tableAtomicField,
297
1123
  cm.EditorView.contentAttributes.of({
298
1124
  role: "textbox",
299
1125
  "aria-multiline": "true",
300
1126
  "aria-label": String(options.ariaLabel || "Markdown 편집")
301
1127
  }),
302
1128
  cm.EditorView.updateListener.of(update => {
303
- if (update.docChanged) options.onChange?.(update.state.doc.toString());
1129
+ if (update.docChanged && !replacingValue) options.onChange?.(update.state.doc.toString());
304
1130
  }),
305
1131
  cm.EditorView.domEventHandlers({
1132
+ mousedown: handleTableCellMouseDown,
1133
+ mouseup: handleTableCellMouseUp,
1134
+ beforeinput: handleTableBeforeInput,
1135
+ paste: handleTablePaste,
306
1136
  focus() {
307
- view.dispatch({ effects: focusEffect.of(true) });
1137
+ if (!view.state.readOnly) view.dispatch({ effects: focusEffect.of(true) });
308
1138
  },
309
1139
  blur() {
310
1140
  view.dispatch({ effects: focusEffect.of(false) });
311
1141
  setTimeout(() => {
312
- if (!destroyed && !view.dom.contains(ownerDocument.activeElement)) options.onBlur?.();
1142
+ if (!destroyed && !images?.hasPending() && !options.parent.contains(ownerDocument.activeElement)) options.onBlur?.();
313
1143
  }, 0);
314
1144
  }
315
1145
  })
316
1146
  ]
317
1147
  });
318
1148
  const view = new cm.EditorView({ state, parent: options.parent });
1149
+ images?.attach(view);
1150
+ options.parent.toggleAttribute("data-markdown-readonly", view.state.readOnly);
319
1151
  return Object.freeze({
1152
+ setEditable(editable) {
1153
+ view.dispatch({ effects: [editableEffect.of(Boolean(editable)), focusEffect.of(Boolean(editable) && view.hasFocus)] });
1154
+ options.parent.toggleAttribute("data-markdown-readonly", !editable);
1155
+ },
1156
+ setValue(value) {
1157
+ const text = String(value ?? "").replace(/\r\n?/g, "\n");
1158
+ if (text === view.state.doc.toString()) return;
1159
+ replacingValue = true;
1160
+ try { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text }, filter: false }); }
1161
+ finally { replacingValue = false; }
1162
+ },
1163
+ hasPendingImages: () => images?.hasPending() || false,
320
1164
  getValue: () => view.state.doc.toString(),
321
1165
  getSelection: () => view.state.selection.ranges.map(range => ({ anchor: range.anchor, head: range.head })),
322
1166
  focusAtPosition(position, options = {}) {
@@ -330,21 +1174,51 @@
330
1174
  view.focus();
331
1175
  },
332
1176
  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;
1177
+ const anchor = this.positionAtClientPoint(clientX, clientY);
337
1178
  view.dispatch({ selection: { anchor } });
338
1179
  view.focus();
339
1180
  return anchor;
340
1181
  },
1182
+ positionAtClientPoint(clientX, clientY) {
1183
+ let position = null;
1184
+ if (Number.isFinite(clientX) && Number.isFinite(clientY)) {
1185
+ const cellElement = ownerDocument.elementFromPoint?.(clientX, clientY)?.closest?.(".writer-md-table-cell");
1186
+ if (cellElement && view.dom.contains(cellElement)) {
1187
+ const from = Number(cellElement.getAttribute("data-writer-md-table-cell-from"));
1188
+ const to = Number(cellElement.getAttribute("data-writer-md-table-cell-to"));
1189
+ if (Number.isSafeInteger(from) && Number.isSafeInteger(to) && from <= to) {
1190
+ position = tableCellPointerPosition(view, cellElement, { clientX, clientY }, from, to);
1191
+ }
1192
+ }
1193
+ if (position == null) position = view.posAtCoords({ x: clientX, y: clientY }, false);
1194
+ }
1195
+ const anchor = position == null ? view.state.doc.length : position;
1196
+ return anchor;
1197
+ },
341
1198
  destroy() {
342
1199
  if (destroyed) return;
343
1200
  destroyed = true;
1201
+ options.parent.removeAttribute("data-markdown-readonly");
1202
+ images?.destroy();
344
1203
  view.destroy();
345
1204
  }
346
1205
  });
347
1206
  }
348
1207
 
349
- return Object.freeze({ inlineTokens, lineSyntax, layoutLines, previewModel, mount });
1208
+ return Object.freeze({
1209
+ inlineTokens,
1210
+ tableEscapedPipeBackslashes,
1211
+ tableCellEdgeWhitespaceRanges,
1212
+ lineSyntax,
1213
+ layoutLines,
1214
+ tableCellContexts,
1215
+ tableCellContextAt,
1216
+ tableSelectionCrossesStructure,
1217
+ tableCellNavigationTarget,
1218
+ syntheticTableCellMaterialization,
1219
+ safeTableCellInput,
1220
+ safeTableCellReplacement,
1221
+ previewModel,
1222
+ mount
1223
+ });
350
1224
  });