From e7852950ed87cecb12702ebcbfd811e9baa69890 Mon Sep 17 00:00:00 2001 From: My-Denia <176143450+My-Denia@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:03:27 +0800 Subject: [PATCH 1/2] feat: add Notes teleprompter mode --- .../launch/NotesToolbar.browser.test.tsx | 131 ++++++++ src/components/launch/NotesToolbar.test.tsx | 117 +++++++ src/components/launch/NotesToolbar.tsx | 299 +++++++++++++----- src/components/launch/NotesWindow.module.css | 20 +- src/components/launch/NotesWindow.test.tsx | 216 +++++++++++++ src/components/launch/NotesWindow.tsx | 121 +++++-- .../launch/notesTeleprompter.test.ts | 134 ++++++++ src/components/launch/notesTeleprompter.ts | 167 ++++++++++ src/i18n/locales/ar/launch.json | 11 +- src/i18n/locales/en/launch.json | 11 +- src/i18n/locales/es/launch.json | 11 +- src/i18n/locales/fr/launch.json | 11 +- src/i18n/locales/it/launch.json | 11 +- src/i18n/locales/ja-JP/launch.json | 11 +- src/i18n/locales/ko-KR/launch.json | 11 +- src/i18n/locales/pt-BR/launch.json | 11 +- src/i18n/locales/ru/launch.json | 11 +- src/i18n/locales/tr/launch.json | 11 +- src/i18n/locales/vi/launch.json | 11 +- src/i18n/locales/zh-CN/launch.json | 11 +- src/i18n/locales/zh-TW/launch.json | 11 +- 21 files changed, 1232 insertions(+), 116 deletions(-) create mode 100644 src/components/launch/NotesToolbar.browser.test.tsx create mode 100644 src/components/launch/NotesToolbar.test.tsx create mode 100644 src/components/launch/NotesWindow.test.tsx create mode 100644 src/components/launch/notesTeleprompter.test.ts create mode 100644 src/components/launch/notesTeleprompter.ts diff --git a/src/components/launch/NotesToolbar.browser.test.tsx b/src/components/launch/NotesToolbar.browser.test.tsx new file mode 100644 index 000000000..47889c730 --- /dev/null +++ b/src/components/launch/NotesToolbar.browser.test.tsx @@ -0,0 +1,131 @@ +import "@/index.css"; +import type { Editor } from "@tiptap/react"; +import { useState } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; +import { NotesToolbar } from "./NotesToolbar"; + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock("@/contexts/I18nContext", () => ({ + useScopedT: () => (key: string) => { + const labels: Record = { + "tooltips.notesToolbar.play": "Play", + "tooltips.notesToolbar.pause": "Pause", + "tooltips.notesToolbar.speed": "Scroll speed", + "tooltips.notesToolbar.decreaseSpeed": "Decrease scroll speed", + "tooltips.notesToolbar.increaseSpeed": "Increase scroll speed", + "tooltips.notesToolbar.fontSize": "Font size", + "tooltips.notesToolbar.decreaseFontSize": "Decrease font size", + "tooltips.notesToolbar.increaseFontSize": "Increase font size", + "tooltips.notesToolbar.mirror": "Mirror", + }; + return labels[key] ?? key; + }, +})); + +function createEditor(): Editor { + const chain: Record> = {}; + for (const command of [ + "focus", + "toggleBold", + "toggleItalic", + "toggleStrike", + "toggleBulletList", + "toggleOrderedList", + "toggleBlockquote", + "toggleCodeBlock", + ]) { + chain[command] = vi.fn(() => chain); + } + chain.run = vi.fn(() => true); + + return { + can: () => ({ chain: () => chain }), + chain: () => chain, + isActive: () => false, + on: vi.fn(), + off: vi.fn(), + } as unknown as Editor; +} + +function ToolbarHarness() { + const [isPlaying, setIsPlaying] = useState(false); + const [speed, setSpeed] = useState(40); + const [fontSize, setFontSize] = useState(16); + const [mirrored, setMirrored] = useState(false); + + return ( + setIsPlaying((current) => !current)} + onDecreaseSpeed={() => setSpeed((current) => current - 10)} + onIncreaseSpeed={() => setSpeed((current) => current + 10)} + onDecreaseFontSize={() => setFontSize((current) => current - 2)} + onIncreaseFontSize={() => setFontSize((current) => current + 2)} + onToggleMirror={() => setMirrored((current) => !current)} + /> + ); +} + +describe("NotesToolbar narrow-width reachability", () => { + let root: Root | null = null; + let host: HTMLDivElement | null = null; + + afterEach(() => { + root?.unmount(); + host?.remove(); + root = null; + host = null; + }); + + it("keeps every teleprompter control reachable at a severe 160px width", async () => { + host = document.createElement("div"); + host.style.width = "160px"; + document.body.append(host); + root = createRoot(host); + flushSync(() => root?.render()); + + const row = host.querySelector('[data-testid="notes-teleprompter-controls"]'); + const controls = Array.from( + host.querySelectorAll("[data-teleprompter-control]"), + ); + if (!row || controls.length === 0) { + throw new Error("Teleprompter controls did not render"); + } + + expect(row.scrollWidth).toBeGreaterThan(row.clientWidth); + await userEvent.wheel(row, { delta: { x: row.scrollWidth } }); + await new Promise(requestAnimationFrame); + expect(row.scrollLeft).toBeGreaterThan(0); + + const rowRect = row.getBoundingClientRect(); + const lastRect = controls.at(-1)?.getBoundingClientRect(); + expect(lastRect).toBeDefined(); + expect(lastRect!.left).toBeGreaterThanOrEqual(rowRect.left - 1); + expect(lastRect!.right).toBeLessThanOrEqual(rowRect.right + 1); + + controls[0]?.focus(); + expect(document.activeElement).toBe(controls[0]); + for (let index = 1; index < controls.length; index++) { + await userEvent.tab(); + expect(document.activeElement).toBe(controls[index]); + } + + controls[0]?.focus(); + await userEvent.keyboard("{Enter}"); + expect(controls[0]?.getAttribute("aria-label")).toBe("Pause"); + + const mirror = controls.at(-1)!; + await userEvent.click(mirror); + expect(mirror.getAttribute("aria-pressed")).toBe("true"); + }); +}); diff --git a/src/components/launch/NotesToolbar.test.tsx b/src/components/launch/NotesToolbar.test.tsx new file mode 100644 index 000000000..e95e04c1a --- /dev/null +++ b/src/components/launch/NotesToolbar.test.tsx @@ -0,0 +1,117 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Editor } from "@tiptap/react"; +import { describe, expect, it, vi } from "vitest"; +import { NotesToolbar, type NotesToolbarProps } from "./NotesToolbar"; + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock("@/contexts/I18nContext", () => ({ + useScopedT: () => (key: string) => { + const labels: Record = { + "tooltips.notesToolbar.play": "Play", + "tooltips.notesToolbar.pause": "Pause", + "tooltips.notesToolbar.speed": "Scroll speed", + "tooltips.notesToolbar.decreaseSpeed": "Decrease scroll speed", + "tooltips.notesToolbar.increaseSpeed": "Increase scroll speed", + "tooltips.notesToolbar.fontSize": "Font size", + "tooltips.notesToolbar.decreaseFontSize": "Decrease font size", + "tooltips.notesToolbar.increaseFontSize": "Increase font size", + "tooltips.notesToolbar.mirror": "Mirror", + }; + return labels[key] ?? key; + }, +})); + +function createEditor(): Editor { + const chain: Record> = {}; + for (const command of [ + "focus", + "toggleBold", + "toggleItalic", + "toggleStrike", + "toggleBulletList", + "toggleOrderedList", + "toggleBlockquote", + "toggleCodeBlock", + ]) { + chain[command] = vi.fn(() => chain); + } + chain.run = vi.fn(() => true); + + return { + can: () => ({ chain: () => chain }), + chain: () => chain, + isActive: () => false, + on: vi.fn(), + off: vi.fn(), + } as unknown as Editor; +} + +function createProps(overrides: Partial = {}): NotesToolbarProps { + return { + editor: createEditor(), + isPlaying: false, + speed: 40, + fontSize: 16, + mirrored: false, + onTogglePlaying: vi.fn(), + onDecreaseSpeed: vi.fn(), + onIncreaseSpeed: vi.fn(), + onDecreaseFontSize: vi.fn(), + onIncreaseFontSize: vi.fn(), + onToggleMirror: vi.fn(), + ...overrides, + }; +} + +describe("NotesToolbar teleprompter controls", () => { + it("exposes values and dispatches every manual control", async () => { + const user = userEvent.setup(); + const props = createProps(); + render(); + + expect(screen.getByRole("status", { name: "Scroll speed" })).toHaveTextContent("40 px/s"); + expect(screen.getByRole("status", { name: "Font size" })).toHaveTextContent("16 px"); + expect(screen.getByRole("button", { name: "Mirror" })).toHaveAttribute("aria-pressed", "false"); + expect(screen.getByRole("button", { name: "Decrease scroll speed" })).not.toHaveAttribute( + "aria-pressed", + ); + expect(screen.getByRole("button", { name: "Increase font size" })).not.toHaveAttribute( + "aria-pressed", + ); + + await user.click(screen.getByRole("button", { name: "Play" })); + await user.click(screen.getByRole("button", { name: "Decrease scroll speed" })); + await user.click(screen.getByRole("button", { name: "Increase scroll speed" })); + await user.click(screen.getByRole("button", { name: "Decrease font size" })); + await user.click(screen.getByRole("button", { name: "Increase font size" })); + await user.click(screen.getByRole("button", { name: "Mirror" })); + + expect(props.onTogglePlaying).toHaveBeenCalledOnce(); + expect(props.onDecreaseSpeed).toHaveBeenCalledOnce(); + expect(props.onIncreaseSpeed).toHaveBeenCalledOnce(); + expect(props.onDecreaseFontSize).toHaveBeenCalledOnce(); + expect(props.onIncreaseFontSize).toHaveBeenCalledOnce(); + expect(props.onToggleMirror).toHaveBeenCalledOnce(); + }); + + it("disables controls at their bounds", () => { + const { rerender } = render(); + expect(screen.getByRole("button", { name: "Decrease scroll speed" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Decrease font size" })).toBeDisabled(); + + rerender(); + expect(screen.getByRole("button", { name: "Increase scroll speed" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Increase font size" })).toBeDisabled(); + }); + + it("keeps playback paused and disabled until the editor is ready", () => { + render(); + expect(screen.getByRole("button", { name: "Play" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Pause" })).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/launch/NotesToolbar.tsx b/src/components/launch/NotesToolbar.tsx index c744b525c..245347468 100644 --- a/src/components/launch/NotesToolbar.tsx +++ b/src/components/launch/NotesToolbar.tsx @@ -1,12 +1,41 @@ import type { Editor } from "@tiptap/react"; -import { Bold, Code, Italic, List, ListOrdered, Quote, Strikethrough } from "lucide-react"; +import { + Bold, + Code, + FlipHorizontal2, + Italic, + List, + ListOrdered, + Minus, + Pause, + Play, + Plus, + Quote, + Strikethrough, +} from "lucide-react"; import { type ReactNode, useEffect, useReducer } from "react"; import { Tooltip } from "@/components/ui/tooltip"; import { useScopedT } from "@/contexts/I18nContext"; import { cn } from "@/lib/utils"; +import { + MAX_NOTES_FONT_SIZE, + MAX_TELEPROMPTER_SPEED, + MIN_NOTES_FONT_SIZE, + MIN_TELEPROMPTER_SPEED, +} from "./notesTeleprompter"; -type NotesToolbarProps = { +export type NotesToolbarProps = { editor: Editor | null; + isPlaying: boolean; + speed: number; + fontSize: number; + mirrored: boolean; + onTogglePlaying: () => void; + onDecreaseSpeed: () => void; + onIncreaseSpeed: () => void; + onDecreaseFontSize: () => void; + onIncreaseFontSize: () => void; + onToggleMirror: () => void; }; type ToolbarButtonProps = { @@ -14,6 +43,7 @@ type ToolbarButtonProps = { tooltipContent: string; active?: boolean; disabled?: boolean; + teleprompterControl?: boolean; onClick: () => void; children: ReactNode; }; @@ -21,8 +51,9 @@ type ToolbarButtonProps = { function ToolbarButton({ "aria-label": ariaLabel, tooltipContent, - active = false, + active, disabled = false, + teleprompterControl = false, onClick, children, }: ToolbarButtonProps) { @@ -33,9 +64,10 @@ function ToolbarButton({ aria-label={ariaLabel} aria-pressed={active} disabled={disabled} + data-teleprompter-control={teleprompterControl ? "" : undefined} onClick={onClick} className={cn( - "shrink-0 inline-flex h-8 w-8 items-center justify-center rounded-md border-0 bg-transparent text-gray-700 transition-colors hover:bg-gray-200 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-35", + "shrink-0 inline-flex h-8 w-8 items-center justify-center rounded-md border-0 bg-transparent text-gray-700 transition-colors hover:bg-gray-200 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-violet-600 disabled:cursor-not-allowed disabled:opacity-35", active && "bg-gray-900 text-white hover:bg-gray-800 hover:text-white", )} > @@ -67,86 +99,199 @@ function useEditorRevision(editor: Editor | null): void { }, [editor]); } -export function NotesToolbar({ editor }: NotesToolbarProps) { +export function NotesToolbar({ + editor, + isPlaying, + speed, + fontSize, + mirrored, + onTogglePlaying, + onDecreaseSpeed, + onIncreaseSpeed, + onDecreaseFontSize, + onIncreaseFontSize, + onToggleMirror, +}: NotesToolbarProps) { useEditorRevision(editor); const t = useScopedT("launch"); return ( -
-
- editor?.chain().focus().toggleBold().run()} - > - - - editor?.chain().focus().toggleItalic().run()} - > - - - editor?.chain().focus().toggleStrike().run()} - > - - -
-
-
-