From 950c97dcb2b46f87e7159ffd8ef7d42df404ee6d Mon Sep 17 00:00:00 2001 From: Loren Segal Date: Sat, 3 Oct 2026 14:11:48 -0700 Subject: [PATCH 1/3] Start work on issue #1 [skip ci] From ad1c8acd6574fb92b3997b3ff5570a290f703aa0 Mon Sep 17 00:00:00 2001 From: Loren Segal Date: Sat, 3 Oct 2026 14:22:18 -0700 Subject: [PATCH 2/3] Checkpoint issue #1 progress --- app/src/App.tsx | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 6f3fd22..de1d96f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -891,22 +891,36 @@ 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 (paragraphId) project?.updateParagraph(paragraphId, paragraphElement.textContent || ""); + }; + const publishCursor = (paragraphId: string, target: HTMLTextAreaElement) => { project?.setCursor(paragraphId, target.selectionStart, target.selectionEnd); }; @@ -1418,6 +1432,7 @@ function App({ config: configOverrides }: { config?: Partial } = {} contentEditable={mode === "write" && !isHistoryPreview} suppressContentEditableWarning onKeyDown={handleScreenplayKey} + onInput={handleScreenplayInput} onCopy={copyFormattedSelection} > {page.map((paragraph) => { @@ -1510,12 +1525,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); From e023ef736446d6dd481993094b173b0080f2674d Mon Sep 17 00:00:00 2001 From: Loren Segal Date: Sat, 3 Oct 2026 14:39:44 -0700 Subject: [PATCH 3/3] Fix typed text never reaching the shared draft Input events fire on the contentEditable host rather than the paragraph being typed in, so the per-paragraph onInput handler never ran and typing was only written to the Yjs document on Enter or Tab. Handle input on the host, resolve the edited paragraph from the selection, and preserve the caret across the resulting re-render. Closes #1 --- app/src/App.test.tsx | 56 ++++++++++++++++++++++++++++++++++++++++++++ app/src/App.tsx | 10 +++++++- 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 app/src/App.test.tsx 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 de1d96f..50a6ed7 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -918,7 +918,15 @@ function App({ config: configOverrides }: { config?: Partial } = {} if (mode !== "write") return; const paragraphElement = editedParagraphElement(event.target); const paragraphId = paragraphElement?.dataset.paragraphId; - if (paragraphId) project?.updateParagraph(paragraphId, paragraphElement.textContent || ""); + 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) => {