@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,86 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection, DomEditTextField } from "./domEditingTypes";
5
+
6
+ const editable = vi.hoisted(() => ({ current: true }));
7
+ vi.mock("./domEditingLayers", () => ({
8
+ isTextEditableSelection: () => editable.current,
9
+ }));
10
+
11
+ const { canEditTextInline, isDoublePress } = await import("./domEditInlineText");
12
+
13
+ function field(key: string): DomEditTextField {
14
+ return {
15
+ key,
16
+ label: key,
17
+ value: "text",
18
+ tagName: "SPAN",
19
+ attributes: [],
20
+ inlineStyles: {},
21
+ computedStyles: {},
22
+ source: "self",
23
+ };
24
+ }
25
+
26
+ function selection(partial: Partial<DomEditSelection> = {}): DomEditSelection {
27
+ return {
28
+ label: "Heading",
29
+ tagName: "H1",
30
+ isCompositionHost: false,
31
+ isInsideLockedComposition: false,
32
+ textFields: [field("self")],
33
+ ...partial,
34
+ } as DomEditSelection;
35
+ }
36
+
37
+ describe("canEditTextInline", () => {
38
+ it("allows an element the panel would let you edit text on", () => {
39
+ editable.current = true;
40
+ expect(canEditTextInline(selection())).toBe(true);
41
+ });
42
+
43
+ // The bar is the panel's bar: nothing becomes editable here that is not
44
+ // editable there.
45
+ it("refuses an element whose text the panel cannot edit either", () => {
46
+ editable.current = false;
47
+ expect(canEditTextInline(selection())).toBe(false);
48
+ });
49
+
50
+ // Editing the whole element would flatten its children into one string.
51
+ it("refuses an element with several text fields", () => {
52
+ editable.current = true;
53
+ expect(canEditTextInline(selection({ textFields: [field("a"), field("b")] }))).toBe(false);
54
+ });
55
+
56
+ it("allows an element with no separate text fields", () => {
57
+ editable.current = true;
58
+ expect(canEditTextInline(selection({ textFields: [] }))).toBe(true);
59
+ });
60
+
61
+ it("refuses the composition host, which is the document rather than copy", () => {
62
+ editable.current = true;
63
+ expect(canEditTextInline(selection({ isCompositionHost: true }))).toBe(false);
64
+ });
65
+
66
+ it("refuses anything inside a locked composition", () => {
67
+ editable.current = true;
68
+ expect(canEditTextInline(selection({ isInsideLockedComposition: true }))).toBe(false);
69
+ });
70
+
71
+ it("refuses nothing at all", () => {
72
+ editable.current = true;
73
+ expect(canEditTextInline(null)).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe("isDoublePress", () => {
78
+ it("pairs presses only when they land on the same element", () => {
79
+ const first = document.createElement("div");
80
+ const second = document.createElement("div");
81
+ const previous = { x: 10, y: 10, at: 100, element: first };
82
+
83
+ expect(isDoublePress(previous, { x: 12, y: 12, at: 200, element: first })).toBe(true);
84
+ expect(isDoublePress(previous, { x: 12, y: 12, at: 200, element: second })).toBe(false);
85
+ });
86
+ });
@@ -0,0 +1,99 @@
1
+ import { isRichTextFormattingTag } from "@hyperframes/core/rich-text-sanitize";
2
+ import type { DomEditSelection } from "./domEditingTypes";
3
+ import { isTextEditableSelection } from "./domEditingLayers";
4
+
5
+ /**
6
+ * Whether this element's text can be edited where it sits.
7
+ *
8
+ * Its own function rather than a condition inside a handler, because this is
9
+ * the rule most likely to change: it is the whole answer to "why did nothing
10
+ * happen when I double-clicked that".
11
+ *
12
+ * The bar is deliberately the same as the design panel's, plus one thing the
13
+ * panel can do that editing in place cannot. An element with several text
14
+ * fields is edited a field at a time there, and making the whole element
15
+ * editable would flatten its children into one string, so those keep the panel.
16
+ */
17
+ export function canEditTextInline(selection: DomEditSelection | null): boolean {
18
+ if (!selection) return false;
19
+ if (!isTextEditableSelection(selection)) return false;
20
+ // The composition host is the document, not a piece of copy in it.
21
+ if (selection.isCompositionHost) return false;
22
+ if (selection.isInsideLockedComposition) return false;
23
+ if (selection.textFields.length <= 1) return true;
24
+ // A styled element reports one field per run of characters, but it is still
25
+ // one piece of copy and the caret edits all of it at once.
26
+ return canEditElementTextInline(selection.element);
27
+ }
28
+
29
+ /**
30
+ * Whether this element's text can be edited in place, judged from the element
31
+ * alone.
32
+ *
33
+ * The press path cannot use the selection-shaped gate above: building a
34
+ * selection is asynchronous, and a press has to decide now whether it is a
35
+ * text edit or the start of a drag. This asks the same question of the DOM.
36
+ *
37
+ * A structural child keeps an element out: those are separate text fields, the
38
+ * panel edits them one at a time, and making the whole element editable would
39
+ * flatten them into a single string.
40
+ *
41
+ * A formatting child does not. Styling a run of characters puts a span inside
42
+ * the element, so a rule of "no element children" would have let the editor
43
+ * lock every element it had ever styled out of itself, permanently, on the
44
+ * first colour change. What counts as formatting is the sanitiser's allowlist,
45
+ * so the editor and the thing that writes the file agree on it.
46
+ */
47
+ export function canEditElementTextInline(element: HTMLElement | null): boolean {
48
+ if (!element) return false;
49
+ const tag = element.tagName;
50
+ if (tag === "BODY" || tag === "HTML") return false;
51
+ if (!hasOnlyFormattingChildren(element)) return false;
52
+ if (element.isContentEditable) return false;
53
+ return (element.textContent ?? "").trim().length > 0;
54
+ }
55
+
56
+ function hasOnlyFormattingChildren(element: HTMLElement): boolean {
57
+ const HTMLElementClass = element.ownerDocument.defaultView?.HTMLElement;
58
+ if (!HTMLElementClass) return false;
59
+ for (const child of Array.from(element.children)) {
60
+ if (!isRichTextFormattingTag(child.tagName)) return false;
61
+ if (!(child instanceof HTMLElementClass)) return false;
62
+ // Formatting nests, and a structural child hidden inside a span is still
63
+ // structural.
64
+ if (!hasOnlyFormattingChildren(child)) return false;
65
+ }
66
+ return true;
67
+ }
68
+
69
+ /** Where and when a press landed, for recognising the next one as a pair. */
70
+ export interface PressMark {
71
+ x: number;
72
+ y: number;
73
+ at: number;
74
+ element: HTMLElement | null;
75
+ }
76
+
77
+ /** Long enough to be deliberate, short enough not to catch two separate clicks. */
78
+ const DOUBLE_PRESS_MS = 450;
79
+ /** A double press is two presses in the same place, not a tiny drag. */
80
+ const DOUBLE_PRESS_SLOP_PX = 6;
81
+
82
+ /**
83
+ * Whether this press pairs with the last one into a double press.
84
+ *
85
+ * Studio cannot use `dblclick` or a click count for this. The selection box
86
+ * takes pointer capture on the first press and prevents its default, which
87
+ * suppresses the compatibility mouse events and stops the browser pairing the
88
+ * two presses at all: no `dblclick` is dispatched, and `detail` stays 1.
89
+ */
90
+ export function isDoublePress(previous: PressMark | null, next: PressMark): boolean {
91
+ if (!previous) return false;
92
+ return (
93
+ next.element !== null &&
94
+ previous.element === next.element &&
95
+ next.at - previous.at <= DOUBLE_PRESS_MS &&
96
+ Math.abs(next.x - previous.x) <= DOUBLE_PRESS_SLOP_PX &&
97
+ Math.abs(next.y - previous.y) <= DOUBLE_PRESS_SLOP_PX
98
+ );
99
+ }
@@ -0,0 +1,59 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { canEditElementTextInline } from "./domEditInlineText";
5
+
6
+ afterEach(() => {
7
+ document.body.innerHTML = "";
8
+ });
9
+
10
+ function mount(html: string): HTMLElement {
11
+ document.body.innerHTML = html;
12
+ return document.body.firstElementChild as HTMLElement;
13
+ }
14
+
15
+ describe("canEditElementTextInline", () => {
16
+ it("opens a plain piece of copy", () => {
17
+ expect(canEditElementTextInline(mount("<h1>hello world</h1>"))).toBe(true);
18
+ });
19
+
20
+ // The trap this exists for: styling a run of characters puts a span inside
21
+ // the element, and a rule of "no element children" would have let the editor
22
+ // lock every element it had ever styled out of itself, permanently.
23
+ it("still opens an element that has been styled", () => {
24
+ const element = mount('<h1>hell<span style="color: red">o</span> world</h1>');
25
+ expect(canEditElementTextInline(element)).toBe(true);
26
+ });
27
+
28
+ it("opens an element whose formatting is nested", () => {
29
+ const element = mount('<h1><b><span style="color: red">deep</span></b></h1>');
30
+ expect(canEditElementTextInline(element)).toBe(true);
31
+ });
32
+
33
+ it("keeps out an element with a structural child, which the panel edits field by field", () => {
34
+ expect(canEditElementTextInline(mount("<div><h1>a</h1><p>b</p></div>"))).toBe(false);
35
+ });
36
+
37
+ it("keeps out an element hiding something structural inside its formatting", () => {
38
+ expect(canEditElementTextInline(mount("<h1><span><div>a</div></span></h1>"))).toBe(false);
39
+ });
40
+
41
+ it("keeps out the document itself", () => {
42
+ expect(canEditElementTextInline(document.body)).toBe(false);
43
+ expect(canEditElementTextInline(document.documentElement)).toBe(false);
44
+ });
45
+
46
+ it("keeps out an element that is already being edited", () => {
47
+ const element = mount("<h1>hello</h1>");
48
+ element.setAttribute("contenteditable", "true");
49
+ expect(canEditElementTextInline(element)).toBe(false);
50
+ });
51
+
52
+ it("keeps out an element with no words in it", () => {
53
+ expect(canEditElementTextInline(mount("<h1> </h1>"))).toBe(false);
54
+ });
55
+
56
+ it("keeps out nothing at all", () => {
57
+ expect(canEditElementTextInline(null)).toBe(false);
58
+ });
59
+ });
@@ -27,6 +27,7 @@ export {
27
27
  export {
28
28
  buildDefaultDomEditTextField,
29
29
  buildDomEditPatchTarget,
30
+ buildDomEditRichTextPatchOperation,
30
31
  buildDomEditStylePatchOperation,
31
32
  buildDomEditTextPatchOperation,
32
33
  collectDomEditLayerItems,
@@ -519,12 +519,13 @@ export function buildDomEditTextPatchOperation(
519
519
  value: string,
520
520
  childLocator?: DomEditChildLocator,
521
521
  ): PatchOperation {
522
- return {
523
- type: "text-content",
524
- property: "text",
525
- value,
526
- ...childLocator,
527
- };
522
+ return { type: "text-content", property: "text", value, ...childLocator };
523
+ }
524
+
525
+ /** Replace an element's contents with markup, for a change no per-child operation
526
+ * can express (a text layer added, removed or reordered). Sanitized at both ends. */
527
+ export function buildDomEditRichTextPatchOperation(value: string): PatchOperation {
528
+ return { type: "rich-text", property: "", value };
528
529
  }
529
530
 
530
531
  // ─── Non-editable reason ─────────────────────────────────────────────────────