@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.106",
3
+ "version": "0.7.107",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/parsers": "0.7.106",
51
- "@hyperframes/studio-server": "0.7.106",
52
- "@hyperframes/core": "0.7.106",
53
- "@hyperframes/player": "0.7.106",
54
- "@hyperframes/sdk": "0.7.106"
50
+ "@hyperframes/player": "0.7.107",
51
+ "@hyperframes/parsers": "0.7.107",
52
+ "@hyperframes/studio-server": "0.7.107",
53
+ "@hyperframes/sdk": "0.7.107",
54
+ "@hyperframes/core": "0.7.107"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -68,7 +68,7 @@
68
68
  "vite": "^6.4.2",
69
69
  "vitest": "^3.2.4",
70
70
  "zustand": "^5.0.0",
71
- "@hyperframes/producer": "0.7.106"
71
+ "@hyperframes/producer": "0.7.107"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "react": "19",
@@ -8,7 +8,6 @@ import {
8
8
  } from "./nle/useTimelineEditCallbacks";
9
9
  import { NLEProvider, useNLEContext } from "./nle/NLEContext";
10
10
  import { CaptionTimeline } from "../captions/components/CaptionTimeline";
11
- import { StudioFeedbackBar } from "./StudioFeedbackBar";
12
11
  import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
13
12
  import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
14
13
  import { TimelineEditProvider } from "../contexts/TimelineEditContext";
@@ -189,7 +188,6 @@ export function EditorShell({
189
188
  />
190
189
  </NLEProvider>
191
190
  </TimelineEditProvider>
192
- <StudioFeedbackBar />
193
191
  </div>
194
192
  );
195
193
  }
@@ -0,0 +1,97 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+
7
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
8
+ vi.mock("../telemetry/policy", () => ({ browserTelemetryAllowed: () => true }));
9
+ vi.mock("../telemetry/events", () => ({
10
+ trackStudioFeedback: vi.fn(),
11
+ trackStudioFeedbackShown: vi.fn(),
12
+ trackStudioFeedbackDismissed: vi.fn(),
13
+ trackStudioFeedbackInterviewClick: vi.fn(),
14
+ }));
15
+
16
+ const { StudioErrorBoundary } = await import("./StudioErrorBoundary");
17
+ const { trackStudioFeedbackShown } = await import("../telemetry/events");
18
+
19
+ function Boom(): React.ReactElement {
20
+ throw new Error("timeline exploded");
21
+ }
22
+
23
+ let container: HTMLDivElement;
24
+ let root: ReturnType<typeof createRoot>;
25
+ let consoleError: ReturnType<typeof vi.spyOn>;
26
+
27
+ beforeEach(() => {
28
+ localStorage.clear();
29
+ sessionStorage.clear();
30
+ vi.clearAllMocks();
31
+ container = document.createElement("div");
32
+ document.body.appendChild(container);
33
+ root = createRoot(container);
34
+ // React logs the caught error itself; the boundary is the thing under test.
35
+ consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
36
+ });
37
+
38
+ afterEach(() => {
39
+ act(() => root.unmount());
40
+ container.remove();
41
+ consoleError.mockRestore();
42
+ });
43
+
44
+ const renderCrashed = () =>
45
+ act(() => {
46
+ root.render(
47
+ <StudioErrorBoundary>
48
+ <Boom />
49
+ </StudioErrorBoundary>,
50
+ );
51
+ });
52
+
53
+ describe("StudioErrorBoundary", () => {
54
+ it("shows the crash screen with the error message and both recovery paths", () => {
55
+ renderCrashed();
56
+ expect(container.textContent).toContain("Something went wrong");
57
+ expect(container.textContent).toContain("timeline exploded");
58
+ expect(container.textContent).toContain("Try again");
59
+ expect(container.textContent).toContain("Reload Studio");
60
+ });
61
+
62
+ it("asks what the user was doing, since the stack trace cannot say", () => {
63
+ renderCrashed();
64
+ const card = container.querySelector('[aria-label="Send feedback to the HyperFrames team"]');
65
+ expect(card).not.toBeNull();
66
+ expect(card?.textContent).toContain("Studio crashed. What were you doing?");
67
+ });
68
+
69
+ it("offers one-tap answers instead of a 0-10 score", () => {
70
+ renderCrashed();
71
+ const card = container.querySelector('[aria-label="Send feedback to the HyperFrames team"]');
72
+ const chips = [...(card?.querySelectorAll("button") ?? [])]
73
+ .map((b) => b.textContent?.trim())
74
+ .filter((t) => t && t !== "Send");
75
+ expect(chips).toContain("Editing the timeline");
76
+ expect(chips).toContain("Just opened it");
77
+ // Rating a crash is a question with no useful answer.
78
+ expect(card?.querySelector('input[name="hf-studio-feedback-rating"]')).toBeNull();
79
+ });
80
+
81
+ it("reports the crash prompt to telemetry so the funnel is visible", () => {
82
+ renderCrashed();
83
+ expect(trackStudioFeedbackShown).toHaveBeenCalledWith(
84
+ expect.objectContaining({ reason: "crash" }),
85
+ );
86
+ });
87
+
88
+ it("stays quiet when the user already gave feedback recently", () => {
89
+ localStorage.setItem("hyperframes-studio:feedbackAnsweredAt", String(Date.now()));
90
+ renderCrashed();
91
+ // The crash screen itself still works; only the ask is suppressed.
92
+ expect(container.textContent).toContain("Something went wrong");
93
+ expect(
94
+ container.querySelector('[aria-label="Send feedback to the HyperFrames team"]'),
95
+ ).toBeNull();
96
+ });
97
+ });
@@ -1,5 +1,6 @@
1
1
  import { Component, type ErrorInfo, type ReactNode } from "react";
2
2
  import { trackStudioEvent } from "../utils/studioTelemetry";
3
+ import { CrashFeedbackPrompt } from "./feedback/CrashFeedbackPrompt";
3
4
 
4
5
  interface Props {
5
6
  children: ReactNode;
@@ -51,6 +52,12 @@ export class StudioErrorBoundary extends Component<Props, State> {
51
52
  Reload Studio
52
53
  </button>
53
54
  </div>
55
+ {/* The crash report tells us what broke; only the user can tell us what
56
+ they were doing when it did. This is also the one screen where they
57
+ have nothing else to get on with. */}
58
+ <div className="mt-6">
59
+ <CrashFeedbackPrompt />
60
+ </div>
54
61
  </div>
55
62
  );
56
63
  }
@@ -3,6 +3,7 @@ import { LintModal } from "./LintModal";
3
3
  import { AskAgentModal } from "./AskAgentModal";
4
4
  import { StudioGlobalDragOverlay } from "./StudioGlobalDragOverlay";
5
5
  import { StudioToast } from "./StudioToast";
6
+ import { StudioFeedbackCard } from "./feedback/StudioFeedbackCard";
6
7
  import { buildAgentContextPreview } from "./editor/domEditingAgentPrompt";
7
8
  import type { useDomEditSession } from "../hooks/useDomEditSession";
8
9
  import type { useToast } from "../hooks/useToast";
@@ -78,19 +79,20 @@ export function StudioOverlays({
78
79
  />
79
80
  )}
80
81
  {dragOverlayActive && <StudioGlobalDragOverlay />}
81
- {toasts.length > 0 && (
82
- <div className="absolute bottom-6 right-6 z-[91] flex flex-col items-end gap-2">
83
- {toasts.map((toast) => (
84
- <StudioToast
85
- key={toast.id}
86
- message={toast.message}
87
- tone={toast.tone}
88
- leaving={toast.leaving}
89
- onDismiss={() => dismissToast(toast.id)}
90
- />
91
- ))}
92
- </div>
93
- )}
82
+ {/* One bottom-right stack so the feedback card and toasts queue instead
83
+ of covering each other. Empty when nothing is showing. */}
84
+ <div className="absolute bottom-6 right-6 z-[91] flex flex-col items-end gap-2">
85
+ {toasts.map((toast) => (
86
+ <StudioToast
87
+ key={toast.id}
88
+ message={toast.message}
89
+ tone={toast.tone}
90
+ leaving={toast.leaving}
91
+ onDismiss={() => dismissToast(toast.id)}
92
+ />
93
+ ))}
94
+ <StudioFeedbackCard />
95
+ </div>
94
96
  </>
95
97
  );
96
98
  }
@@ -16,7 +16,7 @@ import {
16
16
  resolveShiftClickCandidate,
17
17
  } from "./domEditOverlayGestures";
18
18
  import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
19
- import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
19
+ import { ChildRectOutlines, OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
20
20
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
21
21
  import { useDomEditNudge } from "./useDomEditNudge";
22
22
  import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
@@ -30,6 +30,7 @@ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
30
30
  import { useMountEffect } from "../../hooks/useMountEffect";
31
31
  import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
32
32
  import { CanvasContextMenu } from "./CanvasContextMenu";
33
+ import { useInlineTextEditing } from "./useInlineTextEditing";
33
34
  import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
34
35
  import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
35
36
  import { logSelect } from "../../utils/selectDebug";
@@ -164,6 +165,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
164
165
  groupSelectionsRef.current = groupSelections;
165
166
  const hoverSelectionRef = useRef(hoverSelection);
166
167
  hoverSelectionRef.current = hoverSelection;
168
+
169
+ // Double-click an element to edit its text where it sits.
170
+ const inlineText = useInlineTextEditing(selectionRef);
167
171
  const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
168
172
  onPathOffsetCommitRef.current = onPathOffsetCommit;
169
173
  const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
@@ -370,6 +374,16 @@ export const DomEditOverlay = memo(function DomEditOverlay({
370
374
  return;
371
375
  }
372
376
 
377
+ // A second press on the same spot opens that element's text. This is the
378
+ // press path that actually runs: the pointer handler prevents the default
379
+ // on its way through, so the overlay's own mousedown never fires, and the
380
+ // browser never pairs the presses into a dblclick either.
381
+ if (inlineText.startFromPress(event)) {
382
+ event.preventDefault();
383
+ event.stopPropagation();
384
+ return;
385
+ }
386
+
373
387
  const target = event.target as HTMLElement | null;
374
388
  if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
375
389
 
@@ -449,7 +463,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
449
463
  return (
450
464
  <div
451
465
  ref={overlayRef}
452
- className="absolute inset-0 z-10 pointer-events-auto outline-none"
466
+ // Standing aside is the only way the caret below can be reached, and is
467
+ // what keeps selection, drag and marquee from firing mid-edit.
468
+ className={`absolute inset-0 z-10 outline-none ${
469
+ inlineText.editing ? "pointer-events-none" : "pointer-events-auto"
470
+ }`}
471
+ data-editing-text={inlineText.editing ? "true" : undefined}
453
472
  tabIndex={-1}
454
473
  aria-label="Composition canvas"
455
474
  // Cursor follows marquee rect *state* (re-renders), not the mutable ref.
@@ -458,7 +477,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
458
477
  // A pointer gesture supersedes a pending nudge burst — commit it first
459
478
  // so the gesture's member snapshot starts from the nudged position.
460
479
  flushNudge();
461
- focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay);
480
+ // Not while editing: taking focus back would send the keystroke nowhere.
481
+ if (!inlineText.editing) {
482
+ focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay);
483
+ }
484
+ }}
485
+ onKeyDown={(event) => {
486
+ if (!inlineText.handleKeyDown(event)) return;
487
+ event.preventDefault();
488
+ event.stopPropagation();
462
489
  }}
463
490
  onPointerDown={handleOverlayPointerDown}
464
491
  onMouseDown={handleOverlayMouseDown}
@@ -492,6 +519,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
492
519
  )}
493
520
  {!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
494
521
  <DomEditSelectionChrome
522
+ inlineText={inlineText}
495
523
  selection={selection}
496
524
  overlayRect={overlayRect}
497
525
  allowCanvasMovement={allowCanvasMovement}
@@ -508,21 +536,11 @@ export const DomEditOverlay = memo(function DomEditOverlay({
508
536
  onBoxClick={handleBoxClick}
509
537
  />
510
538
  )}
511
- {childRects.length > 0 &&
512
- compRect.width > 0 &&
513
- childRects.map((cr, i) => (
514
- <div
515
- key={i}
516
- className="pointer-events-none absolute border border-dashed border-white/20 rounded-sm"
517
- style={{
518
- left: cr.left,
519
- top: cr.top,
520
- width: cr.width,
521
- height: cr.height,
522
- transform: cr.angle ? `rotate(${cr.angle}deg)` : undefined,
523
- }}
524
- />
525
- ))}
539
+ <ChildRectOutlines rects={compRect.width > 0 ? childRects : []} />
540
+ {/* Mounted here rather than with the selection chrome: the chrome does
541
+ not render for every selection, and the toolbar belongs to the
542
+ editing session, which does. */}
543
+ {inlineText.toolbar}
526
544
  <OffCanvasIndicators
527
545
  rects={offCanvasRects}
528
546
  elements={offCanvasElementsRef}
@@ -132,3 +132,88 @@ describe("DomEditSelectionChrome crop composition", () => {
132
132
  act(() => root.unmount());
133
133
  });
134
134
  });
135
+
136
+ // The bug: the overlay above the preview goes pointer-events-none while text is
137
+ // being edited, but `pointer-events: none` on a parent does not disable a child
138
+ // that sets `auto`. The selection box covers exactly the element being typed
139
+ // into, so it kept swallowing every press: the caret could only ever be placed
140
+ // once, when the edit opened, and dragging across characters did nothing.
141
+ describe("DomEditSelectionChrome while editing text", () => {
142
+ const CAPABLE = {
143
+ canCrop: true,
144
+ canApplyManualOffset: true,
145
+ canApplyManualSize: true,
146
+ canApplyManualRotation: true,
147
+ };
148
+
149
+ function renderChrome(editing: boolean) {
150
+ const element = document.createElement("div");
151
+ element.id = "copy";
152
+ document.body.append(element);
153
+ const selection = {
154
+ element,
155
+ id: "copy",
156
+ selector: "#copy",
157
+ capabilities: CAPABLE,
158
+ } as unknown as DomEditSelection;
159
+ const host = document.createElement("div");
160
+ document.body.append(host);
161
+ const root = createRoot(host);
162
+ act(() => {
163
+ root.render(
164
+ <DomEditSelectionChrome
165
+ selection={selection}
166
+ overlayRect={{ left: 10, top: 20, width: 200, height: 60, editScaleX: 1, editScaleY: 1 }}
167
+ allowCanvasMovement={true}
168
+ boxRef={createRef()}
169
+ boxChromeClass="border border-studio-accent/80"
170
+ boxClipPath={undefined}
171
+ selectionKey="copy"
172
+ groupSelectionCount={0}
173
+ blockedMoveRef={createRef()}
174
+ gestures={{ startGesture: vi.fn() } as never}
175
+ onStyleCommit={vi.fn()}
176
+ onBoxMouseDown={vi.fn()}
177
+ onBoxClick={vi.fn()}
178
+ inlineText={{ editing, startFromPress: vi.fn() }}
179
+ />,
180
+ );
181
+ });
182
+ return { host, unmount: () => act(() => root.unmount()) };
183
+ }
184
+
185
+ it("stops the selection box taking presses, so they reach the caret below", () => {
186
+ const { host, unmount } = renderChrome(true);
187
+ const box = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
188
+ expect(box.className).toContain("pointer-events-none");
189
+ expect(box.className).not.toContain("pointer-events-auto");
190
+ unmount();
191
+ });
192
+
193
+ it("keeps the box interactive when no text is being edited", () => {
194
+ const { host, unmount } = renderChrome(false);
195
+ const box = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
196
+ expect(box.className).toContain("pointer-events-auto");
197
+ unmount();
198
+ });
199
+
200
+ it("still marks the edited element, so it is clear which one has the caret", () => {
201
+ const { host, unmount } = renderChrome(true);
202
+ const box = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
203
+ expect(box.className).toContain("border-studio-accent/80");
204
+ unmount();
205
+ });
206
+
207
+ it("takes away every handle that would sit over the text", () => {
208
+ const { host, unmount } = renderChrome(true);
209
+ expect(host.querySelectorAll(".pointer-events-auto")).toHaveLength(0);
210
+ expect(host.querySelector("[data-dom-edit-crop-frame]")).toBeNull();
211
+ unmount();
212
+ });
213
+
214
+ it("keeps the handles when nothing is being edited", () => {
215
+ const { host, unmount } = renderChrome(false);
216
+ expect(host.querySelectorAll(".pointer-events-auto").length).toBeGreaterThan(1);
217
+ unmount();
218
+ });
219
+ });
@@ -126,6 +126,12 @@ interface DomEditSelectionChromeProps {
126
126
  onStyleCommit?: (property: string, value: string) => Promise<void> | void;
127
127
  onBoxMouseDown: (e: React.MouseEvent) => void;
128
128
  onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
129
+ /** The canvas' text-editing session: what opens one, and whether one is open. */
130
+ inlineText?: {
131
+ editing: boolean;
132
+ /** Every press on the box. Returns true when it opened a text edit. */
133
+ startFromPress: (event: React.PointerEvent) => boolean;
134
+ };
129
135
  }
130
136
 
131
137
  // Oriented selection chrome: a rotation wrapper spanning the overlay, rotated by
@@ -149,7 +155,16 @@ export function DomEditSelectionChrome({
149
155
  onStyleCommit,
150
156
  onBoxMouseDown,
151
157
  onBoxClick,
158
+ inlineText,
152
159
  }: DomEditSelectionChromeProps) {
160
+ // While the text is being edited the chrome is a mark, not a control. The
161
+ // overlay above the preview already stands aside for the caret, but
162
+ // `pointer-events: none` on a parent does not disable a child that asks for
163
+ // them back, and the box is positioned to cover exactly the text being typed
164
+ // into: left interactive, it swallows every press, so the caret can never be
165
+ // moved and characters can never be selected by dragging.
166
+ const editing = inlineText?.editing ?? false;
167
+
153
168
  return (
154
169
  <>
155
170
  <div
@@ -159,7 +174,7 @@ export function DomEditSelectionChrome({
159
174
  transform: overlayRect.angle ? `rotate(${overlayRect.angle}deg)` : undefined,
160
175
  }}
161
176
  >
162
- {allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
177
+ {allowCanvasMovement && !editing && selection.capabilities.canApplyManualRotation && (
163
178
  <DomEditRotateHandle
164
179
  overlayRect={overlayRect}
165
180
  cropOutlineInsetPx={cropOutlineInsetPx}
@@ -173,7 +188,7 @@ export function DomEditSelectionChrome({
173
188
  key={selectionKey}
174
189
  ref={boxRef}
175
190
  data-dom-edit-selection-box="true"
176
- className={`pointer-events-auto absolute rounded-md ${boxChromeClass}`}
191
+ className={`${editing ? "pointer-events-none" : "pointer-events-auto"} absolute rounded-md ${boxChromeClass}`}
177
192
  style={{
178
193
  left: overlayRect.left,
179
194
  top: overlayRect.top,
@@ -186,6 +201,16 @@ export function DomEditSelectionChrome({
186
201
  : "default",
187
202
  }}
188
203
  onPointerDown={(e) => {
204
+ // A second press opens the element's text for editing, and must be
205
+ // caught here rather than on the canvas: this handler prevents the
206
+ // default on the first press, which suppresses the compatibility
207
+ // mousedown the canvas would otherwise see, and the pointer capture
208
+ // it takes stops the browser pairing the presses into a dblclick.
209
+ if (inlineText?.startFromPress(e)) {
210
+ e.preventDefault();
211
+ e.stopPropagation();
212
+ return;
213
+ }
189
214
  if (!allowCanvasMovement || e.shiftKey) return;
190
215
  if (selection.capabilities.canApplyManualOffset) {
191
216
  gestures.startGesture("drag", e);
@@ -221,6 +246,7 @@ export function DomEditSelectionChrome({
221
246
  is positioned relative to the overlay container using the
222
247
  overlayRect origin, matching the old child-relative offsets. */}
223
248
  {allowCanvasMovement &&
249
+ !editing &&
224
250
  selection.capabilities.canApplyManualSize &&
225
251
  RESIZE_HANDLE_DEFS.map((def) =>
226
252
  def.handle !== "se" && !selection.capabilities.canApplyManualOffset ? null : (
@@ -245,7 +271,7 @@ export function DomEditSelectionChrome({
245
271
  </div>
246
272
  {/* Crop owns its element-local oriented frame. Keep it outside the chrome's
247
273
  rotated plane or a rotated selection applies the angle twice. */}
248
- {selection.capabilities.canCrop && groupSelectionCount <= 1 && (
274
+ {selection.capabilities.canCrop && !editing && groupSelectionCount <= 1 && (
249
275
  <DomEditCropHandles
250
276
  selection={selection}
251
277
  overlayRect={overlayRect}