@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, controls: editableId, onFormat: runFormat })), _jsx("div", { ref: rootRef, className: cn('min-h-24 px-2.5 py-2',
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', '[&_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',
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 &nbsp;.
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, and a reference link resolved to the link it stands for.
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): after whatever came
143
- // before them in the original.
144
- const shown = new Set(loadedToOriginal.values());
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.1",
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": {