Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 36 additions & 17 deletions packages/studio/src/components/editor/DomEditOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
resolveShiftClickCandidate,
} from "./domEditOverlayGestures";
import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
import { ChildRectOutlines, OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
import { useDomEditNudge } from "./useDomEditNudge";
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
Expand All @@ -30,6 +30,7 @@ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
import { useMountEffect } from "../../hooks/useMountEffect";
import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
import { CanvasContextMenu } from "./CanvasContextMenu";
import { useInlineTextEditing } from "./useInlineTextEditing";
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
import { logSelect } from "../../utils/selectDebug";
Expand Down Expand Up @@ -164,6 +165,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
groupSelectionsRef.current = groupSelections;
const hoverSelectionRef = useRef(hoverSelection);
hoverSelectionRef.current = hoverSelection;

// Double-click an element to edit its text where it sits.
const inlineText = useInlineTextEditing(selectionRef);
const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
onPathOffsetCommitRef.current = onPathOffsetCommit;
const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
Expand Down Expand Up @@ -370,6 +374,16 @@ export const DomEditOverlay = memo(function DomEditOverlay({
return;
}

// A second press on the same spot opens that element's text. This is the
// press path that actually runs: the pointer handler prevents the default
// on its way through, so the overlay's own mousedown never fires, and the
// browser never pairs the presses into a dblclick either.
if (inlineText.startFromPress(event)) {
event.preventDefault();
event.stopPropagation();
return;
}

const target = event.target as HTMLElement | null;
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;

Expand Down Expand Up @@ -449,7 +463,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
return (
<div
ref={overlayRef}
className="absolute inset-0 z-10 pointer-events-auto outline-none"
// Standing aside is the only way the caret below can be reached, and is
// what keeps selection, drag and marquee from firing mid-edit.
className={`absolute inset-0 z-10 outline-none ${
inlineText.editing ? "pointer-events-none" : "pointer-events-auto"
}`}
data-editing-text={inlineText.editing ? "true" : undefined}
tabIndex={-1}
aria-label="Composition canvas"
// Cursor follows marquee rect *state* (re-renders), not the mutable ref.
Expand All @@ -458,7 +477,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
// A pointer gesture supersedes a pending nudge burst — commit it first
// so the gesture's member snapshot starts from the nudged position.
flushNudge();
focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay);
// Not while editing: taking focus back would send the keystroke nowhere.
if (!inlineText.editing) {
focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay);
}
}}
onKeyDown={(event) => {
if (!inlineText.handleKeyDown(event)) return;
event.preventDefault();
event.stopPropagation();
}}
onPointerDown={handleOverlayPointerDown}
onMouseDown={handleOverlayMouseDown}
Expand Down Expand Up @@ -492,6 +519,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
)}
{!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
<DomEditSelectionChrome
inlineText={inlineText}
selection={selection}
overlayRect={overlayRect}
allowCanvasMovement={allowCanvasMovement}
Expand All @@ -508,20 +536,11 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onBoxClick={handleBoxClick}
/>
)}
{childRects.length > 0 &&
compRect.width > 0 &&
childRects.map((cr, i) => (
<div
key={i}
className="pointer-events-none absolute border border-dashed border-white/20 rounded-sm"
style={{
left: cr.left,
top: cr.top,
width: cr.width,
height: cr.height,
}}
/>
))}
<ChildRectOutlines rects={compRect.width > 0 ? childRects : []} />
{/* Mounted here rather than with the selection chrome: the chrome does
not render for every selection, and the toolbar belongs to the
editing session, which does. */}
{inlineText.toolbar}
<OffCanvasIndicators
rects={offCanvasRects}
elements={offCanvasElementsRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,88 @@ describe("DomEditSelectionChrome crop composition", () => {
act(() => root.unmount());
});
});

// The bug: the overlay above the preview goes pointer-events-none while text is
// being edited, but `pointer-events: none` on a parent does not disable a child
// that sets `auto`. The selection box covers exactly the element being typed
// into, so it kept swallowing every press: the caret could only ever be placed
// once, when the edit opened, and dragging across characters did nothing.
describe("DomEditSelectionChrome while editing text", () => {
const CAPABLE = {
canCrop: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
};

function renderChrome(editing: boolean) {
const element = document.createElement("div");
element.id = "copy";
document.body.append(element);
const selection = {
element,
id: "copy",
selector: "#copy",
capabilities: CAPABLE,
} as unknown as DomEditSelection;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<DomEditSelectionChrome
selection={selection}
overlayRect={{ left: 10, top: 20, width: 200, height: 60, editScaleX: 1, editScaleY: 1 }}
allowCanvasMovement={true}
boxRef={createRef()}
boxChromeClass="border border-studio-accent/80"
boxClipPath={undefined}
selectionKey="copy"
groupSelectionCount={0}
blockedMoveRef={createRef()}
gestures={{ startGesture: vi.fn() } as never}
onStyleCommit={vi.fn()}
onBoxMouseDown={vi.fn()}
onBoxClick={vi.fn()}
inlineText={{ editing, startFromPress: vi.fn() }}
/>,
);
});
return { host, unmount: () => act(() => root.unmount()) };
}

it("stops the selection box taking presses, so they reach the caret below", () => {
const { host, unmount } = renderChrome(true);
const box = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
expect(box.className).toContain("pointer-events-none");
expect(box.className).not.toContain("pointer-events-auto");
unmount();
});

it("keeps the box interactive when no text is being edited", () => {
const { host, unmount } = renderChrome(false);
const box = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
expect(box.className).toContain("pointer-events-auto");
unmount();
});

it("still marks the edited element, so it is clear which one has the caret", () => {
const { host, unmount } = renderChrome(true);
const box = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
expect(box.className).toContain("border-studio-accent/80");
unmount();
});

it("takes away every handle that would sit over the text", () => {
const { host, unmount } = renderChrome(true);
expect(host.querySelectorAll(".pointer-events-auto")).toHaveLength(0);
expect(host.querySelector("[data-dom-edit-crop-frame]")).toBeNull();
unmount();
});

it("keeps the handles when nothing is being edited", () => {
const { host, unmount } = renderChrome(false);
expect(host.querySelectorAll(".pointer-events-auto").length).toBeGreaterThan(1);
unmount();
});
});
32 changes: 29 additions & 3 deletions packages/studio/src/components/editor/DomEditSelectionChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ interface DomEditSelectionChromeProps {
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
onBoxMouseDown: (e: React.MouseEvent) => void;
onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
/** The canvas' text-editing session: what opens one, and whether one is open. */
inlineText?: {
editing: boolean;
/** Every press on the box. Returns true when it opened a text edit. */
startFromPress: (event: React.PointerEvent) => boolean;
};
}

// Oriented selection chrome: a rotation wrapper spanning the overlay, rotated by
Expand All @@ -149,7 +155,16 @@ export function DomEditSelectionChrome({
onStyleCommit,
onBoxMouseDown,
onBoxClick,
inlineText,
}: DomEditSelectionChromeProps) {
// While the text is being edited the chrome is a mark, not a control. The
// overlay above the preview already stands aside for the caret, but
// `pointer-events: none` on a parent does not disable a child that asks for
// them back, and the box is positioned to cover exactly the text being typed
// into: left interactive, it swallows every press, so the caret can never be
// moved and characters can never be selected by dragging.
const editing = inlineText?.editing ?? false;

return (
<>
<div
Expand All @@ -159,7 +174,7 @@ export function DomEditSelectionChrome({
transform: overlayRect.angle ? `rotate(${overlayRect.angle}deg)` : undefined,
}}
>
{allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
{allowCanvasMovement && !editing && selection.capabilities.canApplyManualRotation && (
<DomEditRotateHandle
overlayRect={overlayRect}
cropOutlineInsetPx={cropOutlineInsetPx}
Expand All @@ -173,7 +188,7 @@ export function DomEditSelectionChrome({
key={selectionKey}
ref={boxRef}
data-dom-edit-selection-box="true"
className={`pointer-events-auto absolute rounded-md ${boxChromeClass}`}
className={`${editing ? "pointer-events-none" : "pointer-events-auto"} absolute rounded-md ${boxChromeClass}`}
style={{
left: overlayRect.left,
top: overlayRect.top,
Expand All @@ -186,6 +201,16 @@ export function DomEditSelectionChrome({
: "default",
}}
onPointerDown={(e) => {
// A second press opens the element's text for editing, and must be
// caught here rather than on the canvas: this handler prevents the
// default on the first press, which suppresses the compatibility
// mousedown the canvas would otherwise see, and the pointer capture
// it takes stops the browser pairing the presses into a dblclick.
if (inlineText?.startFromPress(e)) {
e.preventDefault();
e.stopPropagation();
return;
}
if (!allowCanvasMovement || e.shiftKey) return;
if (selection.capabilities.canApplyManualOffset) {
gestures.startGesture("drag", e);
Expand Down Expand Up @@ -221,6 +246,7 @@ export function DomEditSelectionChrome({
is positioned relative to the overlay container using the
overlayRect origin, matching the old child-relative offsets. */}
{allowCanvasMovement &&
!editing &&
selection.capabilities.canApplyManualSize &&
RESIZE_HANDLE_DEFS.map((def) =>
def.handle !== "se" && !selection.capabilities.canApplyManualOffset ? null : (
Expand All @@ -245,7 +271,7 @@ export function DomEditSelectionChrome({
</div>
{/* Crop owns its element-local oriented frame. Keep it outside the chrome's
rotated plane or a rotated selection applies the angle twice. */}
{selection.capabilities.canCrop && groupSelectionCount <= 1 && (
{selection.capabilities.canCrop && !editing && groupSelectionCount <= 1 && (
<DomEditCropHandles
selection={selection}
overlayRect={overlayRect}
Expand Down
Loading
Loading