diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx new file mode 100644 index 0000000..2b515eb --- /dev/null +++ b/app/src/App.test.tsx @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mount } from "./embed"; + +// jsdom does not implement CSS.escape, which the editor uses to look up paragraphs by id. +globalThis.CSS ??= { escape: (value: string) => value.replace(/[^\w-]/g, "\\$&") } as typeof CSS; + +const waitFor = async (read: () => T | null | undefined, timeout = 5000): Promise => { + const started = Date.now(); + for (;;) { + const value = read(); + if (value) return value; + if (Date.now() - started > timeout) throw new Error("Timed out waiting for condition"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +}; + +const firstParagraph = (host: HTMLElement) => + host.querySelector(".paragraph-editor[data-paragraph-id]"); + +afterEach(() => { + document.body.replaceChildren(); + document.getElementById("subscript-write-styles")?.remove(); +}); + +describe("screenplay editing", () => { + it("stores typed text in the shared document so it reaches collaborators", async () => { + const host = document.createElement("div"); + document.body.append(host); + const handle = mount(host); + const paragraph = await waitFor(() => firstParagraph(host)); + const editable = paragraph.closest(".screenplay-editable")!; + + // Browsers dispatch input events to the contentEditable host, not the paragraph typed in. + paragraph.textContent = "INT. KITCHEN - NIGHT"; + const range = document.createRange(); + range.setStart(paragraph.firstChild!, paragraph.textContent.length); + window.getSelection()!.removeAllRanges(); + window.getSelection()!.addRange(range); + editable.dispatchEvent(new Event("input", { bubbles: true })); + + // Wait past the edit commit debounce, then reopen the draft from local storage. + await new Promise((resolve) => setTimeout(resolve, 600)); + handle.unmount(); + const reopened = document.createElement("div"); + document.body.append(reopened); + const reopenedHandle = mount(reopened); + const texts = () => + [...reopened.querySelectorAll(".paragraph-editor[data-paragraph-id]")].map( + (element) => element.textContent, + ); + await waitFor(() => texts().length > 0 && texts()); + expect(texts()).toContain("INT. KITCHEN - NIGHT"); + reopenedHandle.unmount(); + await new Promise((resolve) => setTimeout(resolve, 100)); + }, 15000); +}); diff --git a/app/src/App.tsx b/app/src/App.tsx index 6f3fd22..50a6ed7 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -891,22 +891,44 @@ function App({ config: configOverrides }: { config?: Partial } = {} } }; - const handleScreenplayKey = (event: React.KeyboardEvent) => { - if (mode !== "write") return; - if (deleteSelectionAcrossElements(event)) return; + const editedParagraphElement = (target: EventTarget) => { const anchorNode = window.getSelection()?.anchorNode; - const paragraphElement = - (event.target as HTMLElement).closest(".paragraph-editor[data-paragraph-id]") || + return ( + (target as HTMLElement).closest(".paragraph-editor[data-paragraph-id]") || (anchorNode?.nodeType === Node.ELEMENT_NODE ? (anchorNode as Element) : anchorNode?.parentElement - )?.closest(".paragraph-editor[data-paragraph-id]"); + )?.closest(".paragraph-editor[data-paragraph-id]") + ); + }; + + const handleScreenplayKey = (event: React.KeyboardEvent) => { + if (mode !== "write") return; + if (deleteSelectionAcrossElements(event)) return; + const paragraphElement = editedParagraphElement(event.target); const paragraph = snapshot.paragraphs.find( (item) => item.id === paragraphElement?.dataset.paragraphId, ); if (paragraph && paragraphElement) void handleWritingKey(event, paragraph, paragraphElement); }; + // Input events fire on the contentEditable host rather than the paragraph being typed in, + // so resolve the edited paragraph from the selection and push its text into the shared doc. + const handleScreenplayInput = (event: React.FormEvent) => { + if (mode !== "write") return; + const paragraphElement = editedParagraphElement(event.target); + const paragraphId = paragraphElement?.dataset.paragraphId; + if (!project || !paragraphId) return; + const range = window.getSelection()?.rangeCount ? window.getSelection()!.getRangeAt(0) : null; + if (range && paragraphElement.contains(range.endContainer)) { + const before = document.createRange(); + before.selectNodeContents(paragraphElement); + before.setEnd(range.endContainer, range.endOffset); + pendingCaret.current = { paragraphId, offset: before.toString().length }; + } + project.updateParagraph(paragraphId, paragraphElement.textContent || ""); + }; + const publishCursor = (paragraphId: string, target: HTMLTextAreaElement) => { project?.setCursor(paragraphId, target.selectionStart, target.selectionEnd); }; @@ -1418,6 +1440,7 @@ function App({ config: configOverrides }: { config?: Partial } = {} contentEditable={mode === "write" && !isHistoryPreview} suppressContentEditableWarning onKeyDown={handleScreenplayKey} + onInput={handleScreenplayInput} onCopy={copyFormattedSelection} > {page.map((paragraph) => { @@ -1510,12 +1533,6 @@ function App({ config: configOverrides }: { config?: Partial } = {} paragraph.id === activeParagraphId, )} onFocus={() => setActiveParagraphId(paragraph.id)} - onInput={(event) => { - project?.updateParagraph( - paragraph.id, - event.currentTarget.textContent || "", - ); - }} onBlur={() => { setActiveParagraphId(null); void project?.commitParagraph(paragraph.id);