@ssobig/writer-cli 0.3.2 → 0.3.4

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.
@@ -2,17 +2,22 @@
2
2
  const commonJs = typeof module === "object" && module.exports;
3
3
  const CodeMirror6 = commonJs ? null : root?.WriterCodeMirror6;
4
4
  const markdownDocumentModel = commonJs ? require("./markdown-document-model.js") : root?.WriterMarkdownDocumentModel;
5
- const api = factory(CodeMirror6, markdownDocumentModel);
5
+ const toolbarApi = commonJs ? require("./markdown-toolbar.js") : root?.WriterMarkdownToolbar;
6
+ const api = factory(CodeMirror6, markdownDocumentModel, toolbarApi);
6
7
  if (commonJs) module.exports = api;
7
8
  if (root) root.WriterMarkdownLiveEditor = api;
8
- })(typeof globalThis !== "undefined" ? globalThis : this, function (CodeMirror6, markdownDocumentModel) {
9
+ })(typeof globalThis !== "undefined" ? globalThis : this, function (CodeMirror6, markdownDocumentModel, toolbarApi) {
9
10
  "use strict";
10
11
 
11
12
  const INLINE_MARKERS = Object.freeze([
13
+ { marker: "<u>", close: "</u>", type: "underline" },
14
+ { marker: "***", type: "strong-emphasis" },
12
15
  { marker: "++", type: "brand" },
13
16
  { marker: "**", type: "strong" },
14
17
  { marker: "~~", type: "strikethrough" },
15
- { marker: "==", type: "highlight" }
18
+ { marker: "==", type: "highlight" },
19
+ { marker: "*", type: "emphasis" },
20
+ { marker: "_", type: "emphasis" }
16
21
  ]);
17
22
  function isEscapedAt(source, index) {
18
23
  let backslashes = 0;
@@ -113,19 +118,25 @@
113
118
  continue;
114
119
  }
115
120
  const definition = INLINE_MARKERS.find(item => source.startsWith(item.marker, index));
116
- 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)) {
117
124
  index += 1;
118
125
  continue;
119
126
  }
120
127
  const contentFrom = index + definition.marker.length;
121
- 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
+ }
122
133
  const crossesBlockBoundary = closeFrom > contentFrom && /\n[ \t]*\n/.test(source.slice(contentFrom, closeFrom));
123
134
  if (closeFrom <= contentFrom || crossesBlockBoundary || (closeFrom > 0 && source[closeFrom - 1] === "\\")) {
124
135
  index += definition.marker.length;
125
136
  continue;
126
137
  }
127
- const to = closeFrom + definition.marker.length;
128
- tokens.push({
138
+ const to = closeFrom + closeMarker.length;
139
+ const token = {
129
140
  type: definition.type,
130
141
  marker: definition.marker,
131
142
  from: index,
@@ -134,7 +145,11 @@
134
145
  contentTo: closeFrom,
135
146
  closeFrom,
136
147
  to
137
- });
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);
138
153
  index = to;
139
154
  }
140
155
  return tokens;
@@ -149,6 +164,45 @@
149
164
  });
150
165
  }
151
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
+
152
206
  function selectionTouchesRange(selection, from, to) {
153
207
  return selection.from === selection.to
154
208
  ? selection.head >= from && selection.head <= to
@@ -225,6 +279,10 @@
225
279
  };
226
280
  documentModel.blocks.forEach(sourceBlock => {
227
281
  if (sourceBlock.type === "blank") return;
282
+ if (sourceBlock.type === "horizontalRule") {
283
+ assignBlock("horizontalRule", [sourceBlock.lineIndex]);
284
+ return;
285
+ }
228
286
  if (sourceBlock.type === "image") {
229
287
  assignBlock("image", [sourceBlock.lineIndex], { assetId: sourceBlock.assetId });
230
288
  return;
@@ -532,6 +590,30 @@
532
590
  return values.join(" ");
533
591
  }
534
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
+
535
617
  function mount(options = {}) {
536
618
  const cm = CodeMirror6;
537
619
  if (!cm?.EditorState || !cm?.StateEffect || !cm?.EditorView || !cm?.Decoration || !cm?.StateField) {
@@ -539,8 +621,22 @@
539
621
  }
540
622
  if (!options.parent) throw new Error("Markdown 편집기 표시 영역이 없습니다.");
541
623
  let destroyed = false;
624
+ let toolbar = null;
542
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
+ };
543
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
+ });
544
640
  const editableField = cm.StateField.define({
545
641
  create: () => options.readOnly !== true,
546
642
  update(value, transaction) {
@@ -553,7 +649,7 @@
553
649
  const images = imageEditor?.create({
554
650
  cm, model: markdownDocumentModel, assets: options.assets, parent: options.parent,
555
651
  onIdle: () => setTimeout(() => {
556
- if (!destroyed && !images?.hasPending() && !options.parent.contains(ownerDocument.activeElement)) options.onBlur?.();
652
+ if (!destroyed && !images?.hasPending() && !toolbar?.ownsFocus() && !options.parent.contains(ownerDocument.activeElement)) options.onBlur?.();
557
653
  }, 0),
558
654
  canInsert: (source, from, to) => !markdownDocumentModel.parseDocument(source).blocks.some(block => block.type === "table" && from <= block.to && to >= block.from)
559
655
  });
@@ -568,6 +664,35 @@
568
664
  return focused;
569
665
  }
570
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
+ }
571
696
  const buildDecorations = state => {
572
697
  const selections = state.field(focusField)
573
698
  ? state.selection.ranges.map(range => ({ anchor: range.anchor, head: range.head }))
@@ -589,6 +714,10 @@
589
714
  attributes.style = `--writer-md-table-column-count:${columnCount};--writer-md-table-column-width:${columnWidth}%;--writer-md-table-min-width:${minimumWidth}em`;
590
715
  }
591
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
+ }
592
721
  if (line.blockType === "table") {
593
722
  line.tableCells.forEach((cell, columnIndex) => {
594
723
  if (cell.rawFrom >= cell.rawTo) return;
@@ -646,7 +775,10 @@
646
775
  inclusiveEnd: false
647
776
  }).range(from, to));
648
777
  }
649
- if (line.listMarkerFrom >= 0) {
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) {
650
782
  const from = line.from + line.listMarkerFrom;
651
783
  const to = line.from + line.listMarkerTo;
652
784
  ranges.push(cm.Decoration.mark({
@@ -669,7 +801,8 @@
669
801
  },
670
802
  update(decorations, transaction) {
671
803
  const focusChanged = transaction.effects.some(effect => effect.is(focusEffect));
672
- 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;
673
806
  },
674
807
  provide: field => cm.EditorView.decorations.from(field)
675
808
  });
@@ -1002,6 +1135,16 @@
1002
1135
  if (!transaction.docChanged) return true;
1003
1136
  if (transaction.isUserEvent?.("undo") || transaction.isUserEvent?.("redo")) return true;
1004
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
+ }
1005
1148
  const tables = tableCellContexts(source);
1006
1149
  if (!tables.length) return true;
1007
1150
  const changes = [];
@@ -1097,7 +1240,12 @@
1097
1240
  cm.EditorState.changeFilter.of(transaction => !transaction.startState.readOnly),
1098
1241
  ...(images?.extensions || []),
1099
1242
  cm.EditorView.editorAttributes.of({ class: "writer-markdown-live-editor" }),
1100
- cm.history(),
1243
+ tableHistoryBoundary,
1244
+ cm.history({
1245
+ joinToEvent: (transaction, adjacent) => adjacent
1246
+ && !transaction.startState.field(tableHistoryBoundary)
1247
+ && !transaction.effects.some(effect => effect.is(tableOperationEffect))
1248
+ }),
1101
1249
  cm.keymap.of([
1102
1250
  { key: "Escape", run() { options.onEscape?.(); return true; } },
1103
1251
  { key: "Enter", run: handleEnter },
@@ -1126,7 +1274,9 @@
1126
1274
  "aria-label": String(options.ariaLabel || "Markdown 편집")
1127
1275
  }),
1128
1276
  cm.EditorView.updateListener.of(update => {
1277
+ if (update.docChanged) updateCounter(update.state.doc);
1129
1278
  if (update.docChanged && !replacingValue) options.onChange?.(update.state.doc.toString());
1279
+ toolbar?.refresh();
1130
1280
  }),
1131
1281
  cm.EditorView.domEventHandlers({
1132
1282
  mousedown: handleTableCellMouseDown,
@@ -1139,14 +1289,119 @@
1139
1289
  blur() {
1140
1290
  view.dispatch({ effects: focusEffect.of(false) });
1141
1291
  setTimeout(() => {
1142
- if (!destroyed && !images?.hasPending() && !options.parent.contains(ownerDocument.activeElement)) options.onBlur?.();
1292
+ if (!destroyed && !images?.hasPending() && !toolbar?.ownsFocus() && !options.parent.contains(ownerDocument.activeElement)) options.onBlur?.();
1143
1293
  }, 0);
1144
1294
  }
1145
1295
  })
1146
1296
  ]
1147
1297
  });
1148
1298
  const view = new cm.EditorView({ state, parent: options.parent });
1299
+ updateCounter(state.doc);
1300
+ options.parent.appendChild(counter);
1149
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);
1150
1405
  options.parent.toggleAttribute("data-markdown-readonly", view.state.readOnly);
1151
1406
  return Object.freeze({
1152
1407
  setEditable(editable) {
@@ -1198,14 +1453,20 @@
1198
1453
  destroy() {
1199
1454
  if (destroyed) return;
1200
1455
  destroyed = true;
1456
+ toolbar?.destroy();
1457
+ options.parent.removeEventListener("keydown",onToolbarKey);
1458
+ options.parent.removeEventListener("compositionstart",onComposition);
1459
+ options.parent.removeEventListener("compositionend",onCompositionEnd);
1201
1460
  options.parent.removeAttribute("data-markdown-readonly");
1202
1461
  images?.destroy();
1203
1462
  view.destroy();
1463
+ counter.remove();
1204
1464
  }
1205
1465
  });
1206
1466
  }
1207
1467
 
1208
1468
  return Object.freeze({
1469
+ characterCount,
1209
1470
  inlineTokens,
1210
1471
  tableEscapedPipeBackslashes,
1211
1472
  tableCellEdgeWhitespaceRanges,
@@ -1218,6 +1479,7 @@
1218
1479
  syntheticTableCellMaterialization,
1219
1480
  safeTableCellInput,
1220
1481
  safeTableCellReplacement,
1482
+ tableOperation,
1221
1483
  previewModel,
1222
1484
  mount
1223
1485
  });