From 3e53a8d8384f03c5bdd74c402b62584eb3016c19 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:12:02 +1000 Subject: [PATCH] Prevent click jitter from shifting clips and restore export spacing --- docs/HEROUI_MIGRATION.md | 4 +- package-lock.json | 1 + package.json | 1 + .../video-editor/layout/EditorExportMenu.tsx | 15 ++-- .../components/wrapper/TimelineWrapper.tsx | 11 ++- tests/ui/clip-origin.spec.ts | 87 +++++++++++++++++++ 6 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 tests/ui/clip-origin.spec.ts diff --git a/docs/HEROUI_MIGRATION.md b/docs/HEROUI_MIGRATION.md index f31be42b..22b182b9 100644 --- a/docs/HEROUI_MIGRATION.md +++ b/docs/HEROUI_MIGRATION.md @@ -52,7 +52,9 @@ existing saved values remain intact. New recordings inherit the saved webcam appearance, and 100% is the maximum squircle radius rather than a circular mask. Three compact timeline tracks fit before vertical scrolling. Popovers support outside-click and Escape dismissal and -share one padding layer. Project and preset names truncate inside their rows. +share one padding layer. Export states retain generous content padding. Timeline +selection ignores pointer jitter up to 4px, including clicks on trim handles, +so selecting a clip cannot introduce a leading gap. Project and preset names truncate inside their rows. The recorder keeps its compact desktop layout. The adapters in `src/components/ui` translate existing Recordly state/callback diff --git a/package-lock.json b/package-lock.json index 517ae301..1053d5df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@biomejs/biome": "2.3.13", + "@dnd-kit/core": "^6.3.1", "@electron/rebuild": "^4.2.0", "@fix-webm-duration/fix": "^1.0.1", "@heroui/react": "^3.2.6", diff --git a/package.json b/package.json index 794dc129..13663ca5 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ }, "devDependencies": { "@biomejs/biome": "2.3.13", + "@dnd-kit/core": "^6.3.1", "@electron/rebuild": "^4.2.0", "@fix-webm-duration/fix": "^1.0.1", "@heroui/react": "^3.2.6", diff --git a/src/components/video-editor/layout/EditorExportMenu.tsx b/src/components/video-editor/layout/EditorExportMenu.tsx index 14a02982..4b8c7cca 100644 --- a/src/components/video-editor/layout/EditorExportMenu.tsx +++ b/src/components/video-editor/layout/EditorExportMenu.tsx @@ -112,9 +112,14 @@ export function EditorExportMenu(props: Props) { - + {isExporting ? ( - +

@@ -186,7 +191,7 @@ export function EditorExportMenu(props: Props) { ) : null} ) : exportError ? ( - +

{t("editor.exportStatus.issue", "Export issue")}

@@ -241,7 +246,7 @@ export function EditorExportMenu(props: Props) {
) : exportedFilePath ? ( - +

{t("editor.exportStatus.complete", "Export complete")}

@@ -305,7 +310,7 @@ export function EditorExportMenu(props: Props) { mp4OutputDimensions={mp4OutputDimensions} gifOutputDimensions={gifOutputDimensions} onExport={handleStartExportFromDropdown} - className="rounded-none bg-transparent p-1 shadow-none" + className="rounded-none bg-transparent p-5 shadow-none" /> )} diff --git a/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx b/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx index fa3cc923..a918ce2b 100644 --- a/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx +++ b/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx @@ -1,3 +1,4 @@ +import { KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import type { DragEndEvent, DragMoveEvent, @@ -44,10 +45,16 @@ export default function TimelineWrapper({ onLiveSpanPreviewChange, onDraggingChange, }: TimelineWrapperProps) { + // Treat small pointer jitter as a selection, never as a persisted timeline edit. + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor), + ); const totalMs = Math.max(0, Math.round(videoDuration * 1000)); const onResizeEnd = useCallback( (event: ResizeEndEvent) => { + if (Math.abs(event.delta.x) <= 4) return; const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event); if (!updatedSpan) return; @@ -66,6 +73,7 @@ export default function TimelineWrapper({ const onDragEnd = useCallback( (event: DragEndEvent) => { + if (Math.hypot(event.delta.x, event.delta.y) <= 4) return; const proposedRowId = event.over?.id as string; const updatedSpan = event.active.data.current.getSpanFromDragEvent?.(event); if (!updatedSpan || !proposedRowId) return; @@ -174,7 +182,7 @@ export default function TimelineWrapper({ } else { showTooltip(null); } - const moved = Math.hypot(event.delta?.x ?? 0, event.delta?.y ?? 0) > 0.01; + const moved = Math.hypot(event.delta?.x ?? 0, event.delta?.y ?? 0) > 4; if (moved) { onLiveSpanPreviewChange?.(event.active.id as string, previewSpan); } @@ -249,6 +257,7 @@ export default function TimelineWrapper({ return ( { + test.setTimeout(60000); + await installDesktopBridge(page); + await page.addInitScript(() => { + window.electronAPI.onMenuSaveProject = (callback) => { + const handler = () => { + void callback(); + }; + window.addEventListener("test-save-project", handler); + return () => window.removeEventListener("test-save-project", handler); + }; + window.electronAPI.saveProjectFile = async (project) => { + sessionStorage.setItem("test-saved-clips", JSON.stringify(project.editor.clipRegions)); + return { success: false, canceled: true }; + }; + }); + await page.goto("/?windowType=editor"); + const clip = page.locator('[data-timeline-item][data-variant="clip"]'); + await expect(clip).toBeVisible({ timeout: 20000 }); + await expect(clip).toHaveCSS("left", "0px"); + for (let index = 0; index < 5; index++) { + const box = (await clip.boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 1, box.y + box.height / 2); + await page.mouse.up(); + await expect(clip).toHaveCSS("left", "0px"); + } + // A click on the resize edge must not trim source footage either. + const originalWidth = (await clip.boundingBox())!.width; + const box = (await clip.boundingBox())!; + await page.mouse.move(box.x + 8, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + 9, box.y + box.height / 2); + await page.mouse.up(); + await expect(clip).toHaveCSS("left", "0px"); + expect((await clip.boundingBox())!.width).toBeCloseTo(originalWidth, 1); + await page.evaluate(() => { + window.electronAPI.loadCurrentProjectFile = async () => ({ + success: true, + path: "/test.recordly", + }); + window.dispatchEvent(new Event("test-save-project")); + }); + await expect + .poll(() => page.evaluate(() => sessionStorage.getItem("test-saved-clips"))) + .not.toBeNull(); + const clips = await page.evaluate(() => + JSON.parse(sessionStorage.getItem("test-saved-clips")!), + ); + expect(clips).toHaveLength(1); + expect(clips[0]).toMatchObject({ startMs: 0, endMs: 6000, speed: 1 }); + expect(clips[0].sourceStartMs ?? clips[0].startMs).toBe(0); + // Intentional drags still work, and returning to the origin removes a gap. + const dragBox = (await clip.boundingBox())!; + const dragX = dragBox.x + dragBox.width / 2; + const dragY = dragBox.y + dragBox.height / 2; + await page.mouse.move(dragX, dragY); + await page.mouse.down(); + await page.mouse.move(dragX + 40, dragY, { steps: 8 }); + await page.mouse.up(); + await expect + .poll(() => clip.evaluate((node) => parseFloat(node.style.left))) + .toBeGreaterThan(10); + const movedBox = (await clip.boundingBox())!; + await page.mouse.move(movedBox.x + 150, movedBox.y + movedBox.height / 2); + await page.mouse.down(); + await page.mouse.move(movedBox.x + 70, movedBox.y + movedBox.height / 2, { steps: 8 }); + await page.mouse.up(); + await expect(clip).toHaveCSS("left", "0px"); + + await page.getByRole("button", { name: "Export", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Export", exact: true }); + const dialogBox = (await dialog.boundingBox())!; + const titleBox = (await dialog + .getByRole("heading", { name: "Export", exact: true }) + .boundingBox())!; + expect(titleBox.x - dialogBox.x).toBeGreaterThanOrEqual(16); + expect(titleBox.y - dialogBox.y).toBeGreaterThanOrEqual(16); + await page.screenshot({ + path: "test-results/export-padding-restored.png", + animations: "disabled", + }); +});