From c1598809221977bf9cd829def4e8cbcdca3127f6 Mon Sep 17 00:00:00 2001 From: My-Denia <176143450+My-Denia@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:34:28 +0800 Subject: [PATCH 1/3] fix(notes): make the mirrored note read-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While mirrored, caret placement and text selection are horizontally reversed on screen, so editing there is not a usable mode. The note body now locks whenever it is being presented rather than edited — during playback, as before, and now while mirrored — through a single derived editingLocked state; the formatting buttons disable with it via an explicit formattingDisabled prop instead of piggybacking on isPlaying. The play button's label and icon still track isPlaying alone, the teleprompter controls (play/pause, speed, font size, mirror) stay live while the note is locked, and turning the mirror off with playback paused restores editing. Mirroring never starts playback, pausing never lifts the mirror, and the persisted settings shape is unchanged (speed/fontSize/mirrored only). --- src/components/launch/NotesToolbar.test.tsx | 14 +++-- src/components/launch/NotesToolbar.tsx | 25 +++++--- src/components/launch/NotesWindow.test.tsx | 68 +++++++++++++++++++++ src/components/launch/NotesWindow.tsx | 15 +++-- 4 files changed, 106 insertions(+), 16 deletions(-) diff --git a/src/components/launch/NotesToolbar.test.tsx b/src/components/launch/NotesToolbar.test.tsx index 9ac483ccb..3ee81d6b3 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, @@ -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.test.tsx b/src/components/launch/NotesWindow.test.tsx index aacdb107e..44ff92454 100644 --- a/src/components/launch/NotesWindow.test.tsx +++ b/src/components/launch/NotesWindow.test.tsx @@ -266,6 +266,74 @@ 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(); + + 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("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..b16c3a168 100644 --- a/src/components/launch/NotesWindow.tsx +++ b/src/components/launch/NotesWindow.tsx @@ -49,13 +49,17 @@ 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. + // 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; + // `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 +131,7 @@ export function NotesWindow() { Date: Mon, 3 Aug 2026 13:05:43 +0800 Subject: [PATCH 2/3] fix(notes): create the editor read-only when a restored mirror locks it The lock was applied in an effect, which runs only after the first commit - with a persisted mirrored setting the first painted frame was briefly editable. The editor is now created with editable: !editingLocked, and the effect keeps handling every later transition. The mount test asserts the creation-time option as well. --- src/components/launch/NotesWindow.test.tsx | 9 +++++++++ src/components/launch/NotesWindow.tsx | 18 +++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/components/launch/NotesWindow.test.tsx b/src/components/launch/NotesWindow.test.tsx index 44ff92454..87989c452 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, @@ -311,6 +312,9 @@ describe("NotesWindow teleprompter mode", () => { ); render(); + // The editor must be CREATED read-only — an effect-only lock would leave the first + // painted frame editable. + expect(tiptapState.options?.editable).toBe(false); expect(setEditable).toHaveBeenLastCalledWith(false, false); expect(setEditable).not.toHaveBeenCalledWith(true, false); expect(screen.getByRole("button", { name: "Bold" })).toBeDisabled(); @@ -318,6 +322,11 @@ describe("NotesWindow teleprompter mode", () => { 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(); diff --git a/src/components/launch/NotesWindow.tsx b/src/components/launch/NotesWindow.tsx index b16c3a168..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,6 @@ export function NotesWindow() { saveNotesTeleprompterSettings(settings); }, [settings]); - // 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; - // `emitUpdate: false` — the content did not change, so there is nothing to persist. useEffect(() => { editor?.setEditable(!editingLocked, false); From 1212ab321cdee5eff2162854324864fec72c5d1e Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 3 Aug 2026 08:34:46 +0200 Subject: [PATCH 3/3] test(notes): pin the read-only lock to the real editor The teleprompter suite stubs @tiptap/react wholesale, so its read-only assertions pin the props handed to Tiptap rather than what Tiptap does with them. useEditor honours `editable` at creation and then pins every later option pass to the editor own isEditable, which leaves setEditable as the single path carrying a mirror toggle. Losing it - to a refactor or a Tiptap upgrade - would leave every mocked assertion green with a mirrored note still editable, the exact bug this branch fixes. NotesWindow.editable.test.tsx drives the real editor through a mirror toggle and reads contenteditable back off the DOM; removing the setEditable effect fails it. The constructor option stays pinned at the mocked boundary, since React flushes the effect before a test can observe the first paint - the comment there now says so rather than claiming the mock proves creation-time behaviour. Also stop the playback-label test from rendering isPlaying with formatting still enabled, a state NotesWindow cannot produce. --- src/components/launch/NotesToolbar.test.tsx | 2 +- .../launch/NotesWindow.editable.test.tsx | 50 +++++++++++++++++++ src/components/launch/NotesWindow.test.tsx | 6 ++- 3 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 src/components/launch/NotesWindow.editable.test.tsx diff --git a/src/components/launch/NotesToolbar.test.tsx b/src/components/launch/NotesToolbar.test.tsx index 3ee81d6b3..766e9c680 100644 --- a/src/components/launch/NotesToolbar.test.tsx +++ b/src/components/launch/NotesToolbar.test.tsx @@ -147,7 +147,7 @@ describe("NotesToolbar teleprompter controls", () => { rerender( - + , ); expect(screen.getByRole("button", { name: "Pause auto-scroll" })).not.toHaveAttribute( 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 87989c452..cb77abcb1 100644 --- a/src/components/launch/NotesWindow.test.tsx +++ b/src/components/launch/NotesWindow.test.tsx @@ -312,8 +312,10 @@ describe("NotesWindow teleprompter mode", () => { ); render(); - // The editor must be CREATED read-only — an effect-only lock would leave the first - // painted frame editable. + // `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);