diff --git a/src/components/launch/NotesToolbar.test.tsx b/src/components/launch/NotesToolbar.test.tsx index 9ac483ccb..766e9c680 100644 --- a/src/components/launch/NotesToolbar.test.tsx +++ b/src/components/launch/NotesToolbar.test.tsx @@ -47,6 +47,7 @@ function createProps(overrides: Partial = {}): NotesToolbarPr return { editor: createEditor(), isPlaying: false, + formattingDisabled: false, speed: 40, fontSize: 16, mirrored: false, @@ -146,7 +147,7 @@ describe("NotesToolbar teleprompter controls", () => { rerender( - + , ); expect(screen.getByRole("button", { name: "Pause auto-scroll" })).not.toHaveAttribute( @@ -154,8 +155,9 @@ describe("NotesToolbar teleprompter controls", () => { ); }); - it("locks formatting while the teleprompter scrolls", () => { - renderToolbar(createProps({ isPlaying: true })); + it("locks formatting when the note is locked, keeping teleprompter controls live", () => { + // formattingDisabled without isPlaying is the mirrored-while-paused case. + renderToolbar(createProps({ formattingDisabled: true })); // Sweep the whole row rather than a hand-written list, so a formatting button added // later cannot quietly escape the lock. @@ -167,9 +169,12 @@ describe("NotesToolbar teleprompter controls", () => { expect(button).toBeDisabled(); } - // Teleprompter controls stay live so playback can always be stopped. - expect(screen.getByRole("button", { name: "Pause auto-scroll" })).toBeEnabled(); + // Teleprompter controls stay live so the lock can always be lifted, and the play + // button's label keeps tracking isPlaying alone rather than the lock. + expect(screen.getByRole("button", { name: "Start auto-scroll" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Mirror horizontally" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Decrease scroll speed" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Increase font size" })).toBeEnabled(); }); it("formats readout values for the active locale", () => { @@ -193,6 +198,7 @@ function ToolbarHarness() { {...createProps({ isPlaying, mirrored, + formattingDisabled: isPlaying || mirrored, onTogglePlaying: () => setIsPlaying((current) => !current), onToggleMirror: () => setMirrored((current) => !current), })} diff --git a/src/components/launch/NotesToolbar.tsx b/src/components/launch/NotesToolbar.tsx index c51c42e2a..ce8e9cc0b 100644 --- a/src/components/launch/NotesToolbar.tsx +++ b/src/components/launch/NotesToolbar.tsx @@ -27,6 +27,8 @@ import { export type NotesToolbarProps = { editor: Editor | null; isPlaying: boolean; + /** True while the note body rejects edits (playback or mirroring); disables formatting only. */ + formattingDisabled: boolean; speed: number; fontSize: number; mirrored: boolean; @@ -109,6 +111,7 @@ function useEditorRevision(editor: Editor | null): void { export function NotesToolbar({ editor, isPlaying, + formattingDisabled, speed, fontSize, mirrored, @@ -137,7 +140,7 @@ export function NotesToolbar({ aria-label={t("tooltips.notesToolbar.bold")} tooltipContent={t("tooltips.notesToolbar.bold")} active={editor?.isActive("bold") ?? false} - disabled={isPlaying || !editor?.can().chain().focus().toggleBold().run()} + disabled={formattingDisabled || !editor?.can().chain().focus().toggleBold().run()} onClick={() => editor?.chain().focus().toggleBold().run()} > @@ -146,7 +149,7 @@ export function NotesToolbar({ aria-label={t("tooltips.notesToolbar.italic")} tooltipContent={t("tooltips.notesToolbar.italic")} active={editor?.isActive("italic") ?? false} - disabled={isPlaying || !editor?.can().chain().focus().toggleItalic().run()} + disabled={formattingDisabled || !editor?.can().chain().focus().toggleItalic().run()} onClick={() => editor?.chain().focus().toggleItalic().run()} > @@ -155,7 +158,7 @@ export function NotesToolbar({ aria-label={t("tooltips.notesToolbar.strikethrough")} tooltipContent={t("tooltips.notesToolbar.strikethrough")} active={editor?.isActive("strike") ?? false} - disabled={isPlaying || !editor?.can().chain().focus().toggleStrike().run()} + disabled={formattingDisabled || !editor?.can().chain().focus().toggleStrike().run()} onClick={() => editor?.chain().focus().toggleStrike().run()} > @@ -169,7 +172,9 @@ export function NotesToolbar({ aria-label={t("tooltips.notesToolbar.bulletList")} tooltipContent={t("tooltips.notesToolbar.bulletList")} active={editor?.isActive("bulletList") ?? false} - disabled={isPlaying || !editor?.can().chain().focus().toggleBulletList().run()} + disabled={ + formattingDisabled || !editor?.can().chain().focus().toggleBulletList().run() + } onClick={() => editor?.chain().focus().toggleBulletList().run()} > @@ -178,7 +183,9 @@ export function NotesToolbar({ aria-label={t("tooltips.notesToolbar.numberedList")} tooltipContent={t("tooltips.notesToolbar.numberedList")} active={editor?.isActive("orderedList") ?? false} - disabled={isPlaying || !editor?.can().chain().focus().toggleOrderedList().run()} + disabled={ + formattingDisabled || !editor?.can().chain().focus().toggleOrderedList().run() + } onClick={() => editor?.chain().focus().toggleOrderedList().run()} > @@ -192,7 +199,9 @@ export function NotesToolbar({ aria-label={t("tooltips.notesToolbar.blockquote")} tooltipContent={t("tooltips.notesToolbar.blockquote")} active={editor?.isActive("blockquote") ?? false} - disabled={isPlaying || !editor?.can().chain().focus().toggleBlockquote().run()} + disabled={ + formattingDisabled || !editor?.can().chain().focus().toggleBlockquote().run() + } onClick={() => editor?.chain().focus().toggleBlockquote().run()} > @@ -201,7 +210,9 @@ export function NotesToolbar({ aria-label={t("tooltips.notesToolbar.codeBlock")} tooltipContent={t("tooltips.notesToolbar.codeBlock")} active={editor?.isActive("codeBlock") ?? false} - disabled={isPlaying || !editor?.can().chain().focus().toggleCodeBlock().run()} + disabled={ + formattingDisabled || !editor?.can().chain().focus().toggleCodeBlock().run() + } onClick={() => editor?.chain().focus().toggleCodeBlock().run()} > diff --git a/src/components/launch/NotesWindow.editable.test.tsx b/src/components/launch/NotesWindow.editable.test.tsx new file mode 100644 index 000000000..c13000cb0 --- /dev/null +++ b/src/components/launch/NotesWindow.editable.test.tsx @@ -0,0 +1,50 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import { NotesWindow } from "./NotesWindow"; + +// The sibling suite stubs `@tiptap/react`, so nothing in it can show that the real note ever +// stops accepting edits. `useEditor` honours `editable` only when it CREATES the editor and +// pins every later option pass to the editor's own `isEditable`, which makes `setEditable` the +// one path that carries a mirror toggle — and losing it, to a refactor or a Tiptap upgrade, +// leaves every mocked assertion green. So this one drives the real editor and reads +// `contenteditable` back off the DOM. (The constructor option stays pinned at the mocked +// boundary: React flushes the effect before a test can observe the first paint.) +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children, +})); + +beforeEach(() => { + localStorage.clear(); +}); + +function noteBody(): HTMLElement { + const body = document.querySelector(".tiptap"); + if (!body) { + throw new Error("the editor never mounted"); + } + + return body; +} + +describe("NotesWindow read-only wiring against the real editor", () => { + it("stops and resumes accepting edits with the mirror", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const mirror = screen.getByRole("button", { name: "Mirror horizontally" }); + + expect(noteBody()).toHaveAttribute("contenteditable", "true"); + + await user.click(mirror); + expect(noteBody()).toHaveAttribute("contenteditable", "false"); + + await user.click(mirror); + expect(noteBody()).toHaveAttribute("contenteditable", "true"); + }); +}); diff --git a/src/components/launch/NotesWindow.test.tsx b/src/components/launch/NotesWindow.test.tsx index aacdb107e..cb77abcb1 100644 --- a/src/components/launch/NotesWindow.test.tsx +++ b/src/components/launch/NotesWindow.test.tsx @@ -10,6 +10,7 @@ import { NOTES_TELEPROMPTER_STORAGE_KEY } from "./notesTeleprompter"; const tiptapState = vi.hoisted(() => ({ options: null as null | { content: string; + editable: boolean; onUpdate: (payload: { editor: { getHTML: () => string } }) => void; }, editor: null as Editor | null, @@ -266,6 +267,84 @@ describe("NotesWindow teleprompter mode", () => { expect(screen.getByRole("button", { name: "Bold" })).toBeEnabled(); }); + it("locks the note in every mirrored or playing state and unlocks only when both end", async () => { + const user = userEvent.setup(); + render(); + const bold = () => screen.getByRole("button", { name: "Bold" }); + + // Not playing, not mirrored: editable. + expect(setEditable).toHaveBeenLastCalledWith(true, false); + expect(bold()).toBeEnabled(); + + // Not playing, mirrored: locked. + await user.click(screen.getByRole("button", { name: "Mirror" })); + expect(setEditable).toHaveBeenLastCalledWith(false, false); + expect(bold()).toBeDisabled(); + + // Playing, mirrored: locked. + await user.click(screen.getByRole("button", { name: "Play" })); + expect(setEditable).toHaveBeenLastCalledWith(false, false); + expect(bold()).toBeDisabled(); + + // Paused again but still mirrored: stays locked. + await user.click(screen.getByRole("button", { name: "Pause" })); + expect(setEditable).toHaveBeenLastCalledWith(false, false); + expect(bold()).toBeDisabled(); + + // The teleprompter controls stay usable while the note is locked. + const content = screen.getByTestId("notes-teleprompter-content"); + await user.click(screen.getByRole("button", { name: "Increase font size" })); + expect(content).toHaveStyle({ fontSize: "18px" }); + + // Unmirrored while paused: editable again. + await user.click(screen.getByRole("button", { name: "Mirror" })); + expect(setEditable).toHaveBeenLastCalledWith(true, false); + expect(bold()).toBeEnabled(); + + // None of the state flips wrote note content. + expect(localStorage.getItem("notes")).toBeNull(); + }); + + it("locks the note from the first render when a mirrored setting is restored", () => { + localStorage.setItem( + NOTES_TELEPROMPTER_STORAGE_KEY, + JSON.stringify({ speed: 40, fontSize: 16, mirrored: true }), + ); + render(); + + // `useEditor` is stubbed here, so this pins the option we hand Tiptap rather than what + // Tiptap does with it: the editor must be CREATED read-only, because an effect-only lock + // would leave the first painted frame editable. That the real editor honours it — and + // keeps honouring the effect afterwards — is covered in NotesWindow.editable.test.tsx. + expect(tiptapState.options?.editable).toBe(false); + expect(setEditable).toHaveBeenLastCalledWith(false, false); + expect(setEditable).not.toHaveBeenCalledWith(true, false); + expect(screen.getByRole("button", { name: "Bold" })).toBeDisabled(); + // A restored mirror must not auto-start playback. + expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument(); + }); + + it("creates the editor editable when nothing locks it at mount", () => { + render(); + expect(tiptapState.options?.editable).toBe(true); + }); + + it("keeps mirroring and playback independent", async () => { + const user = userEvent.setup(); + render(); + const content = screen.getByTestId("notes-teleprompter-content"); + + // Mirroring must not start playback. + await user.click(screen.getByRole("button", { name: "Mirror" })); + expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument(); + expect(content).toHaveAttribute("data-mirrored", "true"); + + // Pausing must not unmirror. + await user.click(screen.getByRole("button", { name: "Play" })); + await user.click(screen.getByRole("button", { name: "Pause" })); + expect(content).toHaveAttribute("data-mirrored", "true"); + }); + it("applies and persists font and mirror settings without persisting playback", async () => { const user = userEvent.setup(); render(); diff --git a/src/components/launch/NotesWindow.tsx b/src/components/launch/NotesWindow.tsx index ab042f91f..cf5819ae9 100644 --- a/src/components/launch/NotesWindow.tsx +++ b/src/components/launch/NotesWindow.tsx @@ -23,10 +23,21 @@ export function NotesWindow() { const [settings, setSettings] = useState(loadNotesTeleprompterSettings); const [isPlaying, setIsPlaying] = useState(false); const [initialContent] = useState(loadInitialNotesContent); + + // Whether the note body currently rejects edits. Playback locks it because typing makes + // ProseMirror scroll the caret back into view, which fights the teleprompter. Mirroring + // locks it because caret placement and selection are horizontally reversed on screen, so + // the mirrored note is presentation-only. The teleprompter controls themselves stay live + // in both states; turning the lock's last reason off restores editing. + const editingLocked = isPlaying || settings.mirrored; + const editor = useEditor({ extensions: [StarterKit], content: initialContent, autofocus: "end", + // A restored mirror must lock the note from the very first paint — an effect runs + // only after the first commit, which would leave one editable frame. + editable: !editingLocked, editorProps: { attributes: { class: "tiptap", @@ -49,13 +60,10 @@ export function NotesWindow() { saveNotesTeleprompterSettings(settings); }, [settings]); - // Typing during playback makes ProseMirror scroll the caret back into view, which fights - // the teleprompter. Reading is the only sensible mode while it scrolls, so the note goes - // read-only and the toolbar disables formatting for as long as playback runs. // `emitUpdate: false` — the content did not change, so there is nothing to persist. useEffect(() => { - editor?.setEditable(!isPlaying, false); - }, [editor, isPlaying]); + editor?.setEditable(!editingLocked, false); + }, [editor, editingLocked]); useEffect(() => { if (!isPlaying || !editor) { @@ -127,6 +135,7 @@ export function NotesWindow() {