From 64d7c9a72c9a1625649cca4346412c3eefd08fca Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 5 Sep 2026 09:23:08 +0200 Subject: [PATCH] fix(editor): polish UI layout, slider gauge, icons and captions panel --- .../ai-edition/AudioTrackPane.test.tsx | 69 ++++++++ src/components/ai-edition/CaptionsPane.tsx | 69 ++++++-- .../NewEditorShell.chatOpen.test.tsx | 152 ++++++++++++++++++ .../ai-edition/NewEditorShell.module.css | 42 ++++- src/components/ai-edition/NewEditorShell.tsx | 9 +- src/components/ai-edition/RightPanes.tsx | 98 +++++++---- src/components/ai-edition/SliderCell.test.tsx | 65 ++++++++ .../TranscriptPane.captions.test.tsx | 12 ++ .../ai-edition/v4/FloatingInspector.test.tsx | 89 ++++++++++ .../ai-edition/v4/FloatingInspector.tsx | 20 +-- 10 files changed, 555 insertions(+), 70 deletions(-) create mode 100644 src/components/ai-edition/AudioTrackPane.test.tsx create mode 100644 src/components/ai-edition/NewEditorShell.chatOpen.test.tsx create mode 100644 src/components/ai-edition/SliderCell.test.tsx create mode 100644 src/components/ai-edition/v4/FloatingInspector.test.tsx diff --git a/src/components/ai-edition/AudioTrackPane.test.tsx b/src/components/ai-edition/AudioTrackPane.test.tsx new file mode 100644 index 000000000..010888217 --- /dev/null +++ b/src/components/ai-edition/AudioTrackPane.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/contexts/I18nContext", () => ({ + useScopedT: (scope: string) => (key: string) => `${scope}.${key}`, +})); + +import { AudioTrackPane } from "./RightPanes"; + +describe("AudioTrackPane", () => { + const createTl = () => ({ + selectedAudioTrackId: "track_1", + audioTracks: [ + { + id: "track_1", + clipId: "clip_1", + assetId: "asset_audio_1", + startSec: 0, + durationSec: 5, + gainDb: 0, + fadeInMs: 0, + fadeOutMs: 0, + offsetSec: 0, + }, + ], + assets: [ + { + id: "asset_audio_1", + kind: "audio" as const, + label: "voice.mp3", + originalPath: "/path/voice.mp3", + durationSec: 5, + }, + ], + clearSelection: vi.fn(), + selectAudioTrack: vi.fn(), + setAudioTrackGain: vi.fn(), + setAudioTrackFade: vi.fn(), + removeAudioTrack: vi.fn(), + }); + + it("renders close button and closes audio track by default", () => { + const tl = createTl(); + render(); + + const closeBtn = screen.getByRole("button", { name: "common.actions.close" }); + expect(closeBtn).toBeInTheDocument(); + const header = closeBtn.closest("header"); + expect(header).toHaveStyle({ paddingRight: "var(--sp-4)" }); + const svg = closeBtn.querySelector("svg"); + expect(svg?.classList.contains("lucide-x")).toBe(true); + + fireEvent.click(closeBtn); + expect(tl.clearSelection).toHaveBeenCalledTimes(1); + }); + + it("calls custom onClose if provided", () => { + const tl = createTl(); + const onClose = vi.fn(); + render(); + + const closeBtn = screen.getByRole("button", { name: "common.actions.close" }); + fireEvent.click(closeBtn); + expect(onClose).toHaveBeenCalledTimes(1); + expect(tl.selectAudioTrack).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ai-edition/CaptionsPane.tsx b/src/components/ai-edition/CaptionsPane.tsx index f109e4c6b..97276bcd1 100644 --- a/src/components/ai-edition/CaptionsPane.tsx +++ b/src/components/ai-edition/CaptionsPane.tsx @@ -9,7 +9,7 @@ // translation is stored beside the transcript, keyed by segment id, and picking // "Original" goes straight back to the SSOT text. -import { Captions as CaptionsIcon, Languages, Loader2, Trash2 } from "lucide-react"; +import { Captions as CaptionsIcon, Languages, Loader2, Trash2, X } from "lucide-react"; import { useMemo, useState } from "react"; import { useScopedT } from "@/contexts/I18nContext"; import type { CaptionAnchorH, CaptionAnchorV } from "@/lib/ai-edition/captions"; @@ -73,9 +73,10 @@ const TRANSLATION_LANGUAGES: ReadonlyArray<{ code: string; label: string }> = [ { code: "zh", label: "中文" }, ]; -export function CaptionsPane() { +export function CaptionsPane({ onClose }: { onClose?: () => void } = {}) { const t = useScopedT("settings"); const te = useScopedT("editor"); + const tc = useScopedT("common"); const { settings, translations, @@ -193,14 +194,47 @@ export function CaptionsPane() { }; return ( -
-
-

{t("facets.captions")}

- - +
+
+ + +

{t("facets.captions")}

+ {onClose ? ( + + ) : null}
-
+
{t("captions.show")} {/* The cue count is only meaningful while the layer is on — deriving @@ -284,7 +319,7 @@ export function CaptionsPane() { padding: "12px 14px", border: "1px solid var(--border)", borderRadius: 10, - background: "var(--surface-warm)", + background: "var(--surface-2)", display: "flex", flexDirection: "column", gap: 8, @@ -335,7 +370,7 @@ export function CaptionsPane() { value={target} disabled={disabled || translating} onChange={(e) => setTarget(e.target.value)} - style={{ ...selectStyle, flex: 1 }} + style={{ ...selectStyle, flex: 1, minWidth: 0 }} > {TRANSLATION_LANGUAGES.map((language) => (
+ ); + }, +})); + +import { NewEditorShell } from "./NewEditorShell"; + +function renderShell() { + return render( + + + , + ); +} + +describe("NewEditorShell chatOpen behavior with useChatPromptBus", () => { + beforeEach(() => { + useChatPromptBus.setState({ pending: null }); + (window as unknown as { electronAPI?: unknown }).electronAPI = { + onAiEditionChatEvent: () => () => {}, + setTitleBarOverlay: () => {}, + setHasUnsavedChanges: () => {}, + onRequestCloseConfirm: () => () => {}, + onRequestSaveBeforeClose: () => () => {}, + sendCloseConfirmResponse: () => {}, + findRecordingCamera: () => Promise.resolve(null), + preparePreviewAudioTrack: () => Promise.resolve(null), + }; + Element.prototype.scrollTo = () => {}; + (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver = class { + observe() { + // noop + } + unobserve() { + // noop + } + disconnect() { + // noop + } + }; + }); + + afterEach(() => { + cleanup(); + useChatPromptBus.setState({ pending: null }); + (window as unknown as { electronAPI?: unknown }).electronAPI = undefined; + }); + + it("initially starts with chat closed, and opens when useChatPromptBus receives a prompt", () => { + renderShell(); + + // Initially closed + expect( + screen.queryByRole("complementary", { name: "editor.shell.aiEditor" }), + ).not.toBeInTheDocument(); + const toggleBtn = screen.getByRole("button", { name: "editor.topbar.toggleChatPanel" }); + expect(toggleBtn).toHaveAttribute("aria-pressed", "false"); + + // Submit a prompt via the bus + act(() => { + useChatPromptBus.getState().submit("smart cut prompt"); + }); + + // Now open + expect( + screen.getByRole("complementary", { name: "editor.shell.aiEditor" }), + ).toBeInTheDocument(); + expect(toggleBtn).toHaveAttribute("aria-pressed", "true"); + }); + + it("supports the prompt flow: submit prompt -> opens -> consume -> close -> submit reopens", () => { + renderShell(); + + const toggleBtn = screen.getByRole("button", { name: "editor.topbar.toggleChatPanel" }); + + // 1. Submit prompt opens the chat panel + act(() => { + useChatPromptBus.getState().submit("first prompt"); + }); + expect( + screen.getByRole("complementary", { name: "editor.shell.aiEditor" }), + ).toBeInTheDocument(); + expect(useChatPromptBus.getState().pending).toBe("first prompt"); + + // 2. Consume prompt via send + const consumeBtn = screen.getByTestId("consume-prompt-btn"); + expect(consumeBtn).toHaveTextContent("send:first prompt"); + act(() => { + fireEvent.click(consumeBtn); + }); + expect(useChatPromptBus.getState().pending).toBeNull(); + + // 3. User closes the chat panel manually + act(() => { + fireEvent.click(toggleBtn); + }); + expect( + screen.queryByRole("complementary", { name: "editor.shell.aiEditor" }), + ).not.toBeInTheDocument(); + expect(toggleBtn).toHaveAttribute("aria-pressed", "false"); + + // 4. A newly delivered prompt re-opens the chat panel + act(() => { + useChatPromptBus.getState().submit("second prompt"); + }); + expect( + screen.getByRole("complementary", { name: "editor.shell.aiEditor" }), + ).toBeInTheDocument(); + expect(toggleBtn).toHaveAttribute("aria-pressed", "true"); + }); +}); diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index d1349d909..1c77efdf4 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -818,7 +818,8 @@ gap: 2px; padding: 3px; margin: 12px var(--sp-4); - background: var(--surface-warm); + background: var(--surface-2); + border: 1px solid var(--border); border-radius: 10px; } .paneTabs button { @@ -830,12 +831,16 @@ font: 500 var(--fs-app-sm)/1 var(--font-body); color: var(--muted); cursor: pointer; - transition: background 150ms ease, color 150ms ease; + transition: background var(--motion-fast) var(--ease), color var(--motion-fast) var(--ease); +} +.paneTabs button:hover:not(:disabled) { + color: var(--fg); } .paneTabs button.isActive { - background: var(--brand); - color: var(--accent-on); - box-shadow: 0 1px 2px rgba(22, 23, 29, 0.12); + background: var(--surface); + color: var(--fg-emphasis); + font-weight: 600; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); } .paneSection { display: none; } .paneSection.isActive { display: block; } @@ -1063,6 +1068,20 @@ scrollbar-color: var(--border) transparent; } +.captionsPopover { + width: 310px; + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface-1); + box-shadow: var(--elev-pop); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + max-height: min(var(--radix-popover-content-available-height, 80vh), 560px); + overflow: hidden; +} + /* ─── toggle (switch) ──────────────────────────────────────────── */ .toggle { width: 36px; @@ -1114,12 +1133,23 @@ .sliderCell input[type=range] { width: 100%; height: 4px; - background: var(--border-hi); + background: linear-gradient( + to right, + var(--brand) 0%, + var(--brand) var(--slider-pct, 0%), + var(--border-hi) var(--slider-pct, 0%), + var(--border-hi) 100% + ); border-radius: 2px; outline: none; -webkit-appearance: none; cursor: pointer; } +.sliderCell input[type=range]::-moz-range-progress { + background-color: var(--brand); + height: 4px; + border-radius: 2px; +} .sliderCell input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 6404c3fdb..47af43793 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -30,6 +30,7 @@ import { useTranscriptionStore, } from "@/lib/ai-edition/store/transcriptionStore"; import { useUndoRedoShortcuts } from "@/lib/ai-edition/store/undo"; +import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus"; import { useSequentialTimelineOps } from "@/lib/ai-edition/store/useSequentialTimelineOps"; import { useTimeline } from "@/lib/ai-edition/store/useTimeline"; import { isGeneratedAssetId } from "@/lib/ai-edition/timeline/clip-parts"; @@ -128,7 +129,13 @@ export function NewEditorShell() { // v4 shell: three modes (Media / Edit / Rec), a collapsible agent (chat) // column, and a floating facet inspector over the stage. const [mode, setMode] = useState("edit"); - const [chatOpen, setChatOpen] = useState(true); + const [chatOpen, setChatOpen] = useState(false); + const pendingChatPrompt = useChatPromptBus((s) => s.pending); + useEffect(() => { + if (pendingChatPrompt && !chatOpen) { + setChatOpen(true); + } + }, [pendingChatPrompt, chatOpen]); const [chatWidthPx, setChatWidthPx] = useState( () => Number(localStorage.getItem("os-editor-chat-width")) || 392, ); diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index ca70e8885..d81af75aa 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -7,11 +7,11 @@ import { AudioLines, + Camera, Captions as CaptionsIcon, ChevronDown, FileText, HelpCircle, - Layout as LayoutIcon, Loader2, Mic, MousePointerClick, @@ -20,6 +20,7 @@ import { Trash2, Undo2, Video, + X, } from "lucide-react"; import { @@ -119,16 +120,29 @@ interface PaneProps { // A control that belongs to the pane as a whole rather than to any one of its // rows, sitting left of the Help button. actions?: ReactNode; + onClose?: () => void; children: ReactNode; } -function Pane({ title, icon, helpText, actions, children }: PaneProps) { +function Pane({ title, icon, helpText, actions, onClose, children }: PaneProps) { const ts = useScopedT("settings"); + const tc = useScopedT("common"); const helpLabel = ts("panes.help"); const [helpOpen, setHelpOpen] = useState(false); return (
-
+
+ {icon ? ( + + {icon} + + ) : null}

{title}

{actions} @@ -142,8 +156,18 @@ function Pane({ title, icon, helpText, actions, children }: PaneProps) { > + {onClose ? ( + + ) : null} - {icon} {helpOpen ? (
- - + +
+ setOpen(false)} /> +
); @@ -2741,7 +2774,7 @@ export function LayoutPane() { setLive({ webcamCropPan: pan, webcamCropRegion: cropRegionFor(webcamCrop.width, pan) }); }; return ( - } helpText={helpText}> + } helpText={helpText}>
{ts("layout.preset")}
@@ -2847,31 +2880,23 @@ export function LayoutPane() { ) : null} {isPip ? (
-
-
- {ts("layout.webcamSize")} - {Math.round(settings.webcamSizePreset)}% -
- { - const next = Number(e.target.value); - setLive({ webcamSizePreset: next }); - if (isNativeCompositorActive()) { - setNativeParam("webcamSize", next / NATIVE_WEBCAM_BASE_PCT); - } - }} - onMouseUp={() => void commit()} - onTouchEnd={() => void commit()} - onKeyUp={() => void commit()} - /> -
+ { + setLive({ webcamSizePreset: next }); + if (isNativeCompositorActive()) { + setNativeParam("webcamSize", next / NATIVE_WEBCAM_BASE_PCT); + } + }} + onCommit={() => void commit()} + />
) : null} {/* Le seul contrôle de l'éditeur dont l'effet dépend d'un binaire optionnel : sans la @@ -3046,7 +3071,7 @@ const FADE_MAX_MS = 5000; * with the file name, then the volume, fade in/out, mute, and loop controls, * with actions to reset all parameters or delete the track. */ -export function AudioTrackPane({ tl }: { tl: TimelineApi }) { +export function AudioTrackPane({ tl, onClose }: { tl: TimelineApi; onClose?: () => void }) { const ts = useScopedT("settings"); const trackId = tl.selectedAudioTrackId; // The document stores one clip-anchored fragment per clip the track covers; @@ -3093,6 +3118,7 @@ export function AudioTrackPane({ tl }: { tl: TimelineApi }) { title={ts("audioTrack.defaultLabel")} icon={} helpText={ts("audioTrack.help")} + onClose={onClose ?? (() => tl.clearSelection())} >
min ? ((value - min) / (max - min)) * 100 : 0)); return ( -
+
{label} {showValue ? ( @@ -3479,6 +3508,7 @@ export function SliderCell({ step={step} value={value} disabled={disabled} + style={{ "--slider-pct": `${pct}%` } as CSSProperties} onChange={(e) => onChange(Number(e.target.value))} onMouseUp={onCommit} onTouchEnd={onCommit} diff --git a/src/components/ai-edition/SliderCell.test.tsx b/src/components/ai-edition/SliderCell.test.tsx new file mode 100644 index 000000000..77c5dc9f2 --- /dev/null +++ b/src/components/ai-edition/SliderCell.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SliderCell } from "./RightPanes"; + +describe("SliderCell", () => { + it("computes and sets --slider-pct gauge property correctly", () => { + render( + , + ); + const slider = screen.getByRole("slider", { name: "Volume" }); + expect(slider.style.getPropertyValue("--slider-pct")).toBe("25%"); + }); + + it("clamps --slider-pct to 0% and 100% at boundaries", () => { + const { rerender } = render( + , + ); + const slider = screen.getByRole("slider", { name: "Opacity" }); + expect(slider.style.getPropertyValue("--slider-pct")).toBe("0%"); + + rerender( + , + ); + expect(slider.style.getPropertyValue("--slider-pct")).toBe("100%"); + }); + + it("applies full layout class when full prop is true", () => { + const { container } = render( + , + ); + const cell = container.firstElementChild; + expect(cell?.className).toContain("full"); + }); +}); diff --git a/src/components/ai-edition/TranscriptPane.captions.test.tsx b/src/components/ai-edition/TranscriptPane.captions.test.tsx index 96aae2c17..39c9f4482 100644 --- a/src/components/ai-edition/TranscriptPane.captions.test.tsx +++ b/src/components/ai-edition/TranscriptPane.captions.test.tsx @@ -104,4 +104,16 @@ describe("caption settings on the transcript tab", () => { // mounted whole rather than reimplemented into the popover. expect(await screen.findByText("Show captions")).toBeInTheDocument(); }); + + it("renders a close button and closes the popover when clicked", async () => { + const user = userEvent.setup(); + mount([TRANSCRIPT]); + await user.click(screen.getByRole("button", { name: "Captions" })); + expect(await screen.findByText("Show captions")).toBeInTheDocument(); + + const closeBtn = screen.getByRole("button", { name: "Close" }); + expect(closeBtn).toBeInTheDocument(); + await user.click(closeBtn); + expect(screen.queryByText("Show captions")).not.toBeInTheDocument(); + }); }); diff --git a/src/components/ai-edition/v4/FloatingInspector.test.tsx b/src/components/ai-edition/v4/FloatingInspector.test.tsx new file mode 100644 index 000000000..39f644435 --- /dev/null +++ b/src/components/ai-edition/v4/FloatingInspector.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/contexts/I18nContext", () => ({ + useScopedT: (scope: string) => (key: string) => `${scope}.${key}`, +})); + +vi.mock("../RightPanes", () => ({ + AudioPane: () =>
AudioPane
, + AudioTrackPane: ({ onClose }: { onClose?: () => void }) => ( +
+ AudioTrackPane + {onClose ? ( + + ) : null} +
+ ), + CursorPane: () =>
CursorPane
, + LayoutPane: () =>
LayoutPane
, + SliderCell: () =>
SliderCell
, + Toggle: () =>
Toggle
, + TranscriptPane: () =>
TranscriptPane
, + VideoEffectsPane: () =>
VideoEffectsPane
, +})); + +vi.mock("../CaptionsPane", () => ({ + CaptionsPane: () =>
CaptionsPane
, +})); + +import { FloatingInspector } from "./FloatingInspector"; + +describe("FloatingInspector", () => { + const defaultProps: React.ComponentProps = { + facet: "layout" as const, + open: true, + onFacetChange: vi.fn(), + onToggleOpen: vi.fn(), + clips: [], + onEditClip: vi.fn(), + transcriptProps: {} as unknown as React.ComponentProps< + typeof FloatingInspector + >["transcriptProps"], + tl: { + selection: null, + clearSelection: vi.fn(), + selectedAudioTrackId: null, + selectAudioTrack: vi.fn(), + } as unknown as React.ComponentProps["tl"], + }; + + it("renders layout facet button on rail with camera icon and settings.layout.title", () => { + render(); + const layoutBtn = screen.getByRole("button", { name: "settings.layout.title" }); + expect(layoutBtn).toBeInTheDocument(); + // lucide Camera icon renders an svg with class lucide-camera + const svg = layoutBtn.querySelector("svg"); + expect(svg?.classList.contains("lucide-camera")).toBe(true); + }); + + it("renders collapse button with editor.inspector.collapseInspector and collapses inspector when clicked", () => { + const onToggleOpen = vi.fn(); + render(); + const collapseBtn = screen.getByRole("button", { name: "editor.inspector.collapseInspector" }); + expect(collapseBtn).toBeInTheDocument(); + const svg = collapseBtn.querySelector("svg"); + expect(svg?.classList.contains("lucide-chevron-right")).toBe(true); + + fireEvent.click(collapseBtn); + expect(onToggleOpen).toHaveBeenCalledTimes(1); + }); + + it("renders close button on AudioTrackPane when audio track is selected and deselects on click", () => { + const clearSelection = vi.fn(); + const tl = { + ...defaultProps.tl, + selectedAudioTrackId: "audio-1", + clearSelection, + }; + render(); + expect(screen.getByTestId("audio-track-pane")).toBeInTheDocument(); + const closeBtn = screen.getByRole("button", { name: "common.actions.close" }); + fireEvent.click(closeBtn); + expect(clearSelection).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx index 254da35f2..36c3da3cc 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -1,8 +1,8 @@ import { AudioLines, + Camera, ChevronRight, FileText, - Layout as LayoutIcon, Maximize2, MousePointer2, Pencil, @@ -63,7 +63,7 @@ const FACETS: Array<{ id: Facet; labelKey: string; icon: typeof SlidersHorizonta // Background is a SECTION of this facet now, not a facet of its own — see // VideoEffectsPane for why the split had nowhere to sit. { id: "effects", labelKey: "effects.title", icon: SlidersHorizontal }, - { id: "layout", labelKey: "layout.title", icon: LayoutIcon }, + { id: "layout", labelKey: "layout.title", icon: Camera }, { id: "audio", labelKey: "audio.title", icon: AudioLines }, { id: "cursor", labelKey: "cursor.title", icon: MousePointer2 }, { id: "transcript", labelKey: "facets.transcript", icon: FileText }, @@ -117,7 +117,7 @@ export function FloatingInspector({ const selection = tl.selection; // An imported audio track is selected (issue #350) — like a region selection it // takes over the inspector body with its own pane (see AudioTrackPane). - const audioTrackSelected = tl.selectedAudioTrackId !== null; + const audioTrackSelected = Boolean(tl.selectedAudioTrackId); const effectiveOpen = open || selection !== null || audioTrackSelected; return (
@@ -126,7 +126,7 @@ export function FloatingInspector({ {selection ? ( tl.clearSelection()} /> ) : audioTrackSelected ? ( - + tl.clearSelection()} /> ) : ( )} @@ -272,19 +272,13 @@ function paneHeader(icon: React.ReactNode, title: string, onClose: () => void, c