Markup bug fixes

This commit is contained in:
Leivur Djurhuus 2026-04-06 08:53:28 -05:00
parent e3332c5dc5
commit 9a10cd8063
5 changed files with 256 additions and 4 deletions

View file

@ -743,6 +743,7 @@ model Annotation {
@@index([commentId])
@@index([revisionId])
@@index([revisionId, timestampSeconds])
@@map("annotations")
}

View file

@ -416,7 +416,7 @@ export function AnnotationLayer({
}
}}
onBlur={() => {
setTimeout(() => ann.commitTextAnnotation(), 150);
setTimeout(() => ann.commitTextAnnotationOnBlur(), 150);
}}
className="h-7 min-w-[180px] border-[var(--primary)] bg-[var(--card)] text-sm"
placeholder="Type label..."

View file

@ -95,6 +95,38 @@ export function VideoAnnotationLayer({
const ann = useAnnotationState(revisionId, stageId, videoContext);
// Clipboard paste handler for screenshots
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
if (!revisionId || !stageId) return;
const items = e.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith("image/")) {
e.preventDefault();
const file = item.getAsFile();
if (!file) continue;
// For video annotations, use native coords directly (no pan/zoom transform)
await ann.handleScreenshotPaste(
file,
videoWidth,
videoHeight,
0, // panX
0, // panY
videoWidth / coordWidth, // zoom = display/native ratio
{ width: coordWidth, height: coordHeight }
);
break;
}
}
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [revisionId, stageId, videoWidth, videoHeight, coordWidth, coordHeight, ann]);
// Auto-pause video when a drawing tool is activated
useEffect(() => {
if (ann.activeTool !== "move" && ann.activeTool !== "eyedropper" && isPlaying) {
@ -311,7 +343,7 @@ export function VideoAnnotationLayer({
}
}}
onBlur={() => {
setTimeout(() => ann.commitTextAnnotation(), 150);
setTimeout(() => ann.commitTextAnnotationOnBlur(), 150);
}}
className="h-7 min-w-[180px] border-[var(--primary)] bg-[var(--card)] text-sm"
placeholder="Type label..."
@ -320,6 +352,135 @@ export function VideoAnnotationLayer({
</div>
)}
{/* ── Screenshot callouts (HTML layer, same approach as image annotation layer) ── */}
{ann.visible && ann.screenshotAnnotations.length > 0 && (
<div
className="absolute inset-0 z-25"
style={{ pointerEvents: "none" }}
>
{ann.screenshotAnnotations
.filter((a: any) => {
if (a.timestampSeconds == null) return true;
if (showAllAnnotations) return true;
return Math.abs(a.timestampSeconds - currentTime) <= ANNOTATION_TIME_WINDOW;
})
.map((a: any) => {
const d = a.data ?? {};
const imgX = d.x ?? 0;
const imgY = d.y ?? 0;
const w = d.width ?? 200;
const h = d.height ?? 150;
// Convert native video coords → screen coords using the viewBox scale
const scale = videoWidth / coordWidth;
const screenLeft = imgX * scale;
const screenTop = imgY * scale;
const screenW = w * scale;
const screenH = h * scale;
const isSelected = a.id === ann.selectedId;
const isNearTime = a.timestampSeconds != null
? Math.abs(a.timestampSeconds - currentTime) <= ANNOTATION_TIME_WINDOW
: true;
return (
<div
key={a.id}
style={{
position: "absolute",
left: screenLeft,
top: screenTop,
width: screenW,
height: screenH,
pointerEvents: "auto",
cursor: "grab",
border: `2px solid ${isSelected ? "#fff" : "rgba(0,0,0,0.6)"}`,
boxShadow: isSelected
? "0 0 0 1px rgba(255,255,255,0.8), 0 4px 12px rgba(0,0,0,0.5)"
: "0 2px 8px rgba(0,0,0,0.4)",
borderRadius: 3,
overflow: "hidden",
userSelect: "none",
opacity: isNearTime ? 1 : 0.3,
transition: "opacity 0.3s",
}}
onMouseDown={(e) => {
e.stopPropagation();
ann.setSelectedId(a.id);
const startX = e.clientX;
const startY = e.clientY;
const origX = imgX;
const origY = imgY;
const handleMove = (me: MouseEvent) => {
const dx = (me.clientX - startX) * screenToNativeScale;
const dy = (me.clientY - startY) * screenToNativeScale;
ann.handleScreenshotMove(a.id, origX + dx, origY + dy);
};
const handleUp = () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={d.imageUrl ?? ""}
alt="Screenshot"
draggable={false}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
pointerEvents: "none",
}}
/>
{/* Resize handle */}
{isSelected && (
<div
style={{
position: "absolute",
right: -4,
bottom: -4,
width: 10,
height: 10,
background: "white",
border: "1px solid rgba(0,0,0,0.3)",
borderRadius: 2,
cursor: "nwse-resize",
pointerEvents: "auto",
}}
onMouseDown={(e) => {
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const origW = w;
const origH = h;
const handleMove = (me: MouseEvent) => {
const dx = (me.clientX - startX) * screenToNativeScale;
const dy = (me.clientY - startY) * screenToNativeScale;
ann.handleScreenshotResize(
a.id,
Math.max(40, origW + dx),
Math.max(40, origH + dy)
);
};
const handleUp = () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
}}
/>
)}
</div>
);
})}
</div>
)}
{/* Annotation comment popover */}
{ann.pendingAnnotation && (
<div

View file

@ -94,6 +94,8 @@ export function useAnnotationState(
const svgRef = useRef<SVGSVGElement>(null);
const textInputRef = useRef<HTMLInputElement>(null);
const commentInputRef = useRef<HTMLTextAreaElement>(null);
/** Timestamp when the text input was created — used to ignore premature blur */
const textInputCreatedAt = useRef<number>(0);
// Data hooks
const { data: annotationsRaw } = useAnnotations(revisionId);
@ -250,9 +252,11 @@ export function useAnnotationState(
const img = getImageCoords(e, panX, panY, zoom);
if (activeTool === "text") {
e.preventDefault(); // Prevent browser mousedown focus management from stealing focus from the input
const svgEl = svgRef.current;
if (!svgEl) return;
const rect = svgEl.getBoundingClientRect();
textInputCreatedAt.current = Date.now();
setTextInput({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
@ -380,6 +384,17 @@ export function useAnnotationState(
setTextValue("");
}, [textInput, textValue, color, saveAnnotation]);
/** Blur-safe variant: ignores blur events that fire within 300ms of input creation
* (caused by browser mousedown focus management stealing focus from the new input). */
const commitTextAnnotationOnBlur = useCallback(() => {
if (Date.now() - textInputCreatedAt.current < 300) {
// Re-focus — the blur was caused by the browser, not the user
setTimeout(() => textInputRef.current?.focus(), 0);
return;
}
commitTextAnnotation();
}, [commitTextAnnotation]);
// Undo / Redo
const handleUndo = useCallback(() => {
const last = undoStack[undoStack.length - 1];
@ -540,8 +555,15 @@ export function useAnnotationState(
);
toast.success("Screenshot pasted — add a comment");
} catch {
toast.error("Failed to paste screenshot");
} catch (err) {
console.error("[Screenshot paste]", err);
const message =
err instanceof TypeError
? "Network error — check your connection"
: err instanceof Error
? err.message
: "Unknown error";
toast.error(`Failed to paste screenshot: ${message}`);
}
},
[revisionId, stageId, color, queueAnnotation]
@ -660,6 +682,7 @@ export function useAnnotationState(
handleMouseMove,
handleMouseUp,
commitTextAnnotation,
commitTextAnnotationOnBlur,
handleDeleteSelection,
handleAnnotationMove,
handleScreenshotMove,

View file

@ -1,6 +1,16 @@
import path from "path";
import { existsSync } from "fs";
import { mkdir } from "fs/promises";
import { prisma } from "@/lib/prisma";
import type { CreateAnnotationInput, UpdateAnnotationInput } from "@/lib/validators/annotation";
import { createFeedbackFromAnnotation } from "@/lib/services/feedback-service";
import { extractThumbnail, isFFmpegAvailable } from "@/lib/services/video-service";
const VIDEO_UPLOADS_DIR =
process.env.VIDEO_UPLOADS_DIR ||
(process.env.NODE_ENV === "production"
? "/data/uploads/revisions"
: path.join(process.cwd(), "data", "uploads", "revisions"));
/**
* List all annotations for a revision, including the linked comment + author.
@ -78,9 +88,66 @@ export async function createAnnotation(
// Non-critical: don't fail annotation creation if feedback creation fails
}
// Async: extract frame thumbnail for video annotations (non-blocking)
if (input.timestampSeconds != null) {
extractAnnotationFrameThumbnail(result.id, revisionId, input.timestampSeconds).catch(
(err) => console.error("[Annotation] Frame thumbnail extraction failed:", err)
);
}
return result;
}
/**
* Extract a frame thumbnail for a video annotation and update the record.
* Runs async does not block annotation creation.
*/
async function extractAnnotationFrameThumbnail(
annotationId: string,
revisionId: string,
timestampSeconds: number
): Promise<void> {
if (!(await isFFmpegAvailable())) return;
// Find the video file path from the revision's attachments
const revision = await prisma.revision.findUnique({
where: { id: revisionId },
select: { attachments: true },
});
const attachments = (revision?.attachments as Record<string, any>) ?? {};
const videoUrl: string | undefined =
attachments.video?.url ?? attachments.referenceVideo?.url;
if (!videoUrl) return;
// Resolve local file path from the API URL
// URL format: /api/uploads/revisions/{revisionId}/{filename}
const urlParts = videoUrl.split("/");
const filename = urlParts[urlParts.length - 1];
const videoPath = path.join(VIDEO_UPLOADS_DIR, revisionId, filename);
if (!existsSync(videoPath)) return;
// Extract frame thumbnail
const thumbDir = path.join(VIDEO_UPLOADS_DIR, revisionId, "annotation_frames");
if (!existsSync(thumbDir)) {
await mkdir(thumbDir, { recursive: true });
}
const thumbFilename = `frame_${annotationId}.jpg`;
const thumbPath = path.join(thumbDir, thumbFilename);
const created = await extractThumbnail(videoPath, thumbPath, timestampSeconds);
if (created) {
const thumbUrl = `/api/uploads/revisions/${revisionId}/annotation_frames/${thumbFilename}`;
await prisma.annotation.update({
where: { id: annotationId },
data: { frameThumbnailUrl: thumbUrl },
});
}
}
/**
* Update annotation data (position, shape data).
*/