// @vitest-environment happy-dom import React, { act, useRef, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TextAreaField } from "../components/editor/propertyPanelSections"; import type { DomEditSelection } from "../components/editor/domEditing"; import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar"; import { usePlayerStore } from "../player/store/playerStore"; import { useAppHotkeys } from "./useAppHotkeys"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const timelineDelete = vi.fn(async () => undefined); const domDelete = vi.fn(async () => undefined); const keyframeDelete = vi.fn(); const textCommits = vi.fn(); let root: Root | null = null; function parentWithTextChild(): DomEditSelection { const element = document.createElement("section"); element.id = "selected-card"; const child = document.createElement("span"); child.textContent = "Kicker"; element.append(child); return { element, id: "selected-card", selector: "#selected-card", selectorIndex: 0, label: "Selected card", tagName: "section", sourceFile: "index.html", compositionPath: "index.html", isCompositionHost: false, isInsideLockedComposition: false, boundingBox: { x: 0, y: 0, width: 320, height: 180 }, textContent: "Kicker", dataAttributes: {}, inlineStyles: {}, computedStyles: {}, textFields: [ { key: "child:0:span", label: "Content", value: "Kicker", tagName: "span", attributes: [], inlineStyles: {}, computedStyles: {}, source: "child", sourceChildIndex: 0, }, ], capabilities: { canSelect: true, canEditStyles: true, canCrop: false, canMove: true, canResize: true, canApplyManualOffset: true, canApplyManualSize: true, canApplyManualRotation: true, }, }; } function Harness() { const [value, setValue] = useState("Kicker"); const [selectionRefreshed, setSelectionRefreshed] = useState(false); const selectionRef = useRef(parentWithTextChild()); const clearSelectionRef = useRef<() => void>(() => undefined); const saveTimestampRef = useRef(0); const leftSidebarRef = useRef(null); useAppHotkeys({ handleTimelineElementDelete: timelineDelete, handleTimelineElementSplit: vi.fn(async () => undefined), handleDomEditElementDelete: domDelete, domEditSelectionRef: selectionRef, clearDomSelectionRef: clearSelectionRef, editHistory: { undo: vi.fn(async () => ({ ok: false })), redo: vi.fn(async () => ({ ok: false })), state: { undo: [], redo: [] }, }, readOptionalProjectFile: vi.fn(async () => ""), readProjectFile: vi.fn(async () => ""), writeProjectFile: vi.fn(async () => undefined), domEditSaveTimestampRef: saveTimestampRef, showToast: vi.fn(), syncHistoryPreviewAfterApply: vi.fn(async () => undefined), waitForPendingDomEditSaves: vi.fn(async () => undefined), leftSidebarRef, handleCopy: vi.fn(() => false), handlePaste: vi.fn(async () => undefined), handleCut: vi.fn(async () => false), onResetKeyframes: vi.fn(() => false), onDeleteSelectedKeyframes: keyframeDelete, }); return ( <> { textCommits(next); setValue(next); // Text persistence rebuilds the selected parent's text-field model // from the preview. Model that refresh: it must not transfer keyboard // ownership away from the still-mounted Content editor. setSelectionRefreshed(true); }} /> ); } function setTextareaValue(textarea: HTMLTextAreaElement, value: string) { const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; if (!setter) throw new Error("expected native textarea value setter"); setter.call(textarea, value); textarea.dispatchEvent(new Event("input", { bubbles: true })); textarea.setSelectionRange(value.length, value.length); } function pressBackspace(target: HTMLElement): KeyboardEvent { const event = new KeyboardEvent("keydown", { key: "Backspace", bubbles: true, cancelable: true, }); target.dispatchEvent(event); return event; } beforeEach(() => { vi.useFakeTimers(); timelineDelete.mockClear(); domDelete.mockClear(); keyframeDelete.mockClear(); textCommits.mockClear(); usePlayerStore.getState().reset(); usePlayerStore.getState().setElements([ { id: "selected-card", tag: "section", start: 0, duration: 10, track: 0, }, ]); usePlayerStore.getState().setSelectedElementId("selected-card"); }); afterEach(() => { if (root) act(() => root?.unmount()); root = null; document.body.innerHTML = ""; usePlayerStore.getState().reset(); vi.useRealTimers(); }); describe("useAppHotkeys text-field ownership", () => { it("keeps two Backspaces in a child text editor across its scheduled save", () => { const host = document.createElement("div"); document.body.append(host); root = createRoot(host); act(() => root?.render()); const textarea = host.querySelector("textarea"); const canvas = host.querySelector('[data-testid="canvas"]'); if (!textarea || !canvas) throw new Error("expected text editor and canvas target"); textarea.focus(); textarea.setSelectionRange(textarea.value.length, textarea.value.length); const first = pressBackspace(textarea); act(() => setTextareaValue(textarea, "Kicke")); act(() => vi.advanceTimersByTime(120)); expect(first.defaultPrevented).toBe(false); expect(textCommits).toHaveBeenLastCalledWith("Kicke"); expect(canvas.dataset.selectionRefreshed).toBe("true"); expect(document.activeElement).toBe(textarea); expect(textarea.selectionStart).toBe(5); expect(textarea.selectionEnd).toBe(5); const second = pressBackspace(textarea); act(() => setTextareaValue(textarea, "Kick")); act(() => vi.advanceTimersByTime(120)); expect(second.defaultPrevented).toBe(false); expect(textarea.value).toBe("Kick"); expect(textCommits.mock.calls).toEqual([["Kicke"], ["Kick"]]); expect(document.activeElement).toBe(textarea); expect(textarea.selectionStart).toBe(4); expect(textarea.selectionEnd).toBe(4); expect(timelineDelete).not.toHaveBeenCalled(); expect(domDelete).not.toHaveBeenCalled(); expect(keyframeDelete).not.toHaveBeenCalled(); canvas.focus(); const canvasDelete = pressBackspace(canvas); expect(canvasDelete.defaultPrevented).toBe(true); expect(timelineDelete).toHaveBeenCalledTimes(1); expect(domDelete).not.toHaveBeenCalled(); expect(keyframeDelete).not.toHaveBeenCalled(); }); });