@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.
- package/LICENSE +201 -0
- package/README.md +34 -0
- package/package.json +40 -0
- package/src/index.d.ts +43 -0
- package/src/index.js +42 -0
- package/src/index.js.map +1 -0
- package/src/lib/link-target-autocomplete.component.d.ts +60 -0
- package/src/lib/link-target-autocomplete.component.js +235 -0
- package/src/lib/link-target-autocomplete.component.js.map +1 -0
- package/src/lib/markdown-editor-toolbar.component.d.ts +14 -0
- package/src/lib/markdown-editor-toolbar.component.js +254 -0
- package/src/lib/markdown-editor-toolbar.component.js.map +1 -0
- package/src/lib/markdown-field.component.d.ts +43 -0
- package/src/lib/markdown-field.component.js +144 -0
- package/src/lib/markdown-field.component.js.map +1 -0
- package/src/lib/markdown-html-paste.d.ts +38 -0
- package/src/lib/markdown-html-paste.js +312 -0
- package/src/lib/markdown-html-paste.js.map +1 -0
- package/src/lib/markdown-link-dialog.component.d.ts +39 -0
- package/src/lib/markdown-link-dialog.component.js +195 -0
- package/src/lib/markdown-link-dialog.component.js.map +1 -0
- package/src/lib/markdown-lite-view.component.d.ts +6 -0
- package/src/lib/markdown-lite-view.component.js +243 -0
- package/src/lib/markdown-lite-view.component.js.map +1 -0
- package/src/lib/markdown-source-command.d.ts +51 -0
- package/src/lib/markdown-source-command.js +56 -0
- package/src/lib/markdown-source-command.js.map +1 -0
- package/src/lib/markdown-visual-editor.component.d.ts +109 -0
- package/src/lib/markdown-visual-editor.component.js +2019 -0
- 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
|