mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
Prevent click jitter from shifting clips and restore export spacing
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user