@aglyn/aglyn-markdown-editor 1.0.0-beta.143

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/LICENSE +201 -0
  2. package/README.md +34 -0
  3. package/package.json +40 -0
  4. package/src/index.d.ts +43 -0
  5. package/src/index.js +42 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/link-target-autocomplete.component.d.ts +60 -0
  8. package/src/lib/link-target-autocomplete.component.js +235 -0
  9. package/src/lib/link-target-autocomplete.component.js.map +1 -0
  10. package/src/lib/markdown-editor-toolbar.component.d.ts +14 -0
  11. package/src/lib/markdown-editor-toolbar.component.js +254 -0
  12. package/src/lib/markdown-editor-toolbar.component.js.map +1 -0
  13. package/src/lib/markdown-field.component.d.ts +43 -0
  14. package/src/lib/markdown-field.component.js +144 -0
  15. package/src/lib/markdown-field.component.js.map +1 -0
  16. package/src/lib/markdown-html-paste.d.ts +38 -0
  17. package/src/lib/markdown-html-paste.js +312 -0
  18. package/src/lib/markdown-html-paste.js.map +1 -0
  19. package/src/lib/markdown-link-dialog.component.d.ts +39 -0
  20. package/src/lib/markdown-link-dialog.component.js +195 -0
  21. package/src/lib/markdown-link-dialog.component.js.map +1 -0
  22. package/src/lib/markdown-lite-view.component.d.ts +6 -0
  23. package/src/lib/markdown-lite-view.component.js +243 -0
  24. package/src/lib/markdown-lite-view.component.js.map +1 -0
  25. package/src/lib/markdown-source-command.d.ts +51 -0
  26. package/src/lib/markdown-source-command.js +56 -0
  27. package/src/lib/markdown-source-command.js.map +1 -0
  28. package/src/lib/markdown-visual-editor.component.d.ts +109 -0
  29. package/src/lib/markdown-visual-editor.component.js +2019 -0
  30. package/src/lib/markdown-visual-editor.component.js.map +1 -0
@@ -0,0 +1,2019 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
+ import { isSupportedImageSrc, isSupportedLinkHref, parseMarkdownLite, resolveMediaSrc, serializeMarkdownLite } from "@aglyn/aglyn";
20
+ import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Popover, Stack, TextField, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material";
21
+ import FormatAlignCenterIcon from "@mui/icons-material/FormatAlignCenter";
22
+ import FormatAlignLeftIcon from "@mui/icons-material/FormatAlignLeft";
23
+ import FormatAlignRightIcon from "@mui/icons-material/FormatAlignRight";
24
+ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
25
+ import { htmlToRows } from "./markdown-html-paste.js";
26
+ import MarkdownLinkDialog, { MarkdownLinkTargetName } from "./markdown-link-dialog.component.js";
27
+ const isTextRow = (row)=>row.kind !== 'image' && row.kind !== 'code' && row.kind !== 'table';
28
+ let rowKeySeq = 0;
29
+ const nextRowKey = ()=>`md-row-${++rowKeySeq}`;
30
+ const emptyParagraph = ()=>({
31
+ key: nextRowKey(),
32
+ kind: 'paragraph',
33
+ inlines: []
34
+ });
35
+ /** Flattens parsed blocks to editable rows (lists become item rows). */ export function markdownToRows(markdown) {
36
+ const rows = [];
37
+ for (const block of parseMarkdownLite(markdown)){
38
+ if (block.type === 'heading') {
39
+ rows.push({
40
+ key: nextRowKey(),
41
+ kind: block.level === 2 ? 'heading2' : 'heading3',
42
+ inlines: block.inlines
43
+ });
44
+ } else if (block.type === 'paragraph') {
45
+ rows.push({
46
+ key: nextRowKey(),
47
+ kind: 'paragraph',
48
+ inlines: block.inlines
49
+ });
50
+ } else if (block.type === 'quote') {
51
+ rows.push({
52
+ key: nextRowKey(),
53
+ kind: 'quote',
54
+ inlines: block.inlines
55
+ });
56
+ } else if (block.type === 'image') {
57
+ rows.push({
58
+ key: nextRowKey(),
59
+ kind: 'image',
60
+ src: block.src,
61
+ alt: block.alt
62
+ });
63
+ } else if (block.type === 'code') {
64
+ rows.push({
65
+ key: nextRowKey(),
66
+ kind: 'code',
67
+ lang: block.lang,
68
+ text: block.text
69
+ });
70
+ } else if (block.type === 'table') {
71
+ rows.push({
72
+ key: nextRowKey(),
73
+ kind: 'table',
74
+ align: block.align,
75
+ header: block.header,
76
+ rows: block.rows
77
+ });
78
+ } else if (block.type === 'orderedList') {
79
+ // The start rides on the first item row only (AGL-1320) — the run's
80
+ // opener is what `rowsToMarkdown` reads it back from.
81
+ block.items.forEach((item, index)=>{
82
+ rows.push(_extends({
83
+ key: nextRowKey(),
84
+ kind: 'orderedItem',
85
+ inlines: item
86
+ }, index === 0 ? {
87
+ start: block.start
88
+ } : {}));
89
+ });
90
+ } else {
91
+ for (const item of block.items){
92
+ rows.push({
93
+ key: nextRowKey(),
94
+ kind: 'listItem',
95
+ inlines: item
96
+ });
97
+ }
98
+ }
99
+ }
100
+ if (rows.length === 0) rows.push(emptyParagraph());
101
+ return rows;
102
+ }
103
+ /** Regroups rows to blocks (adjacent list items rejoin) and serializes. */ export function rowsToMarkdown(rows) {
104
+ const blocks = [];
105
+ for (const row of rows){
106
+ if (row.kind === 'image') {
107
+ blocks.push({
108
+ type: 'image',
109
+ src: row.src,
110
+ alt: row.alt
111
+ });
112
+ } else if (row.kind === 'code') {
113
+ blocks.push({
114
+ type: 'code',
115
+ lang: row.lang,
116
+ text: row.text
117
+ });
118
+ } else if (row.kind === 'table') {
119
+ blocks.push({
120
+ type: 'table',
121
+ align: row.align,
122
+ header: row.header,
123
+ rows: row.rows
124
+ });
125
+ } else if (row.kind === 'listItem') {
126
+ const last = blocks[blocks.length - 1];
127
+ if ((last == null ? void 0 : last.type) === 'list') last.items.push(row.inlines);
128
+ else blocks.push({
129
+ type: 'list',
130
+ items: [
131
+ row.inlines
132
+ ]
133
+ });
134
+ } else if (row.kind === 'orderedItem') {
135
+ var _row_start;
136
+ const last = blocks[blocks.length - 1];
137
+ if ((last == null ? void 0 : last.type) === 'orderedList') last.items.push(row.inlines);
138
+ else blocks.push({
139
+ type: 'orderedList',
140
+ start: (_row_start = row.start) != null ? _row_start : 1,
141
+ items: [
142
+ row.inlines
143
+ ]
144
+ });
145
+ } else if (row.kind === 'paragraph') {
146
+ blocks.push({
147
+ type: 'paragraph',
148
+ inlines: row.inlines
149
+ });
150
+ } else if (row.kind === 'quote') {
151
+ blocks.push({
152
+ type: 'quote',
153
+ inlines: row.inlines
154
+ });
155
+ } else {
156
+ blocks.push({
157
+ type: 'heading',
158
+ level: row.kind === 'heading2' ? 2 : 3,
159
+ inlines: row.inlines
160
+ });
161
+ }
162
+ }
163
+ return serializeMarkdownLite(blocks);
164
+ }
165
+ const rowPlainText = (inlines)=>inlines.map((inline)=>inline.text).join('');
166
+ /** Merges adjacent same-type runs (same href for links), drops empties. */ function normalizeInlines(inlines) {
167
+ const out = [];
168
+ for (const inline of inlines){
169
+ if (!inline.text) continue;
170
+ const last = out[out.length - 1];
171
+ if (last && last.type === inline.type && (last.type !== 'link' || last.href === inline.href)) {
172
+ last.text += inline.text;
173
+ } else {
174
+ out.push(_extends({}, inline));
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+ /** Extracts [from, to) in plain-text offsets, keeping marks on the cuts. */ function sliceInlines(inlines, from, to) {
180
+ const out = [];
181
+ let position = 0;
182
+ for (const inline of inlines){
183
+ const end = position + inline.text.length;
184
+ const start = Math.max(from, position);
185
+ const stop = Math.min(to, end);
186
+ if (stop > start) {
187
+ out.push(_extends({}, inline, {
188
+ text: inline.text.slice(start - position, stop - position)
189
+ }));
190
+ }
191
+ position = end;
192
+ }
193
+ return normalizeInlines(out);
194
+ }
195
+ function replaceInlineRange(inlines, from, to, replacement) {
196
+ return normalizeInlines([
197
+ ...sliceInlines(inlines, 0, from),
198
+ ...replacement,
199
+ ...sliceInlines(inlines, to, Infinity)
200
+ ]);
201
+ }
202
+ /**
203
+ * Toggles bold/italic over [from, to). The dialect cannot nest marks, so
204
+ * applying one flattens the covered range to plain text carrying the mark;
205
+ * a range that is already entirely the mark unwraps back to text.
206
+ */ function toggleInlineMark(inlines, from, to, mark) {
207
+ const middle = sliceInlines(inlines, from, to);
208
+ const text = rowPlainText(middle);
209
+ if (!text) return {
210
+ inlines,
211
+ length: 0
212
+ };
213
+ const unwrap = middle.every((inline)=>inline.type === mark);
214
+ return {
215
+ inlines: replaceInlineRange(inlines, from, to, [
216
+ {
217
+ type: unwrap ? 'text' : mark,
218
+ text
219
+ }
220
+ ]),
221
+ length: text.length
222
+ };
223
+ }
224
+ /**
225
+ * Reads a row's live DOM back into canonical inlines. Only elements this
226
+ * editor renders exist in a row (plus text nodes the browser adds while
227
+ * typing); unknown wrappers are recursed through as plain text.
228
+ */ export function readInlinesFromElement(rowEl) {
229
+ const inlines = [];
230
+ const visit = (node)=>{
231
+ var _node_textContent;
232
+ if (node.nodeType === Node.TEXT_NODE) {
233
+ var _node_textContent1;
234
+ const text = (_node_textContent1 = node.textContent) != null ? _node_textContent1 : '';
235
+ if (text) inlines.push({
236
+ type: 'text',
237
+ text
238
+ });
239
+ return;
240
+ }
241
+ if (!(node instanceof HTMLElement) || node.tagName === 'BR') return;
242
+ const text = (_node_textContent = node.textContent) != null ? _node_textContent : '';
243
+ if (!text) return;
244
+ if (node.tagName === 'STRONG' || node.tagName === 'B') {
245
+ inlines.push({
246
+ type: 'bold',
247
+ text
248
+ });
249
+ } else if (node.tagName === 'EM' || node.tagName === 'I') {
250
+ inlines.push({
251
+ type: 'italic',
252
+ text
253
+ });
254
+ } else if (node.dataset['mdLink'] != null) {
255
+ var _node_dataset_mdHref;
256
+ inlines.push({
257
+ type: 'link',
258
+ text,
259
+ href: (_node_dataset_mdHref = node.dataset['mdHref']) != null ? _node_dataset_mdHref : ''
260
+ });
261
+ } else {
262
+ node.childNodes.forEach(visit);
263
+ }
264
+ };
265
+ rowEl.childNodes.forEach(visit);
266
+ return normalizeInlines(inlines);
267
+ }
268
+ /** Plain-text offset of a DOM position inside a row element. */ function textOffsetOf(rowEl, node, offset) {
269
+ const range = rowEl.ownerDocument.createRange();
270
+ range.selectNodeContents(rowEl);
271
+ try {
272
+ range.setEnd(node, offset);
273
+ } catch (unused) {
274
+ return 0;
275
+ }
276
+ return range.toString().length;
277
+ }
278
+ function selectionOffsetsWithin(rowEl) {
279
+ var _rowEl_ownerDocument_defaultView_getSelection, _rowEl_ownerDocument_defaultView;
280
+ const selection = (_rowEl_ownerDocument_defaultView = rowEl.ownerDocument.defaultView) == null ? void 0 : (_rowEl_ownerDocument_defaultView_getSelection = _rowEl_ownerDocument_defaultView.getSelection) == null ? void 0 : _rowEl_ownerDocument_defaultView_getSelection.call(_rowEl_ownerDocument_defaultView);
281
+ if (!selection || selection.rangeCount === 0) return null;
282
+ const range = selection.getRangeAt(0);
283
+ if (!rowEl.contains(range.startContainer) || !rowEl.contains(range.endContainer)) {
284
+ return null;
285
+ }
286
+ const start = textOffsetOf(rowEl, range.startContainer, range.startOffset);
287
+ const end = textOffsetOf(rowEl, range.endContainer, range.endOffset);
288
+ return start <= end ? {
289
+ start,
290
+ end
291
+ } : {
292
+ start: end,
293
+ end: start
294
+ };
295
+ }
296
+ function resolveTextPosition(rowEl, target) {
297
+ const walker = rowEl.ownerDocument.createTreeWalker(rowEl, NodeFilter.SHOW_TEXT);
298
+ let remaining = target;
299
+ let node = walker.nextNode();
300
+ let lastText = null;
301
+ while(node){
302
+ const text = node;
303
+ if (remaining <= text.length) return {
304
+ node: text,
305
+ offset: remaining
306
+ };
307
+ remaining -= text.length;
308
+ lastText = text;
309
+ node = walker.nextNode();
310
+ }
311
+ if (lastText) return {
312
+ node: lastText,
313
+ offset: lastText.length
314
+ };
315
+ return {
316
+ node: rowEl,
317
+ offset: 0
318
+ };
319
+ }
320
+ function setSelectionIn(rowEl, start, end) {
321
+ var _rowEl_ownerDocument_defaultView_getSelection, _rowEl_ownerDocument_defaultView;
322
+ const selection = (_rowEl_ownerDocument_defaultView = rowEl.ownerDocument.defaultView) == null ? void 0 : (_rowEl_ownerDocument_defaultView_getSelection = _rowEl_ownerDocument_defaultView.getSelection) == null ? void 0 : _rowEl_ownerDocument_defaultView_getSelection.call(_rowEl_ownerDocument_defaultView);
323
+ if (!selection) return;
324
+ const from = resolveTextPosition(rowEl, start);
325
+ const to = end === start ? from : resolveTextPosition(rowEl, end);
326
+ const range = rowEl.ownerDocument.createRange();
327
+ range.setStart(from.node, from.offset);
328
+ range.setEnd(to.node, to.offset);
329
+ selection.removeAllRanges();
330
+ selection.addRange(range);
331
+ }
332
+ function renderRowInlines(inlines) {
333
+ // An empty row keeps a <br> so the caret has a line box to land in.
334
+ if (inlines.length === 0) return /*#__PURE__*/ _jsx("br", {});
335
+ return inlines.map((inline, index)=>inline.type === 'bold' ? /*#__PURE__*/ _jsx("strong", {
336
+ children: inline.text
337
+ }, index) : inline.type === 'italic' ? /*#__PURE__*/ _jsx("em", {
338
+ children: inline.text
339
+ }, index) : inline.type === 'link' ? /*#__PURE__*/ _jsx("span", {
340
+ "data-md-link": "",
341
+ "data-md-href": inline.href,
342
+ children: inline.text
343
+ }, index) : /*#__PURE__*/ _jsx("span", {
344
+ children: inline.text
345
+ }, index));
346
+ }
347
+ /**
348
+ * One editable row. The rendered children are FROZEN per `version`: while
349
+ * the user types, React re-renders (parent state changes on every emitted
350
+ * keystroke) but receives the identical element references, so it bails
351
+ * out and never touches the browser-managed DOM. A version bump swaps the
352
+ * `key`, remounting the row cleanly from the model.
353
+ */ const TextRowView = /*#__PURE__*/ memo(function TextRowView(props) {
354
+ const { row, version, onInput, onKeyDown, onPaste, onClick, onSelectionPing } = props;
355
+ // The DOM is the source of truth between versions — deliberately NOT
356
+ // keyed on row.inlines, which trail the DOM during silent typing syncs.
357
+ // eslint-disable-next-line react-hooks/exhaustive-deps
358
+ const children = useMemo(()=>renderRowInlines(row.inlines), [
359
+ version
360
+ ]);
361
+ return /*#__PURE__*/ _jsx(Box, {
362
+ contentEditable: true,
363
+ suppressContentEditableWarning: true,
364
+ spellCheck: true,
365
+ "data-row-key": row.key,
366
+ "data-row-kind": row.kind,
367
+ // The opener of a numbered run seeds the counter (AGL-1320). Reset
368
+ // runs before increment on the same element, so this row itself shows
369
+ // `start` and the rest of the run counts up from it.
370
+ style: row.kind === 'orderedItem' && row.start != null ? {
371
+ counterReset: `md-ordered ${row.start - 1}`
372
+ } : undefined,
373
+ onInput: onInput,
374
+ onKeyDown: onKeyDown,
375
+ onPaste: onPaste,
376
+ onClick: onClick,
377
+ onKeyUp: onSelectionPing,
378
+ onMouseUp: onSelectionPing,
379
+ onFocus: onSelectionPing,
380
+ children: children
381
+ }, `${row.key}:${version}`);
382
+ });
383
+ /** A cell edited to nothing parses back to nothing — keep it canonical. */ const cellFromElement = (el)=>readInlinesFromElement(el);
384
+ const CODE_LANGUAGES = [
385
+ '',
386
+ 'ts',
387
+ 'tsx',
388
+ 'js',
389
+ 'json',
390
+ 'bash',
391
+ 'html',
392
+ 'css',
393
+ 'md'
394
+ ];
395
+ /**
396
+ * A block row: an image, a code block or a table (AGL-983). The document
397
+ * caret never enters one — each brings its own editing surface instead, and
398
+ * the row travels through the model as a unit. Hover reveals its controls.
399
+ *
400
+ * Editing follows the same rule as text rows: while the user types, the DOM
401
+ * is the source of truth and the model syncs SILENTLY (no re-render, no lost
402
+ * caret). Only structural edits — adding a column, changing alignment —
403
+ * re-render, and they bump `version`, which re-keys this row.
404
+ */ const BlockRowView = /*#__PURE__*/ memo(function BlockRowView({ row, version, onEdit, onEditSilently, onRemove }) {
405
+ var _ref;
406
+ const table = row.kind === 'table' ? row : null;
407
+ const columns = (_ref = table == null ? void 0 : table.header.length) != null ? _ref : 0;
408
+ /**
409
+ * Cell contents are rendered ONCE, exactly like a text row's (this view is
410
+ * re-keyed on `version`, so a model change remounts it). Re-rendering them
411
+ * would have React reconcile DOM the browser owns while the caret is in
412
+ * it — typing into an empty cell replaces its <br>, and the next render
413
+ * threw NotFoundError trying to remove a node that was already gone.
414
+ */ const frozenHeader = useMemo(()=>{
415
+ var _ref;
416
+ return ((_ref = table == null ? void 0 : table.header) != null ? _ref : []).map((cell)=>renderRowInlines(cell));
417
+ }, // eslint-disable-next-line react-hooks/exhaustive-deps
418
+ []);
419
+ const frozenRows = useMemo(()=>{
420
+ var _ref;
421
+ return ((_ref = table == null ? void 0 : table.rows) != null ? _ref : []).map((cells)=>cells.map((cell)=>renderRowInlines(cell)));
422
+ }, // eslint-disable-next-line react-hooks/exhaustive-deps
423
+ []);
424
+ /** Writes one cell back from its live DOM. Header row is index -1. */ const syncCell = (rowIndex, cellIndex, el)=>{
425
+ const inlines = cellFromElement(el);
426
+ onEditSilently((current)=>{
427
+ if (current.kind !== 'table') return current;
428
+ if (rowIndex < 0) {
429
+ const header = [
430
+ ...current.header
431
+ ];
432
+ header[cellIndex] = inlines;
433
+ return _extends({}, current, {
434
+ header
435
+ });
436
+ }
437
+ const rows = current.rows.map((cells, index)=>index === rowIndex ? cells.map((cell, i)=>i === cellIndex ? inlines : cell) : cells);
438
+ return _extends({}, current, {
439
+ rows
440
+ });
441
+ });
442
+ };
443
+ const cellProps = (rowIndex, cellIndex)=>{
444
+ var _ref;
445
+ return {
446
+ contentEditable: true,
447
+ suppressContentEditableWarning: true,
448
+ onInput: (event)=>syncCell(rowIndex, cellIndex, event.currentTarget),
449
+ style: {
450
+ textAlign: (_ref = table == null ? void 0 : table.align[cellIndex]) != null ? _ref : 'left'
451
+ }
452
+ };
453
+ };
454
+ return /*#__PURE__*/ _jsxs(Box, {
455
+ contentEditable: false,
456
+ "data-row-key": row.key,
457
+ "data-row-kind": row.kind,
458
+ sx: {
459
+ my: 1,
460
+ position: 'relative',
461
+ '&:hover [data-md-block-controls]': {
462
+ opacity: 1
463
+ }
464
+ },
465
+ children: [
466
+ row.kind === 'image' ? // Resolved for DISPLAY only (AGL-1686); `row.src` keeps the stored
467
+ // form, so serializing back writes the reference rather than the URL
468
+ // it happens to resolve to right now. Without this the author would
469
+ // be editing next to a broken-image icon while the published page
470
+ // rendered correctly.
471
+ // eslint-disable-next-line @next/next/no-img-element
472
+ /*#__PURE__*/ _jsx("img", {
473
+ src: resolveMediaSrc(row.src),
474
+ alt: row.alt
475
+ }) : row.kind === 'code' ? /*#__PURE__*/ _jsxs(Box, {
476
+ sx: {
477
+ borderRadius: 1,
478
+ border: '1px solid',
479
+ borderColor: 'divider',
480
+ bgcolor: 'action.hover',
481
+ overflow: 'hidden',
482
+ '&:focus-within': {
483
+ borderColor: 'primary.main'
484
+ }
485
+ },
486
+ children: [
487
+ /*#__PURE__*/ _jsx(Box, {
488
+ component: "textarea",
489
+ spellCheck: false,
490
+ defaultValue: row.text,
491
+ placeholder: "Your snippet…",
492
+ rows: Math.max(2, row.text.split('\n').length),
493
+ onChange: (event)=>{
494
+ const text = event.target.value;
495
+ // Grow with the snippet rather than scrolling a 2-row box.
496
+ event.target.style.height = 'auto';
497
+ event.target.style.height = `${event.target.scrollHeight}px`;
498
+ onEditSilently((current)=>current.kind === 'code' ? _extends({}, current, {
499
+ text
500
+ }) : current);
501
+ },
502
+ sx: {
503
+ display: 'block',
504
+ width: '100%',
505
+ border: 0,
506
+ outline: 'none',
507
+ resize: 'vertical',
508
+ p: 1.5,
509
+ bgcolor: 'transparent',
510
+ color: 'text.primary',
511
+ fontFamily: 'monospace',
512
+ fontSize: 13,
513
+ lineHeight: 1.6
514
+ }
515
+ }),
516
+ /*#__PURE__*/ _jsxs(Box, {
517
+ sx: {
518
+ display: 'flex',
519
+ alignItems: 'center',
520
+ gap: 1,
521
+ px: 1,
522
+ py: 0.5,
523
+ borderTop: '1px solid',
524
+ borderColor: 'divider'
525
+ },
526
+ children: [
527
+ /*#__PURE__*/ _jsx(Typography, {
528
+ variant: "caption",
529
+ color: "text.secondary",
530
+ children: 'Language'
531
+ }),
532
+ /*#__PURE__*/ _jsx(Box, {
533
+ component: "select",
534
+ defaultValue: row.lang,
535
+ onChange: (event)=>{
536
+ const lang = event.target.value;
537
+ onEditSilently((current)=>current.kind === 'code' ? _extends({}, current, {
538
+ lang
539
+ }) : current);
540
+ },
541
+ sx: {
542
+ border: 0,
543
+ bgcolor: 'transparent',
544
+ color: 'text.secondary',
545
+ font: 'inherit',
546
+ fontSize: 12,
547
+ outline: 'none'
548
+ },
549
+ children: CODE_LANGUAGES.map((lang)=>/*#__PURE__*/ _jsx("option", {
550
+ value: lang,
551
+ children: lang || 'plain'
552
+ }, lang))
553
+ })
554
+ ]
555
+ })
556
+ ]
557
+ }) : table ? /*#__PURE__*/ _jsx(Box, {
558
+ sx: {
559
+ overflowX: 'auto'
560
+ },
561
+ children: /*#__PURE__*/ _jsxs(Box, {
562
+ component: "table",
563
+ sx: {
564
+ borderCollapse: 'collapse',
565
+ width: '100%',
566
+ fontSize: 14,
567
+ '& th, & td': {
568
+ border: '1px solid',
569
+ borderColor: 'divider',
570
+ px: 1,
571
+ py: 0.5,
572
+ minWidth: 64
573
+ },
574
+ '& th': {
575
+ bgcolor: 'action.hover'
576
+ },
577
+ '& [contenteditable]': {
578
+ outline: 'none'
579
+ },
580
+ '& [contenteditable]:focus': {
581
+ boxShadow: (theme)=>`inset 0 0 0 2px ${theme.palette.primary.main}`
582
+ }
583
+ },
584
+ children: [
585
+ /*#__PURE__*/ _jsxs("thead", {
586
+ children: [
587
+ /*#__PURE__*/ _jsxs("tr", {
588
+ children: [
589
+ table.header.map((_cell, index)=>/*#__PURE__*/ _jsx("th", _extends({}, cellProps(-1, index), {
590
+ children: frozenHeader[index]
591
+ }), index)),
592
+ /*#__PURE__*/ _jsxs(Box, {
593
+ component: "th",
594
+ "data-md-block-controls": "",
595
+ sx: {
596
+ border: '0 !important',
597
+ bgcolor: 'transparent !important',
598
+ opacity: 0,
599
+ transition: 'opacity 120ms',
600
+ whiteSpace: 'nowrap'
601
+ },
602
+ children: [
603
+ /*#__PURE__*/ _jsx(Button, {
604
+ size: "small",
605
+ onMouseDown: (event)=>event.preventDefault(),
606
+ onClick: ()=>onEdit((current)=>current.kind === 'table' ? _extends({}, current, {
607
+ align: [
608
+ ...current.align,
609
+ 'left'
610
+ ],
611
+ header: [
612
+ ...current.header,
613
+ []
614
+ ],
615
+ rows: current.rows.map((cells)=>[
616
+ ...cells,
617
+ []
618
+ ])
619
+ }) : current),
620
+ children: '+ Column'
621
+ }),
622
+ columns > 1 ? /*#__PURE__*/ _jsx(Button, {
623
+ size: "small",
624
+ color: "error",
625
+ onMouseDown: (event)=>event.preventDefault(),
626
+ onClick: ()=>onEdit((current)=>current.kind === 'table' ? _extends({}, current, {
627
+ align: current.align.slice(0, -1),
628
+ header: current.header.slice(0, -1),
629
+ rows: current.rows.map((cells)=>cells.slice(0, -1))
630
+ }) : current),
631
+ children: '− Column'
632
+ }) : null
633
+ ]
634
+ })
635
+ ]
636
+ }),
637
+ /*#__PURE__*/ _jsxs(Box, {
638
+ component: "tr",
639
+ "data-md-block-controls": "",
640
+ sx: {
641
+ opacity: 0,
642
+ transition: 'opacity 120ms'
643
+ },
644
+ children: [
645
+ table.align.map((align, index)=>/*#__PURE__*/ _jsx(Box, {
646
+ component: "td",
647
+ sx: {
648
+ border: '0 !important',
649
+ p: '0 !important',
650
+ textAlign: 'center'
651
+ },
652
+ children: /*#__PURE__*/ _jsxs(ToggleButtonGroup, {
653
+ exclusive: true,
654
+ size: "small",
655
+ value: align,
656
+ onChange: (_event, next)=>{
657
+ if (!next) return;
658
+ onEdit((current)=>current.kind === 'table' ? _extends({}, current, {
659
+ align: current.align.map((entry, i)=>i === index ? next : entry)
660
+ }) : current);
661
+ },
662
+ sx: {
663
+ '& .MuiToggleButton-root': {
664
+ px: 0.75,
665
+ py: 0,
666
+ border: 0
667
+ }
668
+ },
669
+ children: [
670
+ /*#__PURE__*/ _jsx(ToggleButton, {
671
+ value: "left",
672
+ "aria-label": "Align left",
673
+ children: /*#__PURE__*/ _jsx(FormatAlignLeftIcon, {
674
+ fontSize: "inherit"
675
+ })
676
+ }),
677
+ /*#__PURE__*/ _jsx(ToggleButton, {
678
+ value: "center",
679
+ "aria-label": "Align center",
680
+ children: /*#__PURE__*/ _jsx(FormatAlignCenterIcon, {
681
+ fontSize: "inherit"
682
+ })
683
+ }),
684
+ /*#__PURE__*/ _jsx(ToggleButton, {
685
+ value: "right",
686
+ "aria-label": "Align right",
687
+ children: /*#__PURE__*/ _jsx(FormatAlignRightIcon, {
688
+ fontSize: "inherit"
689
+ })
690
+ })
691
+ ]
692
+ })
693
+ }, index)),
694
+ /*#__PURE__*/ _jsx(Box, {
695
+ component: "td",
696
+ sx: {
697
+ border: '0 !important'
698
+ }
699
+ })
700
+ ]
701
+ })
702
+ ]
703
+ }),
704
+ /*#__PURE__*/ _jsxs("tbody", {
705
+ children: [
706
+ table.rows.map((cells, rowIndex)=>/*#__PURE__*/ _jsxs("tr", {
707
+ children: [
708
+ cells.map((_cell, index)=>{
709
+ var _frozenRows_rowIndex;
710
+ return /*#__PURE__*/ _jsx("td", _extends({}, cellProps(rowIndex, index), {
711
+ children: (_frozenRows_rowIndex = frozenRows[rowIndex]) == null ? void 0 : _frozenRows_rowIndex[index]
712
+ }), index);
713
+ }),
714
+ /*#__PURE__*/ _jsx(Box, {
715
+ component: "td",
716
+ "data-md-block-controls": "",
717
+ sx: {
718
+ border: '0 !important',
719
+ bgcolor: 'transparent !important',
720
+ opacity: 0,
721
+ transition: 'opacity 120ms'
722
+ },
723
+ children: /*#__PURE__*/ _jsx(Button, {
724
+ size: "small",
725
+ color: "error",
726
+ onMouseDown: (event)=>event.preventDefault(),
727
+ onClick: ()=>onEdit((current)=>current.kind === 'table' ? _extends({}, current, {
728
+ rows: current.rows.filter((_cells, index)=>index !== rowIndex)
729
+ }) : current),
730
+ children: 'Delete row'
731
+ })
732
+ })
733
+ ]
734
+ }, rowIndex)),
735
+ /*#__PURE__*/ _jsx("tr", {
736
+ children: /*#__PURE__*/ _jsx(Box, {
737
+ component: "td",
738
+ colSpan: columns + 1,
739
+ "data-md-block-controls": "",
740
+ sx: {
741
+ border: '0 !important',
742
+ opacity: 0,
743
+ transition: 'opacity 120ms'
744
+ },
745
+ children: /*#__PURE__*/ _jsx(Button, {
746
+ size: "small",
747
+ onMouseDown: (event)=>event.preventDefault(),
748
+ onClick: ()=>onEdit((current)=>current.kind === 'table' ? _extends({}, current, {
749
+ rows: [
750
+ ...current.rows,
751
+ current.header.map(()=>[])
752
+ ]
753
+ }) : current),
754
+ children: '+ Row'
755
+ })
756
+ })
757
+ })
758
+ ]
759
+ })
760
+ ]
761
+ })
762
+ }) : null,
763
+ /*#__PURE__*/ _jsx(Button, {
764
+ size: "small",
765
+ color: "error",
766
+ variant: "contained",
767
+ "data-md-block-controls": "",
768
+ onMouseDown: (event)=>event.preventDefault(),
769
+ onClick: onRemove,
770
+ sx: {
771
+ position: 'absolute',
772
+ top: 8,
773
+ right: 8,
774
+ opacity: 0,
775
+ transition: 'opacity 120ms'
776
+ },
777
+ children: 'Remove'
778
+ })
779
+ ]
780
+ });
781
+ });
782
+ const cloneRows = (rows)=>rows.map((row)=>isTextRow(row) ? _extends({}, row, {
783
+ inlines: row.inlines.map((inline)=>_extends({}, inline))
784
+ }) : // faithful history snapshot.
785
+ _extends({}, row));
786
+ const HISTORY_LIMIT = 100;
787
+ /**
788
+ * What the editor accepts is exactly what the PARSER keeps (AGL-1686).
789
+ *
790
+ * These were hand-rolled copies of the parser's rules, and the copy is what
791
+ * broke: AGL-1645 widened the image rule here to accept the site-relative
792
+ * `/api/media/cdn/{scope}/{id}` the media library hands back, while
793
+ * `markdown-lite`'s image rule stayed absolute-`http(s)`-only. The insert
794
+ * worked, serialized, saved — and the parser then dropped the block on the way
795
+ * back out, so the image was gone from the document, the preview and the
796
+ * published page with nothing logged anywhere.
797
+ *
798
+ * Delegating means the two can no longer disagree. Both still refuse
799
+ * protocol-relative `//host`, which would silently leave the site; images
800
+ * additionally accept a well-formed `media:` reference (AGL-1215), which is
801
+ * now what the media picker inserts.
802
+ */ const isValidLinkUrl = (url)=>isSupportedLinkHref(url);
803
+ const isValidImageUrl = (url)=>isSupportedImageSrc(url);
804
+ const MarkdownVisualEditor = /*#__PURE__*/ forwardRef(function MarkdownVisualEditor({ value, onChange, onPickImageFromMedia, onContextChange, minHeight = 280, maxHeight = 480 }, ref) {
805
+ var _rowsRef_current, _ref, _ref1;
806
+ const [version, setVersion] = useState(0);
807
+ const rowsRef = useRef(null);
808
+ if (rowsRef.current == null) rowsRef.current = markdownToRows(value);
809
+ const rootRef = useRef(null);
810
+ const lastEmittedRef = useRef(value);
811
+ const pendingSelectionRef = useRef(null);
812
+ const lastSelectionRef = useRef(null);
813
+ const historyRef = useRef({
814
+ past: [],
815
+ future: []
816
+ });
817
+ const typingBatchRef = useRef(false);
818
+ const typingTimerRef = useRef(null);
819
+ const dialogTargetRef = useRef(null);
820
+ const [urlDialog, setUrlDialog] = useState(null);
821
+ const [linkDialog, setLinkDialog] = useState(null);
822
+ const [linkPopover, setLinkPopover] = useState(null);
823
+ const [placeholderVisible, setPlaceholderVisible] = useState(()=>{
824
+ var _rowsRef_current;
825
+ return rowsToMarkdown((_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : []).length === 0;
826
+ });
827
+ const rows = (_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [];
828
+ const emitChange = useCallback(()=>{
829
+ var _rowsRef_current;
830
+ const markdown = rowsToMarkdown((_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : []);
831
+ lastEmittedRef.current = markdown;
832
+ setPlaceholderVisible(markdown.length === 0);
833
+ onChange(markdown);
834
+ }, [
835
+ onChange
836
+ ]);
837
+ // External updates (tab remount, AI assist rewriting the body): re-parse
838
+ // unless the incoming value is the one this editor just emitted.
839
+ useEffect(()=>{
840
+ if (value === lastEmittedRef.current) return;
841
+ rowsRef.current = markdownToRows(value);
842
+ lastEmittedRef.current = value;
843
+ historyRef.current = {
844
+ past: [],
845
+ future: []
846
+ };
847
+ pendingSelectionRef.current = null;
848
+ lastSelectionRef.current = null;
849
+ typingBatchRef.current = false;
850
+ setPlaceholderVisible(rowsToMarkdown(rowsRef.current).length === 0);
851
+ setVersion((current)=>current + 1);
852
+ }, [
853
+ value
854
+ ]);
855
+ const pushHistory = useCallback(()=>{
856
+ var _rowsRef_current;
857
+ const { past } = historyRef.current;
858
+ past.push({
859
+ rows: cloneRows((_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : []),
860
+ selection: lastSelectionRef.current
861
+ });
862
+ if (past.length > HISTORY_LIMIT) past.shift();
863
+ historyRef.current.future = [];
864
+ }, []);
865
+ /** Re-renders every row from the model and restores the selection. */ const commit = useCallback((selection)=>{
866
+ pendingSelectionRef.current = selection;
867
+ if (selection) lastSelectionRef.current = selection;
868
+ typingBatchRef.current = false;
869
+ setVersion((current)=>current + 1);
870
+ emitChange();
871
+ }, [
872
+ emitChange
873
+ ]);
874
+ useLayoutEffect(()=>{
875
+ var _rootRef_current;
876
+ const pending = pendingSelectionRef.current;
877
+ if (!pending) return;
878
+ pendingSelectionRef.current = null;
879
+ const rowEl = (_rootRef_current = rootRef.current) == null ? void 0 : _rootRef_current.querySelector(`[data-row-key="${pending.key}"]`);
880
+ // Block rows (image / code / table) have no caret to restore. The
881
+ // attribute, not `isContentEditable` — jsdom does not compute the latter.
882
+ if (!rowEl || rowEl.getAttribute('contenteditable') !== 'true') return;
883
+ rowEl.focus();
884
+ setSelectionIn(rowEl, pending.start, pending.end);
885
+ }, [
886
+ version
887
+ ]);
888
+ const findRowIndex = useCallback((key)=>{
889
+ var _rowsRef_current;
890
+ return ((_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : []).findIndex((row)=>row.key === key);
891
+ }, []);
892
+ /** Records the current DOM selection in model coordinates. */ const captureSelection = useCallback(()=>{
893
+ var _ref;
894
+ var _root_ownerDocument_defaultView_getSelection, _root_ownerDocument_defaultView;
895
+ const root = rootRef.current;
896
+ const selection = (_ref = root == null ? void 0 : (_root_ownerDocument_defaultView = root.ownerDocument.defaultView) == null ? void 0 : (_root_ownerDocument_defaultView_getSelection = _root_ownerDocument_defaultView.getSelection) == null ? void 0 : _root_ownerDocument_defaultView_getSelection.call(_root_ownerDocument_defaultView)) != null ? _ref : null;
897
+ if (!root || !selection || selection.rangeCount === 0) {
898
+ return lastSelectionRef.current;
899
+ }
900
+ const range = selection.getRangeAt(0);
901
+ const rowOf = (node)=>{
902
+ var _node_parentElement, _ref;
903
+ const el = node instanceof HTMLElement ? node : (_node_parentElement = node.parentElement) != null ? _node_parentElement : null;
904
+ const rowEl = (_ref = el == null ? void 0 : el.closest('[data-row-key]')) != null ? _ref : null;
905
+ return rowEl && root.contains(rowEl) ? rowEl : null;
906
+ };
907
+ const startRow = rowOf(range.startContainer);
908
+ const endRow = rowOf(range.endContainer);
909
+ // Cross-row selections are out of scope for phase 1 — keep last known.
910
+ if (!startRow || startRow !== endRow) return lastSelectionRef.current;
911
+ const key = startRow.dataset['rowKey'];
912
+ if (!key) return lastSelectionRef.current;
913
+ const offsets = selectionOffsetsWithin(startRow);
914
+ if (!offsets) return lastSelectionRef.current;
915
+ const snapshot = _extends({
916
+ key
917
+ }, offsets);
918
+ lastSelectionRef.current = snapshot;
919
+ return snapshot;
920
+ }, []);
921
+ /**
922
+ * Publishes the caret's formatting context (AGL-984). A collapsed caret
923
+ * reports the mark of the character BEHIND it — the same character the
924
+ * next keystroke would extend. Deduped, so a toolbar re-renders only when
925
+ * something it draws actually changed.
926
+ */ const contextKeyRef = useRef('');
927
+ const onContextChangeRef = useRef(onContextChange);
928
+ onContextChangeRef.current = onContextChange;
929
+ const reportContext = useCallback(()=>{
930
+ var _rowsRef_current;
931
+ const notify = onContextChangeRef.current;
932
+ if (!notify) return;
933
+ const selection = lastSelectionRef.current;
934
+ const rows = (_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [];
935
+ const row = selection ? rows.find((entry)=>entry.key === selection.key) : undefined;
936
+ let context = {
937
+ bold: false,
938
+ italic: false,
939
+ kind: null
940
+ };
941
+ if (selection && row && isTextRow(row)) {
942
+ const from = selection.start === selection.end ? Math.max(0, selection.start - 1) : selection.start;
943
+ const covered = sliceInlines(row.inlines, from, selection.end);
944
+ context = {
945
+ bold: covered.length > 0 && covered.every((inline)=>inline.type === 'bold'),
946
+ italic: covered.length > 0 && covered.every((inline)=>inline.type === 'italic'),
947
+ kind: row.kind
948
+ };
949
+ }
950
+ const key = `${context.bold}:${context.italic}:${context.kind}`;
951
+ if (key === contextKeyRef.current) return;
952
+ contextKeyRef.current = key;
953
+ notify(context);
954
+ }, []);
955
+ const handleSelectionPing = useCallback(()=>{
956
+ captureSelection();
957
+ reportContext();
958
+ }, [
959
+ captureSelection,
960
+ reportContext
961
+ ]);
962
+ // A structural edit moves the caret without a ping of its own.
963
+ useEffect(()=>{
964
+ reportContext();
965
+ }, [
966
+ version,
967
+ reportContext
968
+ ]);
969
+ const undo = useCallback(()=>{
970
+ var _rowsRef_current;
971
+ const { past, future } = historyRef.current;
972
+ const entry = past.pop();
973
+ if (!entry) return;
974
+ future.push({
975
+ rows: cloneRows((_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : []),
976
+ selection: lastSelectionRef.current
977
+ });
978
+ rowsRef.current = cloneRows(entry.rows);
979
+ typingBatchRef.current = false;
980
+ pendingSelectionRef.current = entry.selection;
981
+ setVersion((current)=>current + 1);
982
+ emitChange();
983
+ }, [
984
+ emitChange
985
+ ]);
986
+ const redo = useCallback(()=>{
987
+ var _rowsRef_current;
988
+ const { past, future } = historyRef.current;
989
+ const entry = future.pop();
990
+ if (!entry) return;
991
+ past.push({
992
+ rows: cloneRows((_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : []),
993
+ selection: lastSelectionRef.current
994
+ });
995
+ rowsRef.current = cloneRows(entry.rows);
996
+ typingBatchRef.current = false;
997
+ pendingSelectionRef.current = entry.selection;
998
+ setVersion((current)=>current + 1);
999
+ emitChange();
1000
+ }, [
1001
+ emitChange
1002
+ ]);
1003
+ /** Resolves where a toolbar command applies: selection or document end. */ const resolveTarget = useCallback(()=>{
1004
+ var _rowsRef_current;
1005
+ const current = (_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [];
1006
+ const selection = captureSelection();
1007
+ if (selection) {
1008
+ const index = current.findIndex((row)=>row.key === selection.key);
1009
+ const row = current[index];
1010
+ if (index >= 0 && row && isTextRow(row)) {
1011
+ return {
1012
+ index,
1013
+ start: selection.start,
1014
+ end: selection.end
1015
+ };
1016
+ }
1017
+ }
1018
+ for(let index = current.length - 1; index >= 0; index--){
1019
+ const row = current[index];
1020
+ if (row && isTextRow(row)) {
1021
+ const length = rowPlainText(row.inlines).length;
1022
+ return {
1023
+ index,
1024
+ start: length,
1025
+ end: length
1026
+ };
1027
+ }
1028
+ }
1029
+ return null;
1030
+ }, [
1031
+ captureSelection
1032
+ ]);
1033
+ /** Splices a block row in after `afterIndex` (end of document if null). */ const insertBlockRow = useCallback((block, afterIndex)=>{
1034
+ var _rowsRef_current;
1035
+ const current = (_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [];
1036
+ pushHistory();
1037
+ const index = afterIndex == null ? current.length : afterIndex + 1;
1038
+ current.splice(index, 0, _extends({
1039
+ key: nextRowKey()
1040
+ }, block));
1041
+ // Keep a caret landing spot after a trailing block.
1042
+ if (index === current.length - 1) current.push(emptyParagraph());
1043
+ const next = current[index + 1];
1044
+ commit(next && isTextRow(next) ? {
1045
+ key: next.key,
1046
+ start: 0,
1047
+ end: 0
1048
+ } : null);
1049
+ }, [
1050
+ commit,
1051
+ pushHistory
1052
+ ]);
1053
+ const insertImageRow = useCallback((alt, url, afterIndex)=>{
1054
+ if (!isValidImageUrl(url)) return;
1055
+ insertBlockRow({
1056
+ kind: 'image',
1057
+ src: url,
1058
+ alt
1059
+ }, afterIndex);
1060
+ }, [
1061
+ insertBlockRow
1062
+ ]);
1063
+ /** Edits one block row in place; `silent` skips the re-render (AGL-983). */ const editBlockRow = useCallback((key, mutate, silent)=>{
1064
+ var _rowsRef_current;
1065
+ const current = (_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [];
1066
+ const index = findRowIndex(key);
1067
+ const row = current[index];
1068
+ if (!row || isTextRow(row)) return;
1069
+ // Typing in a cell or a snippet batches its history the same way
1070
+ // typing in a paragraph does: one undo step per burst, not per key.
1071
+ if (silent) {
1072
+ if (!typingBatchRef.current) {
1073
+ pushHistory();
1074
+ typingBatchRef.current = true;
1075
+ }
1076
+ if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
1077
+ typingTimerRef.current = setTimeout(()=>{
1078
+ typingBatchRef.current = false;
1079
+ }, 800);
1080
+ } else {
1081
+ pushHistory();
1082
+ }
1083
+ current[index] = mutate(row);
1084
+ if (silent) emitChange();
1085
+ else commit(null);
1086
+ }, [
1087
+ commit,
1088
+ emitChange,
1089
+ findRowIndex,
1090
+ pushHistory
1091
+ ]);
1092
+ const execCommand = useCallback((command)=>{
1093
+ var _rowsRef_current;
1094
+ const current = (_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [];
1095
+ const target = resolveTarget();
1096
+ if (command === 'image') {
1097
+ // URL prompt dialog; when the host page wires
1098
+ // `onPickImageFromMedia` it also offers the media picker (AGL-596).
1099
+ dialogTargetRef.current = target;
1100
+ setUrlDialog({
1101
+ kind: 'image',
1102
+ url: '',
1103
+ text: ''
1104
+ });
1105
+ return;
1106
+ }
1107
+ // Block inserts (AGL-983) land after the caret row, like an image.
1108
+ if (command === 'code') {
1109
+ var _ref;
1110
+ insertBlockRow({
1111
+ kind: 'code',
1112
+ lang: '',
1113
+ text: ''
1114
+ }, (_ref = target == null ? void 0 : target.index) != null ? _ref : null);
1115
+ return;
1116
+ }
1117
+ if (command === 'table') {
1118
+ var _ref1;
1119
+ const empty = ()=>[
1120
+ [],
1121
+ []
1122
+ ];
1123
+ insertBlockRow({
1124
+ kind: 'table',
1125
+ align: [
1126
+ 'left',
1127
+ 'left'
1128
+ ],
1129
+ header: empty(),
1130
+ rows: [
1131
+ empty(),
1132
+ empty()
1133
+ ]
1134
+ }, (_ref1 = target == null ? void 0 : target.index) != null ? _ref1 : null);
1135
+ return;
1136
+ }
1137
+ if (!target) return;
1138
+ const row = current[target.index];
1139
+ // Block-kind toggles: pressing the row's own kind returns it to prose.
1140
+ const toggleKind = (kind)=>{
1141
+ pushHistory();
1142
+ current[target.index] = _extends({}, row, {
1143
+ kind: row.kind === kind ? 'paragraph' : kind
1144
+ });
1145
+ commit({
1146
+ key: row.key,
1147
+ start: target.start,
1148
+ end: target.end
1149
+ });
1150
+ };
1151
+ if (command === 'heading') return toggleKind('heading2');
1152
+ if (command === 'heading3') return toggleKind('heading3');
1153
+ if (command === 'list') return toggleKind('listItem');
1154
+ if (command === 'orderedList') return toggleKind('orderedItem');
1155
+ if (command === 'quote') return toggleKind('quote');
1156
+ if (command === 'link') {
1157
+ dialogTargetRef.current = target;
1158
+ setLinkDialog({
1159
+ text: rowPlainText(sliceInlines(row.inlines, target.start, target.end))
1160
+ });
1161
+ return;
1162
+ }
1163
+ pushHistory();
1164
+ if (target.start === target.end) {
1165
+ const placeholder = command === 'bold' ? 'bold text' : 'italic text';
1166
+ current[target.index] = _extends({}, row, {
1167
+ inlines: replaceInlineRange(row.inlines, target.start, target.end, [
1168
+ {
1169
+ type: command,
1170
+ text: placeholder
1171
+ }
1172
+ ])
1173
+ });
1174
+ commit({
1175
+ key: row.key,
1176
+ start: target.start,
1177
+ end: target.start + placeholder.length
1178
+ });
1179
+ return;
1180
+ }
1181
+ const toggled = toggleInlineMark(row.inlines, target.start, target.end, command);
1182
+ current[target.index] = _extends({}, row, {
1183
+ inlines: toggled.inlines
1184
+ });
1185
+ commit({
1186
+ key: row.key,
1187
+ start: target.start,
1188
+ end: target.start + toggled.length
1189
+ });
1190
+ }, [
1191
+ commit,
1192
+ insertBlockRow,
1193
+ pushHistory,
1194
+ resolveTarget
1195
+ ]);
1196
+ useImperativeHandle(ref, ()=>({
1197
+ exec: execCommand,
1198
+ insertImage: (alt, url)=>{
1199
+ const selection = lastSelectionRef.current;
1200
+ const index = selection ? findRowIndex(selection.key) : -1;
1201
+ insertImageRow(alt, url, index >= 0 ? index : null);
1202
+ }
1203
+ }), [
1204
+ execCommand,
1205
+ findRowIndex,
1206
+ insertImageRow
1207
+ ]);
1208
+ const handleRowInput = useCallback((key)=>(event)=>{
1209
+ var _rowsRef_current;
1210
+ const current = (_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [];
1211
+ const index = findRowIndex(key);
1212
+ const row = current[index];
1213
+ if (!row || !isTextRow(row)) return;
1214
+ const rowEl = event.currentTarget;
1215
+ // Snapshot the pre-typing model once per typing burst so undo jumps
1216
+ // back over the whole burst, not one character at a time.
1217
+ if (!typingBatchRef.current) {
1218
+ pushHistory();
1219
+ typingBatchRef.current = true;
1220
+ }
1221
+ if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
1222
+ typingTimerRef.current = setTimeout(()=>{
1223
+ typingBatchRef.current = false;
1224
+ }, 800);
1225
+ const inlines = readInlinesFromElement(rowEl);
1226
+ const text = rowPlainText(inlines);
1227
+ // Markdown shortcuts (AGL-582): "## " / "### " / "- " at the start
1228
+ // of a paragraph convert it to a heading or list item. "# " joins them
1229
+ // (AGL-1082) so typing a heading agrees with pasting one — the parser
1230
+ // clamps a single `#` to the top rendered level too. "1. " / "1) "
1231
+ // opens a numbered list at the number that was typed (AGL-1320).
1232
+ if (row.kind === 'paragraph') {
1233
+ const numbered = /^(\d{1,9})[.)] /.exec(text);
1234
+ const shortcut = text.startsWith('### ') ? {
1235
+ kind: 'heading3',
1236
+ trim: 4
1237
+ } : text.startsWith('## ') ? {
1238
+ kind: 'heading2',
1239
+ trim: 3
1240
+ } : text.startsWith('# ') ? {
1241
+ kind: 'heading2',
1242
+ trim: 2
1243
+ } : text.startsWith('- ') ? {
1244
+ kind: 'listItem',
1245
+ trim: 2
1246
+ } : text.startsWith('> ') ? {
1247
+ kind: 'quote',
1248
+ trim: 2
1249
+ } : numbered ? {
1250
+ kind: 'orderedItem',
1251
+ trim: numbered[0].length,
1252
+ start: Number(numbered[1])
1253
+ } : null;
1254
+ if (shortcut) {
1255
+ var _ref;
1256
+ var _selectionOffsetsWithin;
1257
+ const caret = (_ref = (_selectionOffsetsWithin = selectionOffsetsWithin(rowEl)) == null ? void 0 : _selectionOffsetsWithin.start) != null ? _ref : text.length;
1258
+ const offset = Math.max(0, caret - shortcut.trim);
1259
+ current[index] = _extends({
1260
+ key: row.key,
1261
+ kind: shortcut.kind,
1262
+ inlines: sliceInlines(inlines, shortcut.trim, Infinity)
1263
+ }, 'start' in shortcut ? {
1264
+ start: shortcut.start
1265
+ } : {});
1266
+ commit({
1267
+ key: row.key,
1268
+ start: offset,
1269
+ end: offset
1270
+ });
1271
+ return;
1272
+ }
1273
+ }
1274
+ // Plain typing: sync the model silently — no re-render, the DOM is
1275
+ // already correct and the caret must not move.
1276
+ current[index] = _extends({}, row, {
1277
+ inlines
1278
+ });
1279
+ emitChange();
1280
+ }, [
1281
+ commit,
1282
+ emitChange,
1283
+ findRowIndex,
1284
+ pushHistory
1285
+ ]);
1286
+ const focusAdjacentRow = useCallback((fromIndex, direction, preferredOffset)=>{
1287
+ var _rowsRef_current;
1288
+ const current = (_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [];
1289
+ for(let index = fromIndex + direction; index >= 0 && index < current.length; index += direction){
1290
+ var _rootRef_current;
1291
+ const row = current[index];
1292
+ if (!row || !isTextRow(row)) continue;
1293
+ const rowEl = (_rootRef_current = rootRef.current) == null ? void 0 : _rootRef_current.querySelector(`[data-row-key="${row.key}"]`);
1294
+ if (!rowEl) return;
1295
+ const length = rowPlainText(row.inlines).length;
1296
+ const offset = Math.min(Math.max(preferredOffset, 0), length);
1297
+ rowEl.focus();
1298
+ setSelectionIn(rowEl, offset, offset);
1299
+ captureSelection();
1300
+ return;
1301
+ }
1302
+ }, [
1303
+ captureSelection
1304
+ ]);
1305
+ const handleRowKeyDown = useCallback((key)=>(event)=>{
1306
+ var _rowsRef_current;
1307
+ if (event.nativeEvent.isComposing) return;
1308
+ const current = (_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [];
1309
+ const index = findRowIndex(key);
1310
+ const row = current[index];
1311
+ if (!row || !isTextRow(row)) return;
1312
+ const rowEl = event.currentTarget;
1313
+ if (event.key === 'Enter') {
1314
+ var _selectionOffsetsWithin;
1315
+ event.preventDefault();
1316
+ const inlines = readInlinesFromElement(rowEl);
1317
+ const length = rowPlainText(inlines).length;
1318
+ const offsets = (_selectionOffsetsWithin = selectionOffsetsWithin(rowEl)) != null ? _selectionOffsetsWithin : {
1319
+ start: length,
1320
+ end: length
1321
+ };
1322
+ current[index] = _extends({}, row, {
1323
+ inlines
1324
+ });
1325
+ pushHistory();
1326
+ // Enter on an empty list item exits the list into a paragraph.
1327
+ if ((row.kind === 'listItem' || row.kind === 'orderedItem') && length === 0) {
1328
+ current[index] = {
1329
+ key: row.key,
1330
+ kind: 'paragraph',
1331
+ inlines: []
1332
+ };
1333
+ commit({
1334
+ key: row.key,
1335
+ start: 0,
1336
+ end: 0
1337
+ });
1338
+ return;
1339
+ }
1340
+ const before = sliceInlines(inlines, 0, offsets.start);
1341
+ const after = sliceInlines(inlines, offsets.end, Infinity);
1342
+ // Splitting a heading leaves prose behind; lists continue as items.
1343
+ // The continuation deliberately carries NO `start` (AGL-1320) — only
1344
+ // the row that opens a run numbers it.
1345
+ const nextKind = row.kind === 'listItem' || row.kind === 'orderedItem' ? row.kind : 'paragraph';
1346
+ const newRow = {
1347
+ key: nextRowKey(),
1348
+ kind: nextKind,
1349
+ inlines: after
1350
+ };
1351
+ current[index] = _extends({}, row, {
1352
+ inlines: before
1353
+ });
1354
+ current.splice(index + 1, 0, newRow);
1355
+ commit({
1356
+ key: newRow.key,
1357
+ start: 0,
1358
+ end: 0
1359
+ });
1360
+ return;
1361
+ }
1362
+ if (event.key === 'Backspace') {
1363
+ const offsets = selectionOffsetsWithin(rowEl);
1364
+ if (!offsets || offsets.start !== 0 || offsets.end !== 0) return;
1365
+ event.preventDefault();
1366
+ const inlines = readInlinesFromElement(rowEl);
1367
+ current[index] = _extends({}, row, {
1368
+ inlines
1369
+ });
1370
+ // Backspace at the start of a heading/list item demotes it first.
1371
+ if (row.kind !== 'paragraph') {
1372
+ pushHistory();
1373
+ current[index] = {
1374
+ key: row.key,
1375
+ kind: 'paragraph',
1376
+ inlines
1377
+ };
1378
+ commit({
1379
+ key: row.key,
1380
+ start: 0,
1381
+ end: 0
1382
+ });
1383
+ return;
1384
+ }
1385
+ if (index === 0) return;
1386
+ const previous = current[index - 1];
1387
+ if (!previous) return;
1388
+ pushHistory();
1389
+ if (!isTextRow(previous)) {
1390
+ current.splice(index - 1, 1);
1391
+ commit({
1392
+ key: row.key,
1393
+ start: 0,
1394
+ end: 0
1395
+ });
1396
+ return;
1397
+ }
1398
+ const junction = rowPlainText(previous.inlines).length;
1399
+ current[index - 1] = _extends({}, previous, {
1400
+ inlines: normalizeInlines([
1401
+ ...previous.inlines,
1402
+ ...inlines
1403
+ ])
1404
+ });
1405
+ current.splice(index, 1);
1406
+ commit({
1407
+ key: previous.key,
1408
+ start: junction,
1409
+ end: junction
1410
+ });
1411
+ return;
1412
+ }
1413
+ // Caret hops across row boundaries (separate contentEditables).
1414
+ if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) {
1415
+ return;
1416
+ }
1417
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
1418
+ const offsets = selectionOffsetsWithin(rowEl);
1419
+ if (!offsets || offsets.start !== offsets.end) return;
1420
+ const length = rowPlainText(readInlinesFromElement(rowEl)).length;
1421
+ if (event.key === 'ArrowLeft' && offsets.start === 0) {
1422
+ event.preventDefault();
1423
+ focusAdjacentRow(index, -1, Infinity);
1424
+ } else if (event.key === 'ArrowRight' && offsets.start >= length) {
1425
+ event.preventDefault();
1426
+ focusAdjacentRow(index, 1, 0);
1427
+ }
1428
+ return;
1429
+ }
1430
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
1431
+ const before = selectionOffsetsWithin(rowEl);
1432
+ if (!before) return;
1433
+ const direction = event.key === 'ArrowDown' ? 1 : -1;
1434
+ // If the browser can't move within the row (first/last line), the
1435
+ // selection stays put — hop to the adjacent row instead.
1436
+ setTimeout(()=>{
1437
+ const after = selectionOffsetsWithin(rowEl);
1438
+ if (!after) return;
1439
+ if (after.start !== before.start || after.end !== before.end) return;
1440
+ focusAdjacentRow(index, direction, before.start);
1441
+ }, 0);
1442
+ }
1443
+ }, [
1444
+ commit,
1445
+ findRowIndex,
1446
+ focusAdjacentRow,
1447
+ pushHistory
1448
+ ]);
1449
+ const handleRowPaste = useCallback((key)=>(event)=>{
1450
+ var _rowsRef_current, _selectionOffsetsWithin, _ref, _ref1;
1451
+ var _event_clipboardData, _event_clipboardData1;
1452
+ event.preventDefault();
1453
+ const current = (_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [];
1454
+ const index = findRowIndex(key);
1455
+ const row = current[index];
1456
+ if (!row || !isTextRow(row)) return;
1457
+ const rowEl = event.currentTarget;
1458
+ const inlines = readInlinesFromElement(rowEl);
1459
+ const length = rowPlainText(inlines).length;
1460
+ const offsets = (_selectionOffsetsWithin = selectionOffsetsWithin(rowEl)) != null ? _selectionOffsetsWithin : {
1461
+ start: length,
1462
+ end: length
1463
+ };
1464
+ // Rich clipboard first (AGL-596): text/html converts to rows so
1465
+ // bold/italic/links, headings, lists and images survive the paste.
1466
+ const html = (_ref = (_event_clipboardData = event.clipboardData) == null ? void 0 : _event_clipboardData.getData('text/html')) != null ? _ref : '';
1467
+ if (html) {
1468
+ // Re-key onto this editor's sequence — converted keys are
1469
+ // module-local placeholders and must not collide across pastes.
1470
+ const converted = htmlToRows(html).map((pasted)=>_extends({}, pasted, {
1471
+ key: nextRowKey()
1472
+ }));
1473
+ const only = converted.length === 1 ? converted[0] : undefined;
1474
+ if (only && isTextRow(only)) {
1475
+ // One text row: merge its inlines into this row at the caret,
1476
+ // exactly like a plain-text paste but keeping the marks.
1477
+ current[index] = _extends({}, row, {
1478
+ inlines
1479
+ });
1480
+ pushHistory();
1481
+ current[index] = _extends({}, row, {
1482
+ inlines: replaceInlineRange(inlines, offsets.start, offsets.end, only.inlines)
1483
+ });
1484
+ const caret = offsets.start + rowPlainText(only.inlines).length;
1485
+ commit({
1486
+ key: row.key,
1487
+ start: caret,
1488
+ end: caret
1489
+ });
1490
+ return;
1491
+ }
1492
+ if (converted.length > 0) {
1493
+ // Multi-row (or image) paste: splice whole rows in after this
1494
+ // one — no splitting of the current row.
1495
+ current[index] = _extends({}, row, {
1496
+ inlines
1497
+ });
1498
+ pushHistory();
1499
+ const tail = converted[converted.length - 1];
1500
+ if (tail && !isTextRow(tail)) converted.push(emptyParagraph());
1501
+ current.splice(index + 1, 0, ...converted);
1502
+ const focus = converted[converted.length - 1];
1503
+ if (focus && isTextRow(focus)) {
1504
+ const end = rowPlainText(focus.inlines).length;
1505
+ commit({
1506
+ key: focus.key,
1507
+ start: end,
1508
+ end
1509
+ });
1510
+ } else {
1511
+ commit(null);
1512
+ }
1513
+ return;
1514
+ }
1515
+ // Nothing usable in the HTML — fall through to text/plain.
1516
+ }
1517
+ const text = (_ref1 = (_event_clipboardData1 = event.clipboardData) == null ? void 0 : _event_clipboardData1.getData('text/plain')) != null ? _ref1 : '';
1518
+ if (!text) return;
1519
+ const flattened = text.replace(/\s*\n+\s*/g, ' ');
1520
+ current[index] = _extends({}, row, {
1521
+ inlines
1522
+ });
1523
+ pushHistory();
1524
+ current[index] = _extends({}, row, {
1525
+ inlines: replaceInlineRange(inlines, offsets.start, offsets.end, [
1526
+ {
1527
+ type: 'text',
1528
+ text: flattened
1529
+ }
1530
+ ])
1531
+ });
1532
+ const caret = offsets.start + flattened.length;
1533
+ commit({
1534
+ key: row.key,
1535
+ start: caret,
1536
+ end: caret
1537
+ });
1538
+ }, [
1539
+ commit,
1540
+ findRowIndex,
1541
+ pushHistory
1542
+ ]);
1543
+ const handleRowClick = useCallback((key)=>(event)=>{
1544
+ const target = event.target.closest == null ? void 0 : event.target.closest.call(event.target, '[data-md-link]');
1545
+ if (target && event.currentTarget.contains(target)) {
1546
+ var _target_getAttribute;
1547
+ const links = Array.from(event.currentTarget.querySelectorAll('[data-md-link]'));
1548
+ setLinkPopover({
1549
+ anchor: target,
1550
+ rowKey: key,
1551
+ linkIndex: links.indexOf(target),
1552
+ href: (_target_getAttribute = target.getAttribute('data-md-href')) != null ? _target_getAttribute : ''
1553
+ });
1554
+ }
1555
+ captureSelection();
1556
+ }, [
1557
+ captureSelection
1558
+ ]);
1559
+ /** Nth link inline of a row (document order matches inline order). */ const findLinkInline = useCallback((rowKey, linkIndex)=>{
1560
+ var _rowsRef_current;
1561
+ const index = findRowIndex(rowKey);
1562
+ const row = ((_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [])[index];
1563
+ if (!row || !isTextRow(row)) return null;
1564
+ let seen = -1;
1565
+ for(let i = 0; i < row.inlines.length; i++){
1566
+ const inline = row.inlines[i];
1567
+ if ((inline == null ? void 0 : inline.type) === 'link' && ++seen === linkIndex) {
1568
+ return {
1569
+ rowIndex: index,
1570
+ row,
1571
+ inlineIndex: i,
1572
+ inline
1573
+ };
1574
+ }
1575
+ }
1576
+ return null;
1577
+ }, [
1578
+ findRowIndex
1579
+ ]);
1580
+ const handleLinkRemove = useCallback(()=>{
1581
+ var _rowsRef_current;
1582
+ if (!linkPopover) return;
1583
+ const found = findLinkInline(linkPopover.rowKey, linkPopover.linkIndex);
1584
+ setLinkPopover(null);
1585
+ if (!found) return;
1586
+ pushHistory();
1587
+ const inlines = [
1588
+ ...found.row.inlines
1589
+ ];
1590
+ inlines[found.inlineIndex] = {
1591
+ type: 'text',
1592
+ text: found.inline.text
1593
+ };
1594
+ const current = (_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [];
1595
+ current[found.rowIndex] = _extends({}, found.row, {
1596
+ inlines: normalizeInlines(inlines)
1597
+ });
1598
+ commit(null);
1599
+ }, [
1600
+ commit,
1601
+ findLinkInline,
1602
+ linkPopover,
1603
+ pushHistory
1604
+ ]);
1605
+ const handleLinkEdit = useCallback(()=>{
1606
+ if (!linkPopover) return;
1607
+ setLinkDialog({
1608
+ href: linkPopover.href,
1609
+ text: '',
1610
+ editRowKey: linkPopover.rowKey,
1611
+ editLinkIndex: linkPopover.linkIndex
1612
+ });
1613
+ setLinkPopover(null);
1614
+ }, [
1615
+ linkPopover
1616
+ ]);
1617
+ const handleUrlDialogConfirm = useCallback(()=>{
1618
+ if (!urlDialog) return;
1619
+ const url = urlDialog.url.trim();
1620
+ if (!isValidImageUrl(url)) return;
1621
+ const target = dialogTargetRef.current;
1622
+ setUrlDialog(null);
1623
+ insertImageRow(urlDialog.text.trim(), url, target ? target.index : null);
1624
+ }, [
1625
+ insertImageRow,
1626
+ urlDialog
1627
+ ]);
1628
+ /**
1629
+ * The link dialog's answer: a URL as typed, or a reference the dialog's
1630
+ * lookup produced (AGL-3119). Both are `href` on the same inline — the
1631
+ * dialect keeps either, and the renderer resolves the reference.
1632
+ */ const handleLinkConfirm = useCallback((link)=>{
1633
+ var _rowsRef_current;
1634
+ if (!linkDialog) return;
1635
+ const href = link.href.trim();
1636
+ if (!isValidLinkUrl(href)) return;
1637
+ const current = (_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [];
1638
+ if (linkDialog.editRowKey != null && linkDialog.editLinkIndex != null) {
1639
+ const found = findLinkInline(linkDialog.editRowKey, linkDialog.editLinkIndex);
1640
+ setLinkDialog(null);
1641
+ if (!found) return;
1642
+ pushHistory();
1643
+ const inlines = [
1644
+ ...found.row.inlines
1645
+ ];
1646
+ inlines[found.inlineIndex] = {
1647
+ type: 'link',
1648
+ text: found.inline.text,
1649
+ href
1650
+ };
1651
+ current[found.rowIndex] = _extends({}, found.row, {
1652
+ inlines
1653
+ });
1654
+ commit(null);
1655
+ return;
1656
+ }
1657
+ const target = dialogTargetRef.current;
1658
+ setLinkDialog(null);
1659
+ if (!target) return;
1660
+ const row = current[target.index];
1661
+ if (!row || !isTextRow(row)) return;
1662
+ pushHistory();
1663
+ const text = link.text.trim() || href;
1664
+ current[target.index] = _extends({}, row, {
1665
+ inlines: replaceInlineRange(row.inlines, target.start, target.end, [
1666
+ {
1667
+ type: 'link',
1668
+ text,
1669
+ href
1670
+ }
1671
+ ])
1672
+ });
1673
+ commit({
1674
+ key: row.key,
1675
+ start: target.start,
1676
+ end: target.start + text.length
1677
+ });
1678
+ }, [
1679
+ commit,
1680
+ findLinkInline,
1681
+ linkDialog,
1682
+ pushHistory
1683
+ ]);
1684
+ const handleBlockRemove = useCallback((key)=>()=>{
1685
+ var _rowsRef_current;
1686
+ const current = (_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [];
1687
+ const index = findRowIndex(key);
1688
+ if (index < 0) return;
1689
+ pushHistory();
1690
+ current.splice(index, 1);
1691
+ if (current.length === 0) current.push(emptyParagraph());
1692
+ commit(null);
1693
+ }, [
1694
+ commit,
1695
+ findRowIndex,
1696
+ pushHistory
1697
+ ]);
1698
+ const handleRootKeyDown = useCallback((event)=>{
1699
+ if (event.defaultPrevented) return;
1700
+ const key = event.key.toLowerCase();
1701
+ if ((event.metaKey || event.ctrlKey) && !event.altKey) {
1702
+ if (key === 'z') {
1703
+ // Model-driven rendering breaks native contentEditable undo, so
1704
+ // the editor keeps its own snapshot stack (AGL-582).
1705
+ event.preventDefault();
1706
+ if (event.shiftKey) redo();
1707
+ else undo();
1708
+ return;
1709
+ }
1710
+ if (key === 'y' && event.ctrlKey && !event.shiftKey) {
1711
+ event.preventDefault();
1712
+ redo();
1713
+ return;
1714
+ }
1715
+ if (event.shiftKey) return;
1716
+ if (key === 'b') {
1717
+ event.preventDefault();
1718
+ execCommand('bold');
1719
+ return;
1720
+ }
1721
+ if (key === 'i') {
1722
+ event.preventDefault();
1723
+ execCommand('italic');
1724
+ }
1725
+ }
1726
+ }, [
1727
+ execCommand,
1728
+ redo,
1729
+ undo
1730
+ ]);
1731
+ /** Clicking the padding below the last row puts the caret at the end. */ const handleRootMouseDown = useCallback((event)=>{
1732
+ var _rowsRef_current;
1733
+ if (event.target !== rootRef.current) return;
1734
+ event.preventDefault();
1735
+ const current = (_rowsRef_current = rowsRef.current) != null ? _rowsRef_current : [];
1736
+ for(let index = current.length - 1; index >= 0; index--){
1737
+ var _rootRef_current;
1738
+ const row = current[index];
1739
+ if (!row || !isTextRow(row)) continue;
1740
+ const rowEl = (_rootRef_current = rootRef.current) == null ? void 0 : _rootRef_current.querySelector(`[data-row-key="${row.key}"]`);
1741
+ if (!rowEl) return;
1742
+ const length = rowPlainText(row.inlines).length;
1743
+ rowEl.focus();
1744
+ setSelectionIn(rowEl, length, length);
1745
+ captureSelection();
1746
+ return;
1747
+ }
1748
+ }, [
1749
+ captureSelection
1750
+ ]);
1751
+ const urlDialogValid = urlDialog != null && isValidImageUrl(urlDialog.url.trim());
1752
+ return /*#__PURE__*/ _jsxs(_Fragment, {
1753
+ children: [
1754
+ /*#__PURE__*/ _jsxs(Box, {
1755
+ ref: rootRef,
1756
+ "data-testid": "markdown-visual-editor",
1757
+ onKeyDown: handleRootKeyDown,
1758
+ onMouseDown: handleRootMouseDown,
1759
+ // Clicking into a table cell or a snippet is a selection change the
1760
+ // text rows never see; the toolbar still has to hear about it.
1761
+ onMouseUp: handleSelectionPing,
1762
+ onKeyUp: handleSelectionPing,
1763
+ sx: {
1764
+ position: 'relative',
1765
+ border: '1px solid',
1766
+ borderColor: 'divider',
1767
+ borderRadius: 1,
1768
+ px: 2,
1769
+ py: 1.5,
1770
+ minHeight,
1771
+ maxHeight,
1772
+ overflow: 'auto',
1773
+ cursor: 'text',
1774
+ // Seeds the numbered-row counter (AGL-1320) so a document that
1775
+ // OPENS with `1. ` has one to increment.
1776
+ counterReset: 'md-ordered 0',
1777
+ '&:focus-within': {
1778
+ borderColor: 'primary.main'
1779
+ },
1780
+ // Typography mirrors the tenant's Entry Body rendering (AGL-551)
1781
+ // so the editing surface reads like the published article.
1782
+ '& [data-row-kind]': {
1783
+ outline: 'none',
1784
+ whiteSpace: 'pre-wrap',
1785
+ wordBreak: 'break-word'
1786
+ },
1787
+ '& [data-row-kind="paragraph"]': {
1788
+ typography: 'body1',
1789
+ lineHeight: 1.7,
1790
+ my: 0.75
1791
+ },
1792
+ '& [data-row-kind="heading2"]': {
1793
+ typography: 'h5',
1794
+ mt: 2,
1795
+ mb: 1
1796
+ },
1797
+ '& [data-row-kind="heading3"]': {
1798
+ typography: 'h6',
1799
+ mt: 1.5,
1800
+ mb: 0.75
1801
+ },
1802
+ '& [data-row-kind="listItem"]': {
1803
+ typography: 'body1',
1804
+ lineHeight: 1.7,
1805
+ display: 'list-item',
1806
+ listStyleType: 'disc',
1807
+ ml: 3,
1808
+ my: 0.25
1809
+ },
1810
+ // Numbered rows (AGL-1320). Rows are siblings, not children of an
1811
+ // `<ol>`, so the markers come from an explicit counter rather than
1812
+ // `list-style-type: decimal` — the implicit list-item counter is
1813
+ // shared with the bullet rows above and would keep counting through
1814
+ // them. Every non-ordered row RESETS it, which is what ends a run:
1815
+ // a counter-reset scopes to the element and its following siblings.
1816
+ // The run's opener additionally seeds it from `start` inline.
1817
+ '& [data-row-kind]:not([data-row-kind="orderedItem"])': {
1818
+ counterReset: 'md-ordered 0'
1819
+ },
1820
+ '& [data-row-kind="orderedItem"]': {
1821
+ typography: 'body1',
1822
+ lineHeight: 1.7,
1823
+ counterIncrement: 'md-ordered',
1824
+ position: 'relative',
1825
+ ml: 3,
1826
+ my: 0.25,
1827
+ '&::before': {
1828
+ content: 'counter(md-ordered) ". "',
1829
+ position: 'absolute',
1830
+ right: '100%',
1831
+ pr: 0.5,
1832
+ color: 'text.secondary'
1833
+ }
1834
+ },
1835
+ // The tenant's pull-quote treatment (AGL-1315), at editor scale.
1836
+ '& [data-row-kind="quote"]': {
1837
+ typography: 'body1',
1838
+ lineHeight: 1.6,
1839
+ fontStyle: 'italic',
1840
+ borderLeft: '3px solid',
1841
+ borderColor: 'primary.main',
1842
+ pl: 2,
1843
+ my: 1
1844
+ },
1845
+ '& [data-md-link]': {
1846
+ color: 'primary.main',
1847
+ textDecoration: 'underline',
1848
+ cursor: 'pointer'
1849
+ },
1850
+ '& img': {
1851
+ maxWidth: '100%',
1852
+ borderRadius: 1
1853
+ }
1854
+ },
1855
+ children: [
1856
+ placeholderVisible ? /*#__PURE__*/ _jsx(Typography, {
1857
+ variant: "body1",
1858
+ color: "text.disabled",
1859
+ sx: {
1860
+ position: 'absolute',
1861
+ top: 12,
1862
+ left: 16,
1863
+ pointerEvents: 'none'
1864
+ },
1865
+ children: 'Start writing…'
1866
+ }) : null,
1867
+ rows.map((row)=>!isTextRow(row) ? /*#__PURE__*/ _jsx(BlockRowView, {
1868
+ row: row,
1869
+ version: version,
1870
+ onEdit: (mutate)=>editBlockRow(row.key, mutate, false),
1871
+ onEditSilently: (mutate)=>editBlockRow(row.key, mutate, true),
1872
+ onRemove: handleBlockRemove(row.key)
1873
+ }, `${row.key}:${version}`) : /*#__PURE__*/ _jsx(TextRowView, {
1874
+ row: row,
1875
+ version: version,
1876
+ onInput: handleRowInput(row.key),
1877
+ onKeyDown: handleRowKeyDown(row.key),
1878
+ onPaste: handleRowPaste(row.key),
1879
+ onClick: handleRowClick(row.key),
1880
+ onSelectionPing: handleSelectionPing
1881
+ }, row.key))
1882
+ ]
1883
+ }),
1884
+ /*#__PURE__*/ _jsx(Popover, {
1885
+ open: Boolean(linkPopover),
1886
+ anchorEl: (_ref = linkPopover == null ? void 0 : linkPopover.anchor) != null ? _ref : null,
1887
+ onClose: ()=>setLinkPopover(null),
1888
+ anchorOrigin: {
1889
+ vertical: 'bottom',
1890
+ horizontal: 'left'
1891
+ },
1892
+ children: /*#__PURE__*/ _jsxs(Stack, {
1893
+ direction: "row",
1894
+ spacing: 1,
1895
+ sx: {
1896
+ px: 1.5,
1897
+ py: 1,
1898
+ alignItems: 'center',
1899
+ maxWidth: 420
1900
+ },
1901
+ children: [
1902
+ /*#__PURE__*/ _jsx(Typography, {
1903
+ variant: "caption",
1904
+ color: "text.secondary",
1905
+ noWrap: true,
1906
+ sx: {
1907
+ maxWidth: 240
1908
+ },
1909
+ children: linkPopover ? /*#__PURE__*/ _jsx(MarkdownLinkTargetName, {
1910
+ href: linkPopover.href
1911
+ }) : null
1912
+ }),
1913
+ /*#__PURE__*/ _jsx(Button, {
1914
+ size: "small",
1915
+ onClick: handleLinkEdit,
1916
+ children: 'Edit'
1917
+ }),
1918
+ /*#__PURE__*/ _jsx(Button, {
1919
+ size: "small",
1920
+ color: "error",
1921
+ onClick: handleLinkRemove,
1922
+ children: 'Remove'
1923
+ })
1924
+ ]
1925
+ })
1926
+ }),
1927
+ /*#__PURE__*/ _jsxs(Dialog, {
1928
+ open: Boolean(urlDialog),
1929
+ onClose: ()=>setUrlDialog(null),
1930
+ maxWidth: "xs",
1931
+ fullWidth: true,
1932
+ children: [
1933
+ /*#__PURE__*/ _jsx(DialogTitle, {
1934
+ children: 'Insert image'
1935
+ }),
1936
+ /*#__PURE__*/ _jsxs(DialogContent, {
1937
+ sx: {
1938
+ display: 'flex',
1939
+ flexDirection: 'column',
1940
+ gap: 2,
1941
+ pt: 1
1942
+ },
1943
+ children: [
1944
+ /*#__PURE__*/ _jsx(TextField, {
1945
+ label: "URL",
1946
+ value: (_ref1 = urlDialog == null ? void 0 : urlDialog.url) != null ? _ref1 : '',
1947
+ onChange: (event)=>setUrlDialog((prev)=>prev ? _extends({}, prev, {
1948
+ url: event.target.value
1949
+ }) : prev),
1950
+ onKeyDown: (event)=>{
1951
+ if (event.key === 'Enter' && urlDialogValid) {
1952
+ event.preventDefault();
1953
+ handleUrlDialogConfirm();
1954
+ }
1955
+ },
1956
+ size: "small",
1957
+ autoFocus: true,
1958
+ sx: {
1959
+ mt: 1
1960
+ },
1961
+ helperText: "https:// or a site path like /images/hero.png"
1962
+ }),
1963
+ urlDialog ? /*#__PURE__*/ _jsx(TextField, {
1964
+ label: "Alt text",
1965
+ value: urlDialog.text,
1966
+ onChange: (event)=>setUrlDialog((prev)=>prev ? _extends({}, prev, {
1967
+ text: event.target.value
1968
+ }) : prev),
1969
+ size: "small"
1970
+ }) : null
1971
+ ]
1972
+ }),
1973
+ /*#__PURE__*/ _jsxs(DialogActions, {
1974
+ children: [
1975
+ urlDialog && onPickImageFromMedia ? /*#__PURE__*/ _jsx(Button, {
1976
+ color: "primary",
1977
+ sx: {
1978
+ mr: 'auto'
1979
+ },
1980
+ onClick: ()=>{
1981
+ // Read the alt BEFORE clearing the dialog (AGL-1645) — the
1982
+ // author has already typed it, and the picked image has no
1983
+ // other way to acquire one.
1984
+ const alt = urlDialog.text.trim();
1985
+ setUrlDialog(null);
1986
+ onPickImageFromMedia(alt);
1987
+ },
1988
+ children: 'Choose from media'
1989
+ }) : null,
1990
+ /*#__PURE__*/ _jsx(Button, {
1991
+ onClick: ()=>setUrlDialog(null),
1992
+ children: 'Cancel'
1993
+ }),
1994
+ /*#__PURE__*/ _jsx(Button, {
1995
+ variant: "contained",
1996
+ color: "primary",
1997
+ disabled: !urlDialogValid,
1998
+ onClick: handleUrlDialogConfirm,
1999
+ children: 'Insert'
2000
+ })
2001
+ ]
2002
+ })
2003
+ ]
2004
+ }),
2005
+ /*#__PURE__*/ _jsx(MarkdownLinkDialog, {
2006
+ open: Boolean(linkDialog),
2007
+ href: linkDialog == null ? void 0 : linkDialog.href,
2008
+ text: linkDialog == null ? void 0 : linkDialog.text,
2009
+ withText: (linkDialog == null ? void 0 : linkDialog.editRowKey) == null,
2010
+ onClose: ()=>setLinkDialog(null),
2011
+ onConfirm: handleLinkConfirm
2012
+ })
2013
+ ]
2014
+ });
2015
+ });
2016
+ MarkdownVisualEditor.displayName = 'MarkdownVisualEditor';
2017
+ export default MarkdownVisualEditor;
2018
+
2019
+ //# sourceMappingURL=markdown-visual-editor.component.js.map