Prevent click jitter from shifting clips and restore export spacing

This commit is contained in:
webadderall
2026-09-19 18:12:02 +10:00
parent baea6afb90
commit 3e53a8d838
6 changed files with 112 additions and 7 deletions
+3 -1
View File
@@ -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
+1
View File
@@ -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",
+1
View File
@@ -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",
@@ -112,9 +112,14 @@ export function EditorExportMenu(props: Props) {
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={10} className="w-[360px] p-0">
<PopoverContent
aria-label="Export"
align="end"
sideOffset={10}
className="w-[360px] p-0"
>
{isExporting ? (
<Card className="rounded-none bg-transparent p-1 text-foreground shadow-none">
<Card className="rounded-none bg-transparent p-5 text-foreground shadow-none">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-foreground">
@@ -186,7 +191,7 @@ export function EditorExportMenu(props: Props) {
) : null}
</Card>
) : exportError ? (
<Card className="rounded-none bg-transparent p-1 text-foreground shadow-none">
<Card className="rounded-none bg-transparent p-5 text-foreground shadow-none">
<p className="text-sm font-semibold text-foreground">
{t("editor.exportStatus.issue", "Export issue")}
</p>
@@ -241,7 +246,7 @@ export function EditorExportMenu(props: Props) {
</div>
</Card>
) : exportedFilePath ? (
<Card className="rounded-none bg-transparent p-1 text-foreground shadow-none">
<Card className="rounded-none bg-transparent p-5 text-foreground shadow-none">
<p className="text-sm font-semibold text-foreground">
{t("editor.exportStatus.complete", "Export complete")}
</p>
@@ -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"
/>
)}
</PopoverContent>
@@ -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 (
<TimelineContext
sensors={sensors}
range={range}
onRangeChanged={handleRangeChange}
onResizeEnd={onResizeEndWithTooltip}
+87
View File
@@ -0,0 +1,87 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
test("selecting an untouched clip does not introduce a leading gap", async ({ page }) => {
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",
});
});