@tristan2828/ui-foundation 3.16.1 → 3.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -249,6 +249,8 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
249
249
|
const changes = $prose(() => new Plugin({
|
|
250
250
|
view: () => ({
|
|
251
251
|
update: (view, previous) => {
|
|
252
|
+
if (cancelled)
|
|
253
|
+
return;
|
|
252
254
|
setEditorState(view.state);
|
|
253
255
|
if (!view.state.doc.eq(previous.doc))
|
|
254
256
|
emit();
|
|
@@ -366,6 +368,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
366
368
|
});
|
|
367
369
|
return () => {
|
|
368
370
|
cancelled = true;
|
|
371
|
+
setEditorState(null);
|
|
369
372
|
const editor = editorRef.current;
|
|
370
373
|
editorRef.current = null;
|
|
371
374
|
if (editor)
|
|
@@ -403,12 +406,18 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
403
406
|
setLinkBox(null);
|
|
404
407
|
editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
405
408
|
};
|
|
406
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { "data-slot": "rich-text-editor", className: cn('flex w-full min-w-0 flex-col rounded-lg border border-input bg-transparent type-body text-foreground break-words transition-[border-color,box-shadow]', 'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', className), children: [toolbar && (_jsx(RichTextToolbar, { state: editorState, readOnly: readOnly,
|
|
409
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { "data-slot": "rich-text-editor", className: cn('flex w-full min-w-0 flex-col rounded-lg border border-input bg-transparent type-body text-foreground break-words transition-[border-color,box-shadow]', 'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', className), children: [toolbar && (_jsx(RichTextToolbar, { state: editorState, readOnly: readOnly,
|
|
410
|
+
// Only once the editor is made: until then the editable element,
|
|
411
|
+
// and its id, aren't in the page (axe: aria-valid-attr-value).
|
|
412
|
+
controls: editorState ? editableId : undefined, onFormat: runFormat })), _jsx("div", { ref: rootRef, className: cn('min-h-24 px-2.5 py-2',
|
|
407
413
|
// Read-only text is muted; the toolbar's buttons say so by being disabled.
|
|
408
414
|
'has-[[aria-readonly=true]]:text-muted-foreground',
|
|
409
415
|
// The same type roles <Markdown> uses, so text reads the same
|
|
410
416
|
// being edited as being read.
|
|
411
|
-
'[&_.ProseMirror]:flex [&_.ProseMirror]:min-h-20 [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3',
|
|
417
|
+
'[&_.ProseMirror]:flex [&_.ProseMirror]:min-h-20 [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3',
|
|
418
|
+
// ProseMirror's own required style (prosemirror-view's CSS): spaces
|
|
419
|
+
// as typed, so one at the end of a line isn't kept as .
|
|
420
|
+
'[&_.ProseMirror]:whitespace-pre-wrap [&_.ProseMirror]:break-words', '[&_h3]:type-section-title [&_h4]:type-section-title [&_h5]:type-label [&_h6]:type-label', '[&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:pl-6', '[&_ol]:flex [&_ol]:list-decimal [&_ol]:flex-col [&_ol]:gap-1 [&_ol]:pl-6', '[&_li[data-item-type=task]]:-ml-6 [&_li[data-item-type=task]]:flex [&_li[data-item-type=task]]:list-none [&_li[data-item-type=task]]:items-start [&_li[data-item-type=task]]:gap-2', '[&_li[data-item-type=task]>input]:mt-1 [&_li[data-item-type=task]>input]:size-4 [&_li[data-item-type=task]>input]:accent-primary', '[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4', '[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground', '[&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono', '[&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:font-mono', '[&_hr]:border-border', '[&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:type-label',
|
|
412
421
|
// Raw HTML from the text: shown as its characters, never as markup.
|
|
413
422
|
'[&_span[data-type=html]]:rounded [&_span[data-type=html]]:bg-muted [&_span[data-type=html]]:px-1 [&_span[data-type=html]]:font-mono [&_span[data-type=html]]:text-foreground',
|
|
414
423
|
// The placeholder, in an empty document only (a decoration).
|
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
// original's own source; only a changed or new block is written by the
|
|
18
18
|
// editor (in the document's own bullet and rule marks, `markdownStyle`).
|
|
19
19
|
// - Blocks the editor can't show (a link reference's definition) are kept
|
|
20
|
-
// where they were.
|
|
20
|
+
// where they were. A block the editor shows its own way (a table it
|
|
21
|
+
// padded, marks it nested differently) and that `meaning` still misses is
|
|
22
|
+
// written as the editor shows it, as long as nothing in it is lost
|
|
23
|
+
// (`content`): no text, address or image. A block the editor drops part
|
|
24
|
+
// of (it doesn't show images) is kept as written, and the guard refuses.
|
|
21
25
|
// - Guard: the result must mean exactly what the editor shows. If it
|
|
22
26
|
// wouldn't (two blocks fusing, say), the merge throws instead of saving
|
|
23
27
|
// something else; the draft stays on screen.
|
|
@@ -30,34 +34,103 @@ function parse(markdown) {
|
|
|
30
34
|
}
|
|
31
35
|
// Fields that are about how a node was written, not what it means.
|
|
32
36
|
const STYLE_ONLY = new Set(['position', 'data', 'spread', 'label', 'referenceType']);
|
|
37
|
+
// Nodes whose children are inline text: compared as runs of marked text.
|
|
38
|
+
const INLINE_PARENTS = new Set(['paragraph', 'heading', 'tableCell']);
|
|
39
|
+
// A reference link (or image) as the link it stands for, when its
|
|
40
|
+
// definition is in the document.
|
|
41
|
+
function resolved(node, definitions) {
|
|
42
|
+
if (node.type !== 'linkReference' && node.type !== 'imageReference')
|
|
43
|
+
return node;
|
|
44
|
+
const definition = definitions.get(String(node.identifier));
|
|
45
|
+
if (!definition)
|
|
46
|
+
return node;
|
|
47
|
+
return {
|
|
48
|
+
type: node.type === 'linkReference' ? 'link' : 'image',
|
|
49
|
+
url: definition.url,
|
|
50
|
+
title: definition.title ?? null,
|
|
51
|
+
alt: node.alt,
|
|
52
|
+
children: node.children,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// The mark an inline node puts on its text, or undefined for text itself.
|
|
56
|
+
function markOf(node) {
|
|
57
|
+
if (node.type === 'strong' || node.type === 'emphasis' || node.type === 'delete')
|
|
58
|
+
return node.type;
|
|
59
|
+
if (node.type === 'link')
|
|
60
|
+
return `link ${JSON.stringify([node.url, node.title ?? null])}`;
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
// Inline content as what a reader sees: each piece of text (or code, an
|
|
64
|
+
// image, a break) with the set of marks on it, whichever way they were
|
|
65
|
+
// nested. `**[a](u)**` and `[**a**](u)` are the same bold link; a link with
|
|
66
|
+
// no text shows nothing.
|
|
67
|
+
function runs(nodes, definitions, marks = [], out = []) {
|
|
68
|
+
for (const child of nodes) {
|
|
69
|
+
const node = resolved(child, definitions);
|
|
70
|
+
const mark = markOf(node);
|
|
71
|
+
if (mark) {
|
|
72
|
+
runs(node.children ?? [], definitions, [...marks, mark], out);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const set = [...new Set(marks)].sort();
|
|
76
|
+
const last = out.at(-1);
|
|
77
|
+
if (node.type === 'text' && last?.type === 'text' && JSON.stringify(last.marks) === JSON.stringify(set)) {
|
|
78
|
+
last.value = String(last.value) + String(node.value);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
out.push({ ...meaning(node, definitions), marks: set });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
33
86
|
// A block's meaning as a comparable string: positions and source style
|
|
34
|
-
// dropped,
|
|
87
|
+
// dropped, a reference link resolved to the link it stands for, inline
|
|
88
|
+
// marks compared as a set, and a table's rows all as wide as its widest
|
|
89
|
+
// (GFM allows short rows; the editor pads them with empty cells).
|
|
35
90
|
function meaning(node, definitions) {
|
|
36
91
|
if (Array.isArray(node))
|
|
37
92
|
return node.map((child) => meaning(child, definitions));
|
|
38
93
|
if (!node || typeof node !== 'object')
|
|
39
94
|
return node;
|
|
40
|
-
const md = node;
|
|
41
|
-
if (md.type === 'linkReference' || md.type === 'imageReference') {
|
|
42
|
-
const definition = definitions.get(String(md.identifier));
|
|
43
|
-
if (definition) {
|
|
44
|
-
return meaning({
|
|
45
|
-
type: md.type === 'linkReference' ? 'link' : 'image',
|
|
46
|
-
url: definition.url,
|
|
47
|
-
title: definition.title ?? null,
|
|
48
|
-
alt: md.alt,
|
|
49
|
-
children: md.children,
|
|
50
|
-
}, definitions);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
95
|
+
const md = resolved(node, definitions);
|
|
53
96
|
const out = {};
|
|
54
97
|
for (const key of Object.keys(md).sort()) {
|
|
55
98
|
if (STYLE_ONLY.has(key))
|
|
56
99
|
continue;
|
|
57
|
-
out[key] = meaning(md[key], definitions);
|
|
100
|
+
out[key] = key === 'children' && INLINE_PARENTS.has(md.type) ? runs(md.children ?? [], definitions) : meaning(md[key], definitions);
|
|
58
101
|
}
|
|
59
102
|
if ((md.type === 'link' || md.type === 'image') && out.title === undefined)
|
|
60
103
|
out.title = null;
|
|
104
|
+
if (md.type === 'table') {
|
|
105
|
+
const rows = out.children;
|
|
106
|
+
const width = Math.max(0, ...rows.map((row) => row.children.length));
|
|
107
|
+
for (const row of rows) {
|
|
108
|
+
while (row.children.length < width)
|
|
109
|
+
row.children.push({ children: [], type: 'tableCell' });
|
|
110
|
+
}
|
|
111
|
+
const align = out.align ?? [];
|
|
112
|
+
out.align = Array.from({ length: width }, (_value, index) => align[index] ?? null);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
// Everything in a block a reader could lose: its text (spacing aside),
|
|
117
|
+
// code, raw HTML, link and image addresses, image alt text, rules and
|
|
118
|
+
// task boxes. Not how any of it is marked or laid out.
|
|
119
|
+
function content(node, definitions) {
|
|
120
|
+
const md = resolved(node, definitions);
|
|
121
|
+
let out = '';
|
|
122
|
+
if (typeof md.value === 'string')
|
|
123
|
+
out += md.value.replace(/\s+/g, '');
|
|
124
|
+
if (md.type === 'link' || md.type === 'image')
|
|
125
|
+
out += `<${String(md.url)}>`;
|
|
126
|
+
if (md.type === 'image' || md.type === 'imageReference')
|
|
127
|
+
out += `![${String(md.alt ?? '')}]`;
|
|
128
|
+
if (md.type === 'thematicBreak')
|
|
129
|
+
out += '<hr>';
|
|
130
|
+
if (md.type === 'listItem' && typeof md.checked === 'boolean')
|
|
131
|
+
out += md.checked ? '[x]' : '[ ]';
|
|
132
|
+
for (const child of md.children ?? [])
|
|
133
|
+
out += content(child, definitions);
|
|
61
134
|
return out;
|
|
62
135
|
}
|
|
63
136
|
function blocks(markdown) {
|
|
@@ -76,6 +149,7 @@ function blocks(markdown) {
|
|
|
76
149
|
end,
|
|
77
150
|
source: markdown.slice(start, end),
|
|
78
151
|
key: JSON.stringify(meaning(child, definitions)),
|
|
152
|
+
content: content(child, definitions),
|
|
79
153
|
};
|
|
80
154
|
});
|
|
81
155
|
}
|
|
@@ -104,6 +178,33 @@ function match(from, to) {
|
|
|
104
178
|
}
|
|
105
179
|
return pairs;
|
|
106
180
|
}
|
|
181
|
+
// The original blocks the editor shows: each matched one, and each run of
|
|
182
|
+
// unmatched ones between two matches that the editor shows its own way,
|
|
183
|
+
// with nothing lost, as the unmatched blocks it has between the same two.
|
|
184
|
+
// Those are written as the editor has them; a definition never is.
|
|
185
|
+
function shownOwnWay(originals, loaded, loadedToOriginal) {
|
|
186
|
+
const shown = new Set(loadedToOriginal.values());
|
|
187
|
+
const anchors = [[-1, -1], ...[...loadedToOriginal].map(([to, from]) => [from, to]), [originals.length, loaded.length]];
|
|
188
|
+
for (let k = 1; k < anchors.length; k++) {
|
|
189
|
+
const [fromStart, toStart] = anchors[k - 1];
|
|
190
|
+
const [fromEnd, toEnd] = anchors[k];
|
|
191
|
+
const unmatched = originals
|
|
192
|
+
.map((_block, index) => index)
|
|
193
|
+
.slice(fromStart + 1, fromEnd)
|
|
194
|
+
.filter((index) => originals[index].type !== 'definition');
|
|
195
|
+
if (unmatched.length === 0)
|
|
196
|
+
continue;
|
|
197
|
+
const before = unmatched.map((index) => originals[index].content).join('');
|
|
198
|
+
const after = loaded
|
|
199
|
+
.slice(toStart + 1, toEnd)
|
|
200
|
+
.map((block) => block.content)
|
|
201
|
+
.join('');
|
|
202
|
+
if (before === after)
|
|
203
|
+
for (const index of unmatched)
|
|
204
|
+
shown.add(index);
|
|
205
|
+
}
|
|
206
|
+
return shown;
|
|
207
|
+
}
|
|
107
208
|
export class MarkdownMergeError extends Error {
|
|
108
209
|
constructor() {
|
|
109
210
|
super("This text can't be saved without changing parts you didn't edit. Undo the last change, or edit it in the form.");
|
|
@@ -139,9 +240,9 @@ export function mergeMarkdown(original, baseline, current) {
|
|
|
139
240
|
const after = sources.slice(index + 1).find((value) => value !== undefined) ?? originals.length;
|
|
140
241
|
return { written: block, at: (before + after) / 2 };
|
|
141
242
|
});
|
|
142
|
-
// Blocks the editor never showed (definitions
|
|
143
|
-
// before them in the original.
|
|
144
|
-
const shown =
|
|
243
|
+
// Blocks the editor never showed (definitions, and any block it dropped
|
|
244
|
+
// part of): after whatever came before them in the original.
|
|
245
|
+
const shown = shownOwnWay(originals, loaded, loadedToOriginal);
|
|
145
246
|
originals.forEach((_block, index) => {
|
|
146
247
|
if (shown.has(index))
|
|
147
248
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.16.
|
|
3
|
+
"version": "3.16.2",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|