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
16 changes: 11 additions & 5 deletions src/components/launch/NotesToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function createProps(overrides: Partial<NotesToolbarProps> = {}): NotesToolbarPr
return {
editor: createEditor(),
isPlaying: false,
formattingDisabled: false,
speed: 40,
fontSize: 16,
mirrored: false,
Expand Down Expand Up @@ -146,16 +147,17 @@ describe("NotesToolbar teleprompter controls", () => {

rerender(
<I18nProvider>
<NotesToolbar {...createProps({ isPlaying: true })} />
<NotesToolbar {...createProps({ isPlaying: true, formattingDisabled: true })} />
</I18nProvider>,
);
expect(screen.getByRole("button", { name: "Pause auto-scroll" })).not.toHaveAttribute(
"aria-pressed",
);
});

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.
Expand All @@ -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", () => {
Expand All @@ -193,6 +198,7 @@ function ToolbarHarness() {
{...createProps({
isPlaying,
mirrored,
formattingDisabled: isPlaying || mirrored,
onTogglePlaying: () => setIsPlaying((current) => !current),
onToggleMirror: () => setMirrored((current) => !current),
})}
Expand Down
25 changes: 18 additions & 7 deletions src/components/launch/NotesToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -109,6 +111,7 @@ function useEditorRevision(editor: Editor | null): void {
export function NotesToolbar({
editor,
isPlaying,
formattingDisabled,
speed,
fontSize,
mirrored,
Expand Down Expand Up @@ -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()}
>
<Bold size={16} />
Expand All @@ -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()}
>
<Italic size={16} />
Expand All @@ -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()}
>
<Strikethrough size={16} />
Expand All @@ -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()}
>
<List size={16} />
Expand All @@ -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()}
>
<ListOrdered size={16} />
Expand All @@ -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()}
>
<Quote size={16} />
Expand All @@ -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()}
>
<Code size={16} />
Expand Down
50 changes: 50 additions & 0 deletions src/components/launch/NotesWindow.editable.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(".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(
<I18nProvider>
<NotesWindow />
</I18nProvider>,
);
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");
});
});
79 changes: 79 additions & 0 deletions src/components/launch/NotesWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(<NotesWindow />);
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(<NotesWindow />);

// `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(<NotesWindow />);
expect(tiptapState.options?.editable).toBe(true);
});

it("keeps mirroring and playback independent", async () => {
const user = userEvent.setup();
render(<NotesWindow />);
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(<NotesWindow />);
Expand Down
19 changes: 14 additions & 5 deletions src/components/launch/NotesWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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) {
Expand Down Expand Up @@ -127,6 +135,7 @@ export function NotesWindow() {
<NotesToolbar
editor={editor}
isPlaying={isPlaying}
formattingDisabled={editingLocked}
speed={settings.speed}
fontSize={settings.fontSize}
mirrored={settings.mirrored}
Expand Down
Loading