Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/components/launch/LaunchWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,75 @@ describe("LaunchWindow system language prompt", () => {
});
});

describe("LaunchWindow HUD drag", () => {
beforeEach(() => {
platformState.value = "darwin";
resetLaunchMocks();
resizeCallbacks.length = 0;
vi.stubGlobal("ResizeObserver", CapturingResizeObserver);
// jsdom doesn't implement the Pointer Capture API; stub it so the drag handlers
// (which call set/has/releasePointerCapture) don't throw.
HTMLElement.prototype.setPointerCapture = vi.fn();
HTMLElement.prototype.hasPointerCapture = vi.fn(() => true);
HTMLElement.prototype.releasePointerCapture = vi.fn();
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

it("suppresses ResizeObserver-driven measurement while dragging, and measures once on release", async () => {
renderLaunchWindow();

const dragHandle = await screen.findByTestId("hud-drag-handle");

// Give the bar a non-zero, changing size so a resize observation would actually
// trigger a `setHudOverlaySize` call if it weren't suppressed during the drag.
const bar = dragHandle.closest("[data-tray-layout]") as HTMLElement | null;
if (bar) {
vi.spyOn(bar, "getBoundingClientRect").mockReturnValue({
top: 700,
left: 200,
right: 600,
bottom: 756,
width: 400,
height: 56,
x: 200,
y: 700,
toJSON: () => ({}),
});
Object.defineProperty(bar, "scrollHeight", { value: 56, configurable: true });
Object.defineProperty(bar, "scrollWidth", { value: 400, configurable: true });
}

const sizeMock = window.electronAPI.setHudOverlaySize as unknown as {
mockClear: () => void;
};
sizeMock.mockClear();

fireEvent.pointerDown(dragHandle, { screenX: 100, screenY: 100 });

// Simulate a ResizeObserver firing mid-drag (e.g. transient reflow) -- this must
// NOT reposition/resize the HUD while the user's pointer is still down.
await act(async () => {
for (const callback of resizeCallbacks) {
callback([], {} as ResizeObserver);
}
});
expect(window.electronAPI.setHudOverlaySize).not.toHaveBeenCalled();

fireEvent.pointerMove(dragHandle, { screenX: 140, screenY: 130 });
fireEvent.pointerUp(dragHandle, { screenX: 140, screenY: 130 });

// Content is re-measured once the drag ends, so a real size change made mid-drag
// still gets picked up promptly.
await waitFor(() => {
expect(window.electronAPI.setHudOverlaySize).toHaveBeenCalled();
});
});
});

describe("LaunchWindow software encoder fallback notice", () => {
beforeEach(() => {
platformState.value = "darwin";
Expand Down
11 changes: 11 additions & 0 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,9 +321,16 @@ export function LaunchWindow() {
// and scrolls. Measure from the window's bottom-centre (the anchor the main process
// preserves) so fixed bottom/centre offsets keep this stable and it doesn't oscillate.
const lastHudSizeRef = useRef({ width: 0, height: 0 });
const isDraggingHudRef = useRef(false);
const measureHudSize = useCallback(() => {
const barEl = hudBarRef.current;
if (!barEl || !window.electronAPI?.setHudOverlaySize) return;
// While the user is dragging the HUD, ignore content-size measurements. A
// ResizeObserver-driven resize (hud-overlay-set-size) re-centres the window from
// its own bottom-centre anchor, which fights the position "hud-overlay-move-by" is
// actively applying frame-by-frame -- the two IPC channels racing is what produces
// the reported drift. Content size is re-measured once the drag ends instead.
if (isDraggingHudRef.current) return;

// Breathing room so the drop shadow isn't clipped. TOP_MARGIN must also exceed the
// slack in the bar's `max-h: calc(100vh - 2.5rem)` cap (40px reserved - 20px bottom
Expand Down Expand Up @@ -634,6 +641,7 @@ export function LaunchWindow() {
setHudMouseEventsEnabled(true);
event.currentTarget.setPointerCapture(event.pointerId);
dragLastPositionRef.current = { x: event.screenX, y: event.screenY };
isDraggingHudRef.current = true;
};
const handleHudDragPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const lastPosition = dragLastPositionRef.current;
Expand All @@ -650,6 +658,8 @@ export function LaunchWindow() {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setHudMouseEventsEnabled(false);
isDraggingHudRef.current = false;
measureHudSize();
};

return (
Expand Down Expand Up @@ -916,6 +926,7 @@ export function LaunchWindow() {
>
{/* Drag handle */}
<div
data-testid="hud-drag-handle"
className={`flex ${trayLayout === "vertical" ? "h-6 w-8" : "h-8 w-7"} cursor-grab items-center justify-center active:cursor-grabbing ${styles.electronNoDrag}`}
onPointerDown={handleHudDragPointerDown}
onPointerMove={handleHudDragPointerMove}
Expand Down
22 changes: 14 additions & 8 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import {
type BlurData,
type CameraFullscreenRegion,
clampFocusToDepth,
createTextAnnotationRegion,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
Expand All @@ -119,6 +120,7 @@ import {
type FigureData,
type PlaybackSpeed,
type Rotation3DPreset,
resolveTextAnnotationContent,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
Expand Down Expand Up @@ -406,6 +408,8 @@ export default function VideoEditor() {
}
setIsPlaying(false);
setCurrentTime(0);
// This inferred duration is only a placeholder until the video element's real
// metadata resolves (see VideoPlayback's syncResolvedDuration).
setDuration(inferredDurationMs > 0 ? inferredDurationMs / 1000 : 0);

setError(null);
Expand All @@ -415,6 +419,13 @@ export default function VideoEditor() {
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
setRecordingCursorCaptureMode(projectCursorCaptureMode);
setCurrentProjectPath(path ?? null);
// Reset the memoized last-resolved-duration guard so resolution isn't skipped
// just because the real duration happens to match a value already seen from
// before this load (e.g. reloading a project referencing the same video file,
// whose src may not change and so never re-fires `loadedmetadata`). Must run
// after the setDuration placeholder above, or its correction gets clobbered by
// the same-tick placeholder assignment winning the state-batch race.
videoPlaybackRef.current?.resetDurationResolution();

// A loaded project keeps its zooms exactly as saved, so never auto-suggest
// over it (even if it has zero zooms because the user deleted them all).
Expand Down Expand Up @@ -1472,17 +1483,12 @@ export default function VideoEditor() {
(span: Span) => {
const id = `annotation-${nextAnnotationIdRef.current++}`;
const zIndex = nextAnnotationZIndexRef.current++;
const newRegion: AnnotationRegion = {
const newRegion = createTextAnnotationRegion({
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
type: "text",
content: "Enter text...",
position: { ...DEFAULT_ANNOTATION_POSITION },
size: { ...DEFAULT_ANNOTATION_SIZE },
style: { ...DEFAULT_ANNOTATION_STYLE },
zIndex,
};
});
pushState((prev) => ({
annotationRegions: [...prev.annotationRegions, newRegion],
}));
Expand Down Expand Up @@ -1616,7 +1622,7 @@ export default function VideoEditor() {
if (region.id !== id) return region;
const updatedRegion = { ...region, type };
if (type === "text") {
updatedRegion.content = region.textContent || "Enter text...";
updatedRegion.content = resolveTextAnnotationContent(region.textContent);
} else if (type === "image") {
updatedRegion.content = region.imageContent || "";
} else if (type === "figure") {
Expand Down
21 changes: 21 additions & 0 deletions src/components/video-editor/VideoPlayback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@ export interface VideoPlaybackRef {
containerRef: React.RefObject<HTMLDivElement>;
play: () => Promise<void>;
pause: () => void;
/**
* Clears the memoized last-resolved-duration guard so the next metadata load
* re-syncs `duration` even if the video's real (resolved) duration happens to
* match a value already seen — needed when a caller (e.g. loading a saved
* project) sets `duration` to something else in between, which the guard has
* no other way to detect.
*/
resetDurationResolution: () => void;
}

function getResolvedVideoDuration(video: HTMLVideoElement): number | null {
Expand Down Expand Up @@ -695,6 +703,19 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
video.pause();
supplementalAudioRef.current?.pause();
},
resetDurationResolution: () => {
lastResolvedDurationRef.current = null;
// If the video element is already loaded (e.g. reloading a project that
// references the same file, so its src never actually changes and
// `loadedmetadata` won't fire again), clearing the guard alone leaves
// nothing to trigger a re-sync. Resolve immediately in that case too.
const video = videoRef.current;
if (video && video.readyState >= HTMLMediaElement.HAVE_METADATA) {
if (!syncResolvedDuration(video)) {
forceResolveDuration(video);
}
}
},
}));

const updateFocusFromClientPoint = (clientX: number, clientY: number) => {
Expand Down
29 changes: 29 additions & 0 deletions src/components/video-editor/types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { createTextAnnotationRegion, resolveTextAnnotationContent } from "./types";

// Regression coverage for #127: a freshly created text annotation must start with
// truly empty content so the properties panel's placeholder shows and typing
// replaces rather than appends to baked-in text.
describe("createTextAnnotationRegion", () => {
it("starts with empty content, not a baked-in placeholder string", () => {
const region = createTextAnnotationRegion({
id: "annotation-1",
startMs: 1000,
endMs: 2000,
zIndex: 1,
});

expect(region.content).toBe("");
expect(region.type).toBe("text");
});
});

describe("resolveTextAnnotationContent", () => {
it("falls back to empty content when no prior text was stored", () => {
expect(resolveTextAnnotationContent(undefined)).toBe("");
});

it("preserves existing text content when converting an existing region to text", () => {
expect(resolveTextAnnotationContent("hello world")).toBe("hello world");
});
});
31 changes: 31 additions & 0 deletions src/components/video-editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,37 @@ export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = {
textAnimation: "none",
};

/**
* A freshly created text annotation starts with no content: the properties panel's
* textarea has a real `placeholder` attribute for the empty-state hint, so the
* actual value must be empty for it to show and for typing to replace rather than
* append to baked-in text (see #127).
*/
export function createTextAnnotationRegion(params: {
id: string;
startMs: number;
endMs: number;
zIndex: number;
}): AnnotationRegion {
return {
id: params.id,
startMs: params.startMs,
endMs: params.endMs,
type: "text",
content: "",
position: { ...DEFAULT_ANNOTATION_POSITION },
size: { ...DEFAULT_ANNOTATION_SIZE },
style: { ...DEFAULT_ANNOTATION_STYLE },
zIndex: params.zIndex,
};
}

/** Resolves the content for a region whose type is being switched to "text" -- same
* empty-by-default rule as a freshly created one when no prior text was stored. */
export function resolveTextAnnotationContent(existingTextContent?: string): string {
return existingTextContent || "";
}

export const DEFAULT_FIGURE_DATA: FigureData = {
arrowDirection: "right",
color: "#34B27B",
Expand Down
Loading