Skip to content
Merged
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
56 changes: 56 additions & 0 deletions app/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <T,>(read: () => T | null | undefined, timeout = 5000): Promise<T> => {
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<HTMLElement>(".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<HTMLElement>(".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);
});
41 changes: 29 additions & 12 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -891,22 +891,44 @@ function App({ config: configOverrides }: { config?: Partial<WriteConfig> } = {}
}
};

const handleScreenplayKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (mode !== "write") return;
if (deleteSelectionAcrossElements(event)) return;
const editedParagraphElement = (target: EventTarget) => {
const anchorNode = window.getSelection()?.anchorNode;
const paragraphElement =
(event.target as HTMLElement).closest<HTMLElement>(".paragraph-editor[data-paragraph-id]") ||
return (
(target as HTMLElement).closest<HTMLElement>(".paragraph-editor[data-paragraph-id]") ||
(anchorNode?.nodeType === Node.ELEMENT_NODE
? (anchorNode as Element)
: anchorNode?.parentElement
)?.closest<HTMLElement>(".paragraph-editor[data-paragraph-id]");
)?.closest<HTMLElement>(".paragraph-editor[data-paragraph-id]")
);
};

const handleScreenplayKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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);
};
Expand Down Expand Up @@ -1418,6 +1440,7 @@ function App({ config: configOverrides }: { config?: Partial<WriteConfig> } = {}
contentEditable={mode === "write" && !isHistoryPreview}
suppressContentEditableWarning
onKeyDown={handleScreenplayKey}
onInput={handleScreenplayInput}
onCopy={copyFormattedSelection}
>
{page.map((paragraph) => {
Expand Down Expand Up @@ -1510,12 +1533,6 @@ function App({ config: configOverrides }: { config?: Partial<WriteConfig> } = {}
paragraph.id === activeParagraphId,
)}
onFocus={() => setActiveParagraphId(paragraph.id)}
onInput={(event) => {
project?.updateParagraph(
paragraph.id,
event.currentTarget.textContent || "",
);
}}
onBlur={() => {
setActiveParagraphId(null);
void project?.commitParagraph(paragraph.id);
Expand Down
Loading