From dbf8055f0bb4a18edb150ae0e5db6b8af240786c Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 1 Jul 2026 22:48:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(preview):=20on-canvas=20Transform=20overla?= =?UTF-8?q?y=20=E2=80=94=20drag=20to=20move/resize=20the=20selected=20clip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port upstream TransformOverlayView: when a single visual clip is selected, draw a bounding box with 4 corner resize handles + a move surface over the composited preview canvas, committing to the clip's transform. Faithful to what upstream ACTUALLY has — corners only (no edge handles) and NO rotation handle (upstream sets rotation only via the Inspector field; grep atan2/rotationHandle across the upstream repo = zero hits), so neither is invented. - clip.ts: moveTransformByDelta (normalized center translate + canvas-edge/center snap when unrotated, preserving upstream's single-width-threshold quirk), rotateDeltaIntoLocalFrame (R(-theta) so a screen-space corner drag maps into the rotated box's local frame; move deltas stay raw, rotation-invariant), sampledTransform (topLeft/size/rotation samplers -> center Transform = upstream clip.transformAt(frame:), so the box tracks keyframed transform), and findSelectedVisualClip. resizeTransformFromCorner reused unchanged. 20 tests. - TransformOverlay.tsx: the box + handles; container pointerEvents:none with only the handles/move-surface interactive (never blocks the preview); local-optimistic drag preview, ONE setClipProperties on pointerup (avoids per-move undo-stack spam / IPC latency — KeyframesLaneRow's pattern). 6 render tests. - Preview.tsx: surgical mount inside the canvas div, gated on a single selected visual clip. 4 negative-mount tests. Scope cuts (documented in code): pink center-snap GUIDE LINES (functional snap ported, guide rendering not); text-clip fontScale resize (no applyTextStyle wired yet — text resizes via transform). On-canvas drag still needs a real-machine visual pass (headless/Dock-blocked here); tsc + unit tests are green. Verified: pnpm build (tsc) clean; pnpm test 239 passed (+30). --- web/src/components/preview/Preview.test.tsx | 65 +++++ web/src/components/preview/Preview.tsx | 26 ++ .../preview/TransformOverlay.test.tsx | 135 ++++++++++ .../components/preview/TransformOverlay.tsx | 214 ++++++++++++++++ web/src/lib/clip.test.ts | 232 +++++++++++++++++- web/src/lib/clip.ts | 152 ++++++++++++ 6 files changed, 823 insertions(+), 1 deletion(-) create mode 100644 web/src/components/preview/TransformOverlay.test.tsx create mode 100644 web/src/components/preview/TransformOverlay.tsx diff --git a/web/src/components/preview/Preview.test.tsx b/web/src/components/preview/Preview.test.tsx index 245dd7e..18a05a5 100644 --- a/web/src/components/preview/Preview.test.tsx +++ b/web/src/components/preview/Preview.test.tsx @@ -11,6 +11,7 @@ const store = vi.hoisted(() => ({ isPlaying: false, isScrubbing: false, previewMediaId: null as string | null, + selectedClipIds: new Set(), setCurrentFrame: vi.fn(), setScrubbing: vi.fn(), togglePlay: vi.fn(), @@ -109,6 +110,7 @@ describe("Preview timeline rendering", () => { isPlaying: false, isScrubbing: false, previewMediaId: null, + selectedClipIds: new Set(), }; store.media.items = [ { id: "base", name: "base", type: "video", duration: 10, hasAudio: true, path: "/base.mov" }, @@ -187,4 +189,67 @@ describe("Preview timeline rendering", () => { expect(html).toContain(" { + beforeEach(() => { + store.timeline = timeline([ + track({ + id: "v1", + type: "video", + clips: [ + clip({ id: "base-clip", mediaRef: "base", mediaType: "video" }), + clip({ id: "text-clip", mediaRef: "base", mediaType: "text", startFrame: 10 }), + ], + }), + track({ + id: "a1", + type: "audio", + clips: [clip({ id: "audio-clip", mediaRef: "base", mediaType: "audio", startFrame: 20 })], + }), + ]); + }); + + it("stays hidden when no clip is selected", () => { + store.ui.selectedClipIds = new Set(); + + const html = renderToStaticMarkup(); + + expect(html).not.toContain('data-testid="transform-overlay"'); + }); + + it("stays hidden when more than one clip is selected (marquee)", () => { + store.ui.selectedClipIds = new Set(["base-clip", "text-clip"]); + + const html = renderToStaticMarkup(); + + expect(html).not.toContain('data-testid="transform-overlay"'); + }); + + it("stays hidden when the single selected clip is on an audio track", () => { + store.ui.selectedClipIds = new Set(["audio-clip"]); + + const html = renderToStaticMarkup(); + + expect(html).not.toContain('data-testid="transform-overlay"'); + }); + + it("stays hidden while viewing a media-library preview, even with a visual clip selected", () => { + store.ui.selectedClipIds = new Set(["base-clip"]); + store.ui.previewMediaId = "still"; + store.media.items = [ + { id: "still", name: "still", type: "image", duration: 1, hasAudio: false, path: "/still.png" }, + ]; + + const html = renderToStaticMarkup(); + + expect(html).not.toContain('data-testid="transform-overlay"'); + }); + }); }); diff --git a/web/src/components/preview/Preview.tsx b/web/src/components/preview/Preview.tsx index b53d84c..286ca68 100644 --- a/web/src/components/preview/Preview.tsx +++ b/web/src/components/preview/Preview.tsx @@ -22,6 +22,7 @@ import { useMediaStore } from "../../store/mediaStore"; import { formatTimecode, totalFrames } from "../../lib/geometry"; import { assetUrl } from "../../lib/asset"; import { TimelinePlayback } from "./TimelinePlaybackLayer"; +import { TransformOverlay } from "./TransformOverlay"; import { aspectFitBox, timelinePreviewCanvasStyle } from "./previewLayerStyles"; import { useT } from "../../i18n"; import { @@ -32,6 +33,7 @@ import { } from "../../lib/api"; import { rustEngineEnabled } from "./rustEngine"; import { shouldUseRustEngine } from "./timelinePlayback"; +import { findSelectedVisualClip, mediaCanvasAspect } from "../../lib/clip"; import type { MediaItem } from "../../lib/types"; export function Preview() { @@ -43,10 +45,27 @@ export function Preview() { const setScrubbing = useEditorUiStore((s) => s.setScrubbing); const togglePlayTimeline = useEditorUiStore((s) => s.togglePlay); const previewMediaId = useEditorUiStore((s) => s.previewMediaId); + const selectedClipIds = useEditorUiStore((s) => s.selectedClipIds); const pushToast = useEditorUiStore((s) => s.pushToast); const previewItem = useMediaStore((s) => previewMediaId ? s.items.find((m) => m.id === previewMediaId) ?? null : null, ); + // The Transform overlay's target clip + media aspect (Inspector.tsx:295-301's + // same mediaCanvasAspect lookup pattern, reused so both surfaces agree on + // aspect-preserving resize). `transformClip` is null whenever upstream's + // TransformOverlayView.selectedClip would also be nil (see clip.ts's + // findSelectedVisualClip doc comment) — resolved unconditionally here (cheap) + // and gated at render time alongside the timeline-tab / has-content checks. + const transformClip = findSelectedVisualClip(timeline, selectedClipIds); + const transformMediaItem = useMediaStore((s) => + transformClip ? s.items.find((m) => m.id === transformClip.mediaRef) ?? null : null, + ); + const transformMediaAspect = mediaCanvasAspect( + transformMediaItem?.width, + transformMediaItem?.height, + timeline.width, + timeline.height, + ); // Media-preview playback is driven by the app transport (more capable than the //