@hyperframes/studio 0.7.106 → 0.7.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/assets/{hyperframes-player-CvVcx_CV.js → hyperframes-player-BLrfwq5o.js} +1 -1
  2. package/dist/assets/{index-CRzCCuPH.js → index-BA9yhzfR.js} +1 -1
  3. package/dist/assets/{index-BbYg_isc.js → index-BSBk5srs.js} +1 -1
  4. package/dist/assets/{index-DerI0ikN.js → index-dF-CmLZu.js} +218 -218
  5. package/dist/assets/index-zQ4JFwwB.css +1 -0
  6. package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
  7. package/dist/chunk-AZYHQC6V.js.map +1 -0
  8. package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5243 -3933
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/components/EditorShell.tsx +0 -2
  15. package/src/components/StudioErrorBoundary.test.tsx +97 -0
  16. package/src/components/StudioErrorBoundary.tsx +7 -0
  17. package/src/components/StudioOverlays.tsx +15 -13
  18. package/src/components/editor/DomEditOverlay.tsx +36 -18
  19. package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
  20. package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
  21. package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
  22. package/src/components/editor/InlineTextToolbar.tsx +281 -0
  23. package/src/components/editor/OffCanvasIndicators.tsx +38 -0
  24. package/src/components/editor/domEditInlineText.test.ts +86 -0
  25. package/src/components/editor/domEditInlineText.ts +99 -0
  26. package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
  27. package/src/components/editor/domEditing.ts +1 -0
  28. package/src/components/editor/domEditingLayers.ts +7 -6
  29. package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
  30. package/src/components/editor/inlineTextStyleRange.ts +593 -0
  31. package/src/components/editor/inlineTextStyleRead.ts +47 -0
  32. package/src/components/editor/useDomEditNudge.ts +2 -2
  33. package/src/components/editor/useInlineTextEditing.tsx +119 -0
  34. package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
  35. package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
  36. package/src/components/feedback/feedbackTrigger.test.ts +168 -0
  37. package/src/components/feedback/feedbackTrigger.ts +269 -0
  38. package/src/components/feedback/projectProvenance.test.ts +120 -0
  39. package/src/components/feedback/projectProvenance.ts +83 -0
  40. package/src/components/renders/useRenderQueue.ts +62 -4
  41. package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
  42. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
  43. package/src/contexts/DomEditContext.tsx +4 -0
  44. package/src/hooks/domEditPersistFailure.ts +0 -7
  45. package/src/hooks/useAppHotkeys.ts +4 -4
  46. package/src/hooks/useDomEditCommits.test.tsx +103 -21
  47. package/src/hooks/useDomEditCommits.ts +2 -0
  48. package/src/hooks/useDomEditSession.ts +2 -0
  49. package/src/hooks/useDomEditTextCommits.ts +110 -11
  50. package/src/hooks/useFileTree.ts +8 -3
  51. package/src/hooks/useInlineTextEdit.test.tsx +555 -0
  52. package/src/hooks/useInlineTextEdit.ts +320 -0
  53. package/src/player/lib/playbackShortcuts.ts +5 -4
  54. package/src/telemetry/breadcrumbs.test.ts +68 -0
  55. package/src/telemetry/breadcrumbs.ts +70 -0
  56. package/src/telemetry/client.ts +5 -0
  57. package/src/telemetry/events.ts +84 -3
  58. package/src/utils/sourcePatcher.ts +5 -1
  59. package/src/utils/studioHelpers.ts +2 -5
  60. package/src/utils/timelineDiscovery.ts +3 -24
  61. package/src/utils/typingTarget.test.ts +54 -0
  62. package/src/utils/typingTarget.ts +43 -0
  63. package/dist/assets/index-tBPidglp.css +0 -1
  64. package/dist/chunk-OBAG3GWK.js.map +0 -1
  65. package/src/components/StudioFeedbackBar.tsx +0 -217
  66. /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
@@ -0,0 +1,593 @@
1
+ /**
2
+ * Styling a run of characters inside an element being edited in place.
3
+ *
4
+ * Not `document.execCommand`. That is deprecated, and what it emits varies by
5
+ * browser between `<font>`, a class, and an inline style depending on
6
+ * `styleWithCSS`. The output of this goes into the user's composition file, so
7
+ * it has to be one predictable shape, and the shape is a `<span>` carrying an
8
+ * inline style.
9
+ *
10
+ * Not DOM range surgery either, which is the obvious way and the wrong one.
11
+ * Wrapping a range in a span is three lines and then every interesting case is
12
+ * a special case: recolouring nests spans that shadow each other, removing a
13
+ * style cannot reach the ancestor that set it, and styling across an existing
14
+ * run's boundary has to split it. Each fix is a new branch and the branches
15
+ * interact.
16
+ *
17
+ * So the element is read into a flat list of styled runs, the styling is
18
+ * applied to a span of characters in that list, and the element is rebuilt
19
+ * from it. Replacing, removing, splitting and merging all stop being cases:
20
+ * the rebuild emits one span per distinct run and cannot nest or duplicate,
21
+ * whatever was there before. Text elements in a composition are a headline or
22
+ * a sentence, so reading and rebuilding one is not a cost worth avoiding.
23
+ */
24
+
25
+ import {
26
+ isRichTextFormattingAttribute,
27
+ isRichTextFormattingStyle,
28
+ isRichTextFormattingTag,
29
+ } from "@hyperframes/core/rich-text-sanitize";
30
+
31
+ /** One stretch of characters that are all styled the same way. */
32
+ interface StyledRun {
33
+ text: string;
34
+ style: Record<string, string>;
35
+ /**
36
+ * The child element these characters came out of, when they came out of one.
37
+ *
38
+ * Carried because an element's children are not always anonymous formatting:
39
+ * the design panel keeps them as text layers and tracks each by an attribute
40
+ * on it. Rebuilding from style alone emitted fresh, bare spans, which threw
41
+ * that identity away — after colouring a single word the panel could no
42
+ * longer match a layer to its source, so every edit it offered failed to
43
+ * save. The rebuild puts the identity back on the run that still holds it.
44
+ */
45
+ origin: Element | null;
46
+ /**
47
+ * That identity as a value, so two runs can be compared without comparing
48
+ * the nodes they came from. A child with nothing on it but a style has no
49
+ * identity to lose, and merges with its neighbour exactly as before.
50
+ */
51
+ identity: string;
52
+ }
53
+
54
+ export type InlineStyleDelta = Record<string, string | null>;
55
+
56
+ /**
57
+ * Tags that mean a style. They are read as styling and written back as spans,
58
+ * so there is one representation to reason about instead of two that have to
59
+ * agree. Rendering is unchanged; the markup for an edited element is not.
60
+ */
61
+ const TAG_STYLES: Record<string, Record<string, string>> = {
62
+ B: { "font-weight": "700" },
63
+ STRONG: { "font-weight": "700" },
64
+ I: { "font-style": "italic" },
65
+ EM: { "font-style": "italic" },
66
+ U: { "text-decoration-line": "underline" },
67
+ };
68
+
69
+ /**
70
+ * Stands in for a `<br>` while the element is a flat string, so a break counts
71
+ * as one character and offsets survive the rebuild.
72
+ *
73
+ * Not a newline. Compositions are written across lines, so an element's text
74
+ * routinely contains real newlines that are only source formatting, and using
75
+ * one as the marker turned every one of them into a visible line break the
76
+ * first time a word was styled. A NUL never appears: the HTML parser replaces
77
+ * it with U+FFFD, so no document can contain one.
78
+ */
79
+ const BREAK = "\u0000";
80
+
81
+ /** Apply `style` to the characters the range covers, then rebuild the element. */
82
+ export function applyInlineStyle(range: Range, style: InlineStyleDelta): void {
83
+ if (range.collapsed) return;
84
+ // Resolved from where the selection starts, not from where it and its end
85
+ // happen to meet. A selection dragged past the element's edge meets its end
86
+ // at an ancestor, and taking that as the host would rebuild the ancestor:
87
+ // every sibling element inside it flattened into text by an edit that was
88
+ // meant to colour a word.
89
+ const host = editingHost(range.startContainer);
90
+ if (!host || !holdsBothEnds(host, range)) return;
91
+
92
+ const runs = readRuns(host);
93
+ const span = graphemeBounds(
94
+ runs,
95
+ offsetOf(host, range.startContainer, range.startOffset),
96
+ offsetOf(host, range.endContainer, range.endOffset),
97
+ );
98
+ if (!span) return;
99
+
100
+ const next = restyle(runs, span.start, span.end, style);
101
+ render(host, next);
102
+ reconcileFillColors(host);
103
+ selectRange(host, span.start, span.end);
104
+ }
105
+
106
+ /**
107
+ * Whether something above the run is painting the glyphs a different colour.
108
+ *
109
+ * `-webkit-text-fill-color` inherits and paints the glyph fill, so an ancestor
110
+ * that sets it wins over any `color` a descendant sets. A composition doing so
111
+ * is not doing anything wrong, but from the editor it reads as the colour
112
+ * picker being broken: the run is saved with the colour asked for and renders
113
+ * in someone else's.
114
+ *
115
+ * Asked of the rendered span rather than worked out from the stylesheet. Its
116
+ * own `color` is set, so its computed colour IS the one that was asked for, and
117
+ * if the fill differs from it then something else is painting it. Both sides
118
+ * come from the same computed style, so neither notation nor inheritance has to
119
+ * be untangled by hand.
120
+ */
121
+ function reconcileFillColors(host: Element): void {
122
+ const view = host.ownerDocument.defaultView;
123
+ if (!view?.getComputedStyle) return;
124
+ for (const span of host.querySelectorAll<HTMLElement>("span")) {
125
+ if (!span.style.color) continue;
126
+ // A generated mirror repeats the run's colour. Remove that before asking
127
+ // what would paint the run without it; an authored, different fill stays
128
+ // in place long enough to be detected as the overpaint it is.
129
+ const existingFill = span.style.getPropertyValue("-webkit-text-fill-color");
130
+ if (existingFill === span.style.color) {
131
+ span.style.removeProperty("-webkit-text-fill-color");
132
+ }
133
+ const computed = view.getComputedStyle(span) as CSSStyleDeclaration & {
134
+ webkitTextFillColor?: string;
135
+ };
136
+ const fill = computed.webkitTextFillColor;
137
+ if (!fill || !computed.color) continue;
138
+ if (fill !== computed.color) {
139
+ span.style.setProperty("-webkit-text-fill-color", span.style.color);
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The offsets to style, widened so they never fall inside a character.
146
+ *
147
+ * Selection offsets count UTF-16 units, while one visible character can be a
148
+ * surrogate pair, combining sequence, flag, modifier sequence, or a family
149
+ * joined by zero-width joiners. Splitting any of those across spans corrupts
150
+ * what the user selected even when every individual code point remains valid.
151
+ */
152
+ function graphemeBounds(
153
+ runs: StyledRun[],
154
+ start: number | null,
155
+ end: number | null,
156
+ ): { start: number; end: number } | null {
157
+ if (start === null || end === null || start >= end) return null;
158
+ const text = runs.map((run) => run.text).join("");
159
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
160
+ const boundaries = [0, ...Array.from(segmenter.segment(text), atEndOfSegment)];
161
+ return {
162
+ start: boundaryAtOrBefore(boundaries, start),
163
+ end: boundaries.find((boundary) => boundary >= end) ?? end,
164
+ };
165
+ }
166
+
167
+ function atEndOfSegment({ index, segment }: Intl.SegmentData): number {
168
+ return index + segment.length;
169
+ }
170
+
171
+ function boundaryAtOrBefore(boundaries: number[], offset: number): number {
172
+ let previous = offset;
173
+ for (const boundary of boundaries) {
174
+ if (boundary > offset) return previous;
175
+ previous = boundary;
176
+ }
177
+ return previous;
178
+ }
179
+
180
+ /** Whether the whole selection lives inside this element. */
181
+ function holdsBothEnds(host: Element, range: Range): boolean {
182
+ return host.contains(range.startContainer) && host.contains(range.endContainer);
183
+ }
184
+
185
+ export interface InlineStyleChar {
186
+ char: string;
187
+ style: Readonly<Record<string, string>>;
188
+ }
189
+
190
+ /** Every character the range covers with its style, or null when it covers none. */
191
+ export function readCoveredInlineStyleChars(range: Range): readonly InlineStyleChar[] | null {
192
+ const host = editingHost(range.startContainer);
193
+ if (!host || !holdsBothEnds(host, range)) return null;
194
+ const start = offsetOf(host, range.startContainer, range.startOffset);
195
+ const end = offsetOf(host, range.endContainer, range.endOffset);
196
+ if (start === null || end === null) return null;
197
+ const collapsed = start === end;
198
+ const covered = charRuns(readRuns(host))
199
+ .slice(collapsed ? Math.max(0, start - 1) : start, collapsed ? Math.max(1, start) : end)
200
+ .map((entry) => ({ char: entry.char, style: entry.style }));
201
+ return covered.length > 0 ? covered : null;
202
+ }
203
+
204
+ /**
205
+ * The element the caret is in: the one made editable, never a span inside it.
206
+ *
207
+ * Reading the nearest element instead would rebuild only the run the caret
208
+ * happened to land in, which is how a recolour ends up nested inside the
209
+ * colour it was meant to replace.
210
+ */
211
+ function editingHost(node: Node): HTMLElement | null {
212
+ let element = (node.nodeType === 1 ? node : node.parentElement) as HTMLElement | null;
213
+ const editable = element?.closest<HTMLElement>("[contenteditable]");
214
+ if (editable) {
215
+ return editable.getAttribute("contenteditable")?.toLowerCase() === "false" ? null : editable;
216
+ }
217
+ // No open edit, so climb out of the formatting to the element that owns it.
218
+ while (element?.parentElement && isRichTextFormattingTag(element.tagName)) {
219
+ element = element.parentElement;
220
+ }
221
+ return element;
222
+ }
223
+
224
+ /** Read the element as a flat list of runs, in document order. */
225
+ interface RunWalkFrame {
226
+ node: Node;
227
+ inherited: Record<string, string>;
228
+ origin: Element | null;
229
+ }
230
+
231
+ function readRuns(host: Element): StyledRun[] {
232
+ const runs: StyledRun[] = [];
233
+ const pending: RunWalkFrame[] = Array.from(
234
+ host.childNodes,
235
+ (node): RunWalkFrame => ({ node, inherited: {}, origin: null }),
236
+ ).reverse();
237
+ for (let frame = pending.pop(); frame; frame = pending.pop()) {
238
+ pending.push(...visitRunFrame(frame, runs));
239
+ }
240
+ return runs;
241
+ }
242
+
243
+ function visitRunFrame(frame: RunWalkFrame, runs: StyledRun[]): RunWalkFrame[] {
244
+ const { node, inherited, origin } = frame;
245
+ if (node.nodeType === 3) {
246
+ const text = node.textContent ?? "";
247
+ if (text) runs.push(styledRun(text, inherited, origin));
248
+ return [];
249
+ }
250
+ if (!isStyleElement(node)) return [];
251
+ const element = node;
252
+ if (element.tagName === "BR") {
253
+ runs.push(styledRun(BREAK, inherited, origin));
254
+ return [];
255
+ }
256
+ return childRunFrames(element, inherited, origin);
257
+ }
258
+
259
+ function isStyleElement(node: Node): node is HTMLElement {
260
+ return node.nodeType === 1 && "style" in node;
261
+ }
262
+
263
+ function styledRun(text: string, style: Record<string, string>, origin: Element | null): StyledRun {
264
+ return { text, style, origin, identity: identityOf(origin) };
265
+ }
266
+
267
+ function childRunFrames(
268
+ element: HTMLElement,
269
+ inherited: Record<string, string>,
270
+ origin: Element | null,
271
+ ): RunWalkFrame[] {
272
+ const formatting = isRichTextFormattingTag(element.tagName);
273
+ const nextInherited = formatting
274
+ ? { ...inherited, ...TAG_STYLES[element.tagName], ...ownStyle(element) }
275
+ : inherited;
276
+ // The outermost formatting child that carries anything is the one the panel
277
+ // knows as a layer. Tags the sanitizer unwraps cannot own a layer.
278
+ const nextOrigin =
279
+ formatting && !origin && preservedAttributes(element).size > 0 ? element : origin;
280
+ return Array.from(
281
+ element.childNodes,
282
+ (node): RunWalkFrame => ({ node, inherited: nextInherited, origin: nextOrigin }),
283
+ ).reverse();
284
+ }
285
+
286
+ /** A child's identity as a comparable string, empty when it has none. */
287
+ function identityOf(element: Element | null): string {
288
+ if (!element) return "";
289
+ return JSON.stringify([...preservedAttributes(element)].sort(([a], [b]) => (a < b ? -1 : 1)));
290
+ }
291
+
292
+ function ownStyle(element: HTMLElement): Record<string, string> {
293
+ const style: Record<string, string> = {};
294
+ for (let index = 0; index < element.style.length; index += 1) {
295
+ const property = element.style.item(index);
296
+ if (!property) continue;
297
+ const value = element.style.getPropertyValue(property);
298
+ if (isRichTextFormattingStyle(property, value)) style[property] = value;
299
+ }
300
+ return style;
301
+ }
302
+
303
+ /** One entry per character, which is the easiest thing to slice and compare. */
304
+ function charRuns(runs: StyledRun[]): Array<Omit<StyledRun, "text"> & { char: string }> {
305
+ const perChar: Array<Omit<StyledRun, "text"> & { char: string }> = [];
306
+ for (const run of runs) {
307
+ // By UTF-16 unit, not code point: `restyle` indexes this list with selection
308
+ // offsets, which count units, so an emoji has to stay two entries long.
309
+ for (let index = 0; index < run.text.length; index += 1) {
310
+ perChar.push({
311
+ char: run.text[index] ?? "",
312
+ style: run.style,
313
+ origin: run.origin,
314
+ identity: run.identity,
315
+ });
316
+ }
317
+ }
318
+ return perChar;
319
+ }
320
+
321
+ /** Apply the delta to `[start, end)` and hand back runs covering the element. */
322
+ function restyle(
323
+ runs: StyledRun[],
324
+ start: number,
325
+ end: number,
326
+ delta: InlineStyleDelta,
327
+ ): StyledRun[] {
328
+ const text = runs.map((run) => run.text).join("");
329
+ const perChar = charRuns(runs);
330
+ const next: StyledRun[] = [];
331
+ // Indexed by UTF-16 unit, not by code point: `perChar`, `start` and `end` all
332
+ // count units, and spreading the string would count a surrogate pair once and
333
+ // slide every index after an emoji.
334
+ for (let index = 0; index < text.length; index += 1) {
335
+ appendChar(
336
+ next,
337
+ text[index] ?? "",
338
+ charAfter(perChar[index], index >= start && index < end, delta),
339
+ );
340
+ }
341
+ return next;
342
+ }
343
+
344
+ /** What one character is styled with once the delta has been applied to it. */
345
+ function charAfter(
346
+ at: Omit<StyledRun, "text"> | undefined,
347
+ inside: boolean,
348
+ delta: InlineStyleDelta,
349
+ ): Omit<StyledRun, "text"> {
350
+ const style = at?.style ?? {};
351
+ return {
352
+ style: inside ? withDelta(style, delta) : style,
353
+ origin: at?.origin ?? null,
354
+ identity: at?.identity ?? "",
355
+ };
356
+ }
357
+
358
+ /**
359
+ * One character onto the run list, merged into the run before it when they
360
+ * belong together.
361
+ *
362
+ * Merged as it is built, so equal neighbours never become two spans. Two that
363
+ * the design panel tracks as separate layers stay apart even when they now look
364
+ * identical, because merging them deletes one of them.
365
+ */
366
+ function appendChar(runs: StyledRun[], char: string, at: Omit<StyledRun, "text">): void {
367
+ const last = runs[runs.length - 1];
368
+ if (last && last.identity === at.identity && sameStyle(last.style, at.style)) {
369
+ last.text += char;
370
+ return;
371
+ }
372
+ runs.push({ text: char, ...at });
373
+ }
374
+
375
+ function withDelta(style: Record<string, string>, delta: InlineStyleDelta): Record<string, string> {
376
+ const next = { ...style };
377
+ for (const [property, value] of Object.entries(delta)) {
378
+ if (value === null) delete next[property];
379
+ else next[property] = value;
380
+ }
381
+ return next;
382
+ }
383
+
384
+ function sameStyle(a: Record<string, string>, b: Record<string, string>): boolean {
385
+ return styleKey(a) === styleKey(b);
386
+ }
387
+
388
+ /** Sorted, so two runs styled the same way compare equal whatever the order. */
389
+ function styleKey(style: Record<string, string>): string {
390
+ return Object.keys(style)
391
+ .sort()
392
+ .map((property) => `${property}: ${style[property]}`)
393
+ .join("; ");
394
+ }
395
+
396
+ /** Rebuild the element: bare text where there is no styling, one span where there is. */
397
+ function render(host: Element, runs: StyledRun[]): void {
398
+ const doc = host.ownerDocument;
399
+ const nodes = runNodes(doc, runs);
400
+ host.replaceChildren();
401
+ if (nodes.length > 1 && laysOutItsChildren(host)) {
402
+ // Wrapped, because in a flex or grid container every child is an item to
403
+ // be laid out. Text that was one anonymous item becomes several boxes the
404
+ // moment a word inside it is coloured, and the element visibly reflows:
405
+ // centring, wrapping and order all change under an edit that was only ever
406
+ // meant to change a colour. One wrapper keeps it a single item, and the
407
+ // runs inside it stay inline text.
408
+ const wrapper = doc.createElement("span");
409
+ wrapper.append(...nodes);
410
+ host.append(wrapper);
411
+ return;
412
+ }
413
+ host.append(...nodes);
414
+ }
415
+
416
+ function runNodes(doc: Document, runs: StyledRun[]): Node[] {
417
+ const nodes: Node[] = [];
418
+ // One span per origin keeps its attributes: an identity that appeared twice
419
+ // would be two layers claiming to be the same one. A run split off from an
420
+ // origin is a new layer and is written as one.
421
+ const claimed = new Set<Element>();
422
+ for (const run of runs) {
423
+ const carried =
424
+ run.origin && !claimed.has(run.origin) ? preservedAttributes(run.origin) : new Map();
425
+ if (carried.size > 0 && run.origin) claimed.add(run.origin);
426
+ nodes.push(...runNode(doc, run, carried));
427
+ }
428
+ return nodes;
429
+ }
430
+
431
+ /**
432
+ * One run's nodes: its line breaks as `<br>`, and its text as bare text when it
433
+ * has nothing to carry or a span when it has. The identity goes on the first
434
+ * piece only, so a run broken across lines does not claim it twice.
435
+ */
436
+ function runNode(doc: Document, run: StyledRun, carried: Map<string, string>): Node[] {
437
+ const key = styleKey(run.style);
438
+ const nodes: Node[] = [];
439
+ for (const [index, piece] of run.text.split(BREAK).entries()) {
440
+ if (index > 0) nodes.push(doc.createElement("br"));
441
+ if (!piece) continue;
442
+ if (!key && carried.size === 0) {
443
+ nodes.push(doc.createTextNode(piece));
444
+ continue;
445
+ }
446
+ const span = doc.createElement("span");
447
+ for (const [name, value] of carried) span.setAttribute(name, value);
448
+ if (key) span.setAttribute("style", key);
449
+ span.textContent = piece;
450
+ nodes.push(span);
451
+ carried.clear();
452
+ }
453
+ return nodes;
454
+ }
455
+
456
+ /**
457
+ * The one attribute the writer assigns rather than the author.
458
+ *
459
+ * Left out on purpose. It is stamped onto every element on the way to disk, so
460
+ * carrying it preserves nothing — and it made the wrapper this rebuild adds
461
+ * inside a flex container look like a layer as soon as the file had been saved
462
+ * once, which put it back to shadowing the real layers underneath it.
463
+ */
464
+ const DERIVED_ATTR = "data-hf-id";
465
+
466
+ /**
467
+ * What a child carries besides its styling: the identity the design panel
468
+ * tracks it by. Its style is not copied — that is what the run holds, already
469
+ * merged with whatever the edit changed.
470
+ */
471
+ function preservedAttributes(element: Element): Map<string, string> {
472
+ const kept = new Map<string, string>();
473
+ for (const name of element.getAttributeNames()) {
474
+ if (name === "style" || name === DERIVED_ATTR) continue;
475
+ const value = element.getAttribute(name) ?? "";
476
+ if (isRichTextFormattingAttribute(name, value)) kept.set(name, value);
477
+ }
478
+ return kept;
479
+ }
480
+
481
+ /** Displays whose children are boxes it positions, rather than text it flows. */
482
+ const LAYS_OUT_CHILDREN = new Set([
483
+ "flex",
484
+ "inline-flex",
485
+ "grid",
486
+ "inline-grid",
487
+ // How line clamping is written, and it boxes its children like flex.
488
+ "-webkit-box",
489
+ "-webkit-inline-box",
490
+ ]);
491
+
492
+ function laysOutItsChildren(host: Element): boolean {
493
+ const view = host.ownerDocument.defaultView;
494
+ if (!view) return false;
495
+ return LAYS_OUT_CHILDREN.has(view.getComputedStyle(host).display);
496
+ }
497
+
498
+ /** Where a DOM position falls, counted in characters from the element's start. */
499
+ function offsetOf(host: Element, container: Node, containerOffset: number): number | null {
500
+ // A position between children, expressed as a child index.
501
+ if (container === host) {
502
+ return Array.from(host.childNodes)
503
+ .slice(0, containerOffset)
504
+ .reduce((count, child) => count + subtreeCharLength(child), 0);
505
+ }
506
+ let count = 0;
507
+ const walker = host.ownerDocument.createTreeWalker(
508
+ host,
509
+ NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
510
+ );
511
+ let node = walker.nextNode();
512
+ while (node) {
513
+ if (node === container) return count + containerOffset;
514
+ count += charLength(node);
515
+ node = walker.nextNode();
516
+ }
517
+ return null;
518
+ }
519
+
520
+ /** How many character positions a node occupies itself: its own text, or one
521
+ * for a break. An element contributes nothing; the walk visits its text. */
522
+ function charLength(node: Node): number {
523
+ if (node.nodeType === 3) return (node.textContent ?? "").length;
524
+ return nodeName(node) === "BR" ? 1 : 0;
525
+ }
526
+
527
+ /** The same count for a child and everything inside it, for a position given as
528
+ * a child index rather than a place in a text node. */
529
+ function subtreeCharLength(node: Node): number {
530
+ let count = 0;
531
+ const pending = [node];
532
+ for (let current = pending.pop(); current; current = pending.pop()) {
533
+ count += charLength(current);
534
+ pending.push(...Array.from(current.childNodes));
535
+ }
536
+ return count;
537
+ }
538
+
539
+ function nodeName(node: Node): string {
540
+ return node.nodeType === 1 ? (node as Element).tagName : "";
541
+ }
542
+
543
+ /** Put the selection back over the characters that were just styled. */
544
+ function selectRange(host: Element, start: number, end: number): void {
545
+ const doc = host.ownerDocument;
546
+ const selection = doc.defaultView?.getSelection();
547
+ const from = positionAt(host, start);
548
+ const to = positionAt(host, end);
549
+ if (!selection || !from || !to) return;
550
+ const range = doc.createRange();
551
+ range.setStart(from.node, from.offset);
552
+ range.setEnd(to.node, to.offset);
553
+ selection.removeAllRanges();
554
+ selection.addRange(range);
555
+ }
556
+
557
+ /**
558
+ * The DOM position a character offset lands on, after a rebuild.
559
+ *
560
+ * Counts a line break as one position, because everything that produced the
561
+ * offset did. This walked text nodes only, so in an element containing a `<br>`
562
+ * it landed one character early for every break before the offset — and the
563
+ * selection it put back was not the one that had just been styled.
564
+ *
565
+ * Which was invisible until a control fired more than once. The colour input
566
+ * does: a native picker reports every sample while the pointer moves in it, and
567
+ * each one restyled a selection that had walked one character further along
568
+ * than the last. Choosing a colour for three characters painted a different
569
+ * shade onto each character of the whole line.
570
+ */
571
+ function positionAt(host: Element, offset: number): { node: Node; offset: number } | null {
572
+ let count = 0;
573
+ const walker = host.ownerDocument.createTreeWalker(
574
+ host,
575
+ NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
576
+ );
577
+ let node = walker.nextNode();
578
+ let last: Node | null = null;
579
+ while (node) {
580
+ if (node.nodeType !== 3) {
581
+ if (nodeName(node) === "BR") count += 1;
582
+ node = walker.nextNode();
583
+ continue;
584
+ }
585
+ const length = (node.textContent ?? "").length;
586
+ if (count + length >= offset) return { node, offset: offset - count };
587
+ count += length;
588
+ last = node;
589
+ node = walker.nextNode();
590
+ }
591
+ if (last) return { node: last, offset: (last.textContent ?? "").length };
592
+ return { node: host, offset: 0 };
593
+ }
@@ -0,0 +1,47 @@
1
+ import { readCoveredInlineStyleChars } from "./inlineTextStyleRange";
2
+
3
+ /**
4
+ * What the range is styled with, for a toolbar that has to open showing the
5
+ * truth rather than a default. Reports a property only when the whole range
6
+ * agrees about it, which is what a control can honestly display.
7
+ */
8
+ export function readInlineStyle(range: Range, properties: string[]): Record<string, string> {
9
+ const chars = readCoveredInlineStyleChars(range);
10
+ if (!chars) return {};
11
+
12
+ const styles: Record<string, string> = {};
13
+ for (const property of properties) {
14
+ const first: string | undefined = chars[0]?.style[property];
15
+ if (first === undefined) continue;
16
+ if (chars.every(({ style }) => style[property] === first)) styles[property] = first;
17
+ }
18
+ return styles;
19
+ }
20
+
21
+ /**
22
+ * The distinct values one property takes across the range, in document order:
23
+ * `["red", "lime"]`.
24
+ *
25
+ * `readInlineStyle` above answers "what is this range" and reports nothing when
26
+ * the range disagrees with itself — right for a toggle, which can only be on or
27
+ * off. A swatch can show more than one value at once, and showing the default
28
+ * instead reads as "this text is white" when none of it is.
29
+ */
30
+ export function readInlineStyleSpread(range: Range, property: string): string[] {
31
+ const covered = readCoveredInlineStyleChars(range);
32
+ if (!covered) return [];
33
+ const seen = new Set<string>();
34
+ const spread: string[] = [];
35
+ for (const { char, style } of covered) {
36
+ // A space paints nothing, so the colour it inherits is not a colour anyone
37
+ // can see. Counting it puts the element's own colour in the swatch for text
38
+ // that shows none of it — the stray band on a selection that happens to
39
+ // start or end next to a space.
40
+ if (!char.trim()) continue;
41
+ const value = style[property];
42
+ if (value === undefined || seen.has(value)) continue;
43
+ seen.add(value);
44
+ spread.push(value);
45
+ }
46
+ return spread;
47
+ }
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { useCallback, useEffect, useRef, type RefObject } from "react";
9
9
  import { useMountEffect } from "../../hooks/useMountEffect";
10
- import { isEditableTarget } from "../../utils/timelineDiscovery";
10
+ import { isTypingTarget } from "../../utils/typingTarget";
11
11
  import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
12
12
  import type { DomEditSelection } from "./domEditing";
13
13
  import {
@@ -130,7 +130,7 @@ function resolveSingleNudgeTarget(
130
130
  function shouldIgnoreNudgeKey(p: UseDomEditNudgeParams, event: KeyboardEvent): boolean {
131
131
  if (!p.allowCanvasMovement || event.defaultPrevented) return true;
132
132
  if (p.gestureRef.current || p.groupGestureRef.current || p.blockedMoveRef.current) return true;
133
- return isEditableTarget(event.target);
133
+ return isTypingTarget(event.target);
134
134
  }
135
135
 
136
136
  export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () => void } {