diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx
index 261b82f24b..11cd1f0d4c 100644
--- a/packages/studio/src/components/editor/DomEditOverlay.tsx
+++ b/packages/studio/src/components/editor/DomEditOverlay.tsx
@@ -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";
@@ -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";
@@ -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);
@@ -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;
@@ -449,7 +463,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
return (
{
+ if (!inlineText.handleKeyDown(event)) return;
+ event.preventDefault();
+ event.stopPropagation();
}}
onPointerDown={handleOverlayPointerDown}
onMouseDown={handleOverlayMouseDown}
@@ -492,6 +519,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
)}
{!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
)}
- {childRects.length > 0 &&
- compRect.width > 0 &&
- childRects.map((cr, i) => (
-
- ))}
+ 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}
{
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(
+ ,
+ );
+ });
+ 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('[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('[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('[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();
+ });
+});
diff --git a/packages/studio/src/components/editor/DomEditSelectionChrome.tsx b/packages/studio/src/components/editor/DomEditSelectionChrome.tsx
index acbde93f69..8046e8608f 100644
--- a/packages/studio/src/components/editor/DomEditSelectionChrome.tsx
+++ b/packages/studio/src/components/editor/DomEditSelectionChrome.tsx
@@ -126,6 +126,12 @@ interface DomEditSelectionChromeProps {
onStyleCommit?: (property: string, value: string) => Promise | void;
onBoxMouseDown: (e: React.MouseEvent) => void;
onBoxClick: (event: React.MouseEvent) => 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
@@ -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 (
<>
- {allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
+ {allowCanvasMovement && !editing && selection.capabilities.canApplyManualRotation && (
{
+ // 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);
@@ -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 : (
@@ -245,7 +271,7 @@ export function DomEditSelectionChrome({
{/* 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 && (
{
+ document.body.innerHTML = "";
+});
+
+/** An element standing in for one in the preview, and a fake frame around it. */
+function scene(html: string) {
+ document.body.innerHTML = `
${html}
`;
+ const element = document.body.firstElementChild as HTMLElement;
+ const iframe = document.createElement("iframe");
+ document.body.append(iframe);
+ // The composition is drawn scaled, so the toolbar has to map out of it.
+ iframe.getBoundingClientRect = () => ({ left: 100, top: 50, width: 400 }) as DOMRect;
+ Object.defineProperty(iframe, "contentWindow", { value: window });
+ const session: InlineTextEditSession = { element, original: html, outline: "" };
+ return { element, iframe, session };
+}
+
+function render(session: InlineTextEditSession | null, iframe: HTMLIFrameElement | null) {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => root.render());
+ return { host, root, rerender: () => act(() => root.render()) };
+}
+
+function selectAll(element: HTMLElement) {
+ const range = document.createRange();
+ range.selectNodeContents(element);
+ const selection = document.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+ act(() => void document.dispatchEvent(new Event("selectionchange")));
+}
+
+function toolbarIn(host: HTMLElement): HTMLElement | null {
+ return host.querySelector('[data-inline-text-toolbar="true"]');
+}
+
+describe("InlineTextToolbar", () => {
+ it("stays out of the way until characters are actually selected", () => {
+ const { session, iframe } = scene("hello world");
+ const { host } = render(session, iframe);
+
+ expect(toolbarIn(host)).toBeNull();
+ });
+
+ it("appears once a run of characters is selected", () => {
+ const { element, session, iframe } = scene("hello world");
+ const { host } = render(session, iframe);
+
+ selectAll(element);
+
+ expect(toolbarIn(host)).not.toBeNull();
+ });
+
+ it("goes away when the selection collapses again", () => {
+ const { element, session, iframe } = scene("hello world");
+ const { host } = render(session, iframe);
+ selectAll(element);
+
+ const selection = document.getSelection()!;
+ act(() => {
+ selection.collapseToEnd();
+ document.dispatchEvent(new Event("selectionchange"));
+ });
+
+ expect(toolbarIn(host)).toBeNull();
+ });
+
+ it("shows nothing at all when no text is being edited", () => {
+ const { iframe } = scene("hello");
+ const { host } = render(null, iframe);
+
+ expect(toolbarIn(host)).toBeNull();
+ });
+
+ it("keeps the press, so clicking a control does not collapse the selection", () => {
+ const { element, session, iframe } = scene("hello world");
+ const { host } = render(session, iframe);
+ selectAll(element);
+
+ const press = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
+ act(() => void toolbarIn(host)!.dispatchEvent(press));
+
+ expect(press.defaultPrevented).toBe(true);
+ });
+
+ // It renders inside the canvas overlay, so a press it lets through is read as
+ // a click on the composition: the element deselects and the edit commits out
+ // from under the button that was just pressed.
+ it("keeps its presses away from the canvas underneath", () => {
+ const { element, session, iframe } = scene("hello world");
+ const seen: string[] = [];
+ // A stand-in for the canvas overlay: the toolbar renders inside it, and
+ // these are the handlers that would deselect the element.
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() =>
+ root.render(
+
+ );
+}
+
+function swallow(event: { preventDefault: () => void; stopPropagation: () => void }): void {
+ event.preventDefault();
+ event.stopPropagation();
+}
+
+function ToolbarToggle({
+ label,
+ glyph,
+ on,
+ onToggle,
+ bold,
+ italic,
+ underline,
+}: {
+ label: string;
+ glyph: string;
+ on: boolean;
+ onToggle: (on: boolean) => void;
+ bold?: boolean;
+ italic?: boolean;
+ underline?: boolean;
+}) {
+ return (
+
+ );
+}
+
+/** Where the selection is on screen, or null when there is nothing selected. */
+function placeOverSelection(
+ element: HTMLElement,
+ iframe: HTMLIFrameElement,
+): ToolbarPlacement | null {
+ const doc = element.ownerDocument;
+ const view = doc.defaultView;
+ const selection = view?.getSelection();
+ if (!view || !selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
+
+ const range = selection.getRangeAt(0);
+ if (!element.contains(range.commonAncestorContainer)) return null;
+ const rect = range.getBoundingClientRect();
+
+ // The composition is drawn scaled into the iframe's box, so a point inside it
+ // is that scale away from a point on Studio's screen. This is the inverse of
+ // the mapping the canvas uses to turn a press into a caret position.
+ const box = iframe.getBoundingClientRect();
+ const scale = view.innerWidth ? box.width / view.innerWidth : 1;
+
+ return {
+ left: box.left + (rect.left + rect.width / 2) * scale,
+ top: box.top + rect.top * scale - GAP_PX,
+ styles: readInlineStyle(range, READ_PROPERTIES),
+ };
+}
+
+function isBold(weight: string | undefined): boolean {
+ if (!weight) return false;
+ if (weight === "bold" || weight === "bolder") return true;
+ return Number.parseInt(weight, 10) >= 600;
+}
+
+/**
+ * A colour input only accepts `#rrggbb`, and what the page reports is whatever
+ * the stylesheet said. An unreadable value opens the picker on white rather
+ * than refusing to open.
+ */
+function toHexColor(value: string | undefined): string {
+ if (!value) return DEFAULT_COLOR;
+ if (/^#[0-9a-f]{6}$/i.test(value)) return value;
+ const channels = value.match(/\d+(\.\d+)?/g);
+ if (!channels || channels.length < 3) return DEFAULT_COLOR;
+ return `#${channels
+ .slice(0, 3)
+ .map((channel) => Number(channel).toString(16).padStart(2, "0"))
+ .join("")}`;
+}
diff --git a/packages/studio/src/components/editor/OffCanvasIndicators.tsx b/packages/studio/src/components/editor/OffCanvasIndicators.tsx
index ead1a53a4f..c18d36beb9 100644
--- a/packages/studio/src/components/editor/OffCanvasIndicators.tsx
+++ b/packages/studio/src/components/editor/OffCanvasIndicators.tsx
@@ -143,3 +143,41 @@ export function OffCanvasIndicators({
>
);
}
+
+/**
+ * The dashed outlines around a selected element's children.
+ *
+ * Extracted from the canvas overlay, which is at its 600-line limit, and it
+ * sits here rather than in its own file because it is the same kind of thing:
+ * a passive, non-interactive mark the overlay draws over the composition.
+ */
+export function ChildRectOutlines({
+ rects,
+}: {
+ rects: ReadonlyArray<{
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+ angle?: number;
+ }>;
+}) {
+ return (
+ <>
+ {rects.map((rect, index) => (
+
+ ))}
+ >
+ );
+}
diff --git a/packages/studio/src/components/editor/domEditInlineText.test.ts b/packages/studio/src/components/editor/domEditInlineText.test.ts
new file mode 100644
index 0000000000..a229e8cd49
--- /dev/null
+++ b/packages/studio/src/components/editor/domEditInlineText.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it, vi } from "vitest";
+import type { DomEditSelection, DomEditTextField } from "./domEditingTypes";
+
+const editable = vi.hoisted(() => ({ current: true }));
+vi.mock("./domEditingLayers", () => ({
+ isTextEditableSelection: () => editable.current,
+}));
+
+const { canEditTextInline } = await import("./domEditInlineText");
+
+function field(key: string): DomEditTextField {
+ return {
+ key,
+ label: key,
+ value: "text",
+ tagName: "SPAN",
+ attributes: [],
+ inlineStyles: {},
+ computedStyles: {},
+ source: "self",
+ };
+}
+
+function selection(partial: Partial = {}): DomEditSelection {
+ return {
+ label: "Heading",
+ tagName: "H1",
+ isCompositionHost: false,
+ isInsideLockedComposition: false,
+ textFields: [field("self")],
+ ...partial,
+ } as DomEditSelection;
+}
+
+describe("canEditTextInline", () => {
+ it("allows an element the panel would let you edit text on", () => {
+ editable.current = true;
+ expect(canEditTextInline(selection())).toBe(true);
+ });
+
+ // The bar is the panel's bar: nothing becomes editable here that is not
+ // editable there.
+ it("refuses an element whose text the panel cannot edit either", () => {
+ editable.current = false;
+ expect(canEditTextInline(selection())).toBe(false);
+ });
+
+ // Editing the whole element would flatten its children into one string.
+ it("refuses an element with several text fields", () => {
+ editable.current = true;
+ expect(canEditTextInline(selection({ textFields: [field("a"), field("b")] }))).toBe(false);
+ });
+
+ it("allows an element with no separate text fields", () => {
+ editable.current = true;
+ expect(canEditTextInline(selection({ textFields: [] }))).toBe(true);
+ });
+
+ it("refuses the composition host, which is the document rather than copy", () => {
+ editable.current = true;
+ expect(canEditTextInline(selection({ isCompositionHost: true }))).toBe(false);
+ });
+
+ it("refuses anything inside a locked composition", () => {
+ editable.current = true;
+ expect(canEditTextInline(selection({ isInsideLockedComposition: true }))).toBe(false);
+ });
+
+ it("refuses nothing at all", () => {
+ editable.current = true;
+ expect(canEditTextInline(null)).toBe(false);
+ });
+});
diff --git a/packages/studio/src/components/editor/domEditInlineText.ts b/packages/studio/src/components/editor/domEditInlineText.ts
new file mode 100644
index 0000000000..451c5b1832
--- /dev/null
+++ b/packages/studio/src/components/editor/domEditInlineText.ts
@@ -0,0 +1,93 @@
+import { isRichTextFormattingTag } from "@hyperframes/core/rich-text-sanitize";
+import type { DomEditSelection } from "./domEditingTypes";
+import { isTextEditableSelection } from "./domEditingLayers";
+
+/**
+ * Whether this element's text can be edited where it sits.
+ *
+ * Its own function rather than a condition inside a handler, because this is
+ * the rule most likely to change: it is the whole answer to "why did nothing
+ * happen when I double-clicked that".
+ *
+ * The bar is deliberately the same as the design panel's, plus one thing the
+ * panel can do that editing in place cannot. An element with several text
+ * fields is edited a field at a time there, and making the whole element
+ * editable would flatten its children into one string, so those keep the panel.
+ */
+export function canEditTextInline(selection: DomEditSelection | null): boolean {
+ if (!selection) return false;
+ if (!isTextEditableSelection(selection)) return false;
+ // The composition host is the document, not a piece of copy in it.
+ if (selection.isCompositionHost) return false;
+ if (selection.isInsideLockedComposition) return false;
+ if (selection.textFields.length <= 1) return true;
+ // A styled element reports one field per run of characters, but it is still
+ // one piece of copy and the caret edits all of it at once.
+ return canEditElementTextInline(selection.element);
+}
+
+/**
+ * Whether this element's text can be edited in place, judged from the element
+ * alone.
+ *
+ * The press path cannot use the selection-shaped gate above: building a
+ * selection is asynchronous, and a press has to decide now whether it is a
+ * text edit or the start of a drag. This asks the same question of the DOM.
+ *
+ * A structural child keeps an element out: those are separate text fields, the
+ * panel edits them one at a time, and making the whole element editable would
+ * flatten them into a single string.
+ *
+ * A formatting child does not. Styling a run of characters puts a span inside
+ * the element, so a rule of "no element children" would have let the editor
+ * lock every element it had ever styled out of itself, permanently, on the
+ * first colour change. What counts as formatting is the sanitiser's allowlist,
+ * so the editor and the thing that writes the file agree on it.
+ */
+export function canEditElementTextInline(element: HTMLElement | null): boolean {
+ if (!element) return false;
+ const tag = element.tagName;
+ if (tag === "BODY" || tag === "HTML") return false;
+ if (!hasOnlyFormattingChildren(element)) return false;
+ if (element.isContentEditable) return false;
+ return (element.textContent ?? "").trim().length > 0;
+}
+
+function hasOnlyFormattingChildren(element: HTMLElement): boolean {
+ for (const child of Array.from(element.children)) {
+ if (!isRichTextFormattingTag(child.tagName)) return false;
+ // Formatting nests, and a structural child hidden inside a span is still
+ // structural.
+ if (!hasOnlyFormattingChildren(child as HTMLElement)) return false;
+ }
+ return true;
+}
+
+/** Where and when a press landed, for recognising the next one as a pair. */
+export interface PressMark {
+ x: number;
+ y: number;
+ at: number;
+}
+
+/** Long enough to be deliberate, short enough not to catch two separate clicks. */
+const DOUBLE_PRESS_MS = 450;
+/** A double press is two presses in the same place, not a tiny drag. */
+const DOUBLE_PRESS_SLOP_PX = 6;
+
+/**
+ * Whether this press pairs with the last one into a double press.
+ *
+ * Studio cannot use `dblclick` or a click count for this. The selection box
+ * takes pointer capture on the first press and prevents its default, which
+ * suppresses the compatibility mouse events and stops the browser pairing the
+ * two presses at all: no `dblclick` is dispatched, and `detail` stays 1.
+ */
+export function isDoublePress(previous: PressMark | null, next: PressMark): boolean {
+ if (!previous) return false;
+ return (
+ next.at - previous.at <= DOUBLE_PRESS_MS &&
+ Math.abs(next.x - previous.x) <= DOUBLE_PRESS_SLOP_PX &&
+ Math.abs(next.y - previous.y) <= DOUBLE_PRESS_SLOP_PX
+ );
+}
diff --git a/packages/studio/src/components/editor/domEditInlineTextElement.test.ts b/packages/studio/src/components/editor/domEditInlineTextElement.test.ts
new file mode 100644
index 0000000000..c43ab7c807
--- /dev/null
+++ b/packages/studio/src/components/editor/domEditInlineTextElement.test.ts
@@ -0,0 +1,59 @@
+// @vitest-environment happy-dom
+
+import { afterEach, describe, expect, it } from "vitest";
+import { canEditElementTextInline } from "./domEditInlineText";
+
+afterEach(() => {
+ document.body.innerHTML = "";
+});
+
+function mount(html: string): HTMLElement {
+ document.body.innerHTML = html;
+ return document.body.firstElementChild as HTMLElement;
+}
+
+describe("canEditElementTextInline", () => {
+ it("opens a plain piece of copy", () => {
+ expect(canEditElementTextInline(mount("