feat(web): OCR overlay interactivity during zoom

Change-Id: Id62e1a0264df2de0f3177a59b24bc5176a6a6964
This commit is contained in:
midzelis
2026-03-19 12:54:44 +00:00
parent 00dae6ac38
commit ed04d87273
14 changed files with 770 additions and 246 deletions

View File

@@ -1,14 +1,35 @@
export interface ContentMetrics {
// Coordinate spaces used throughout the viewer:
//
// "Normalized": 01 range, (0,0) = top-left, (1,1) = bottom-right. Resolution-independent.
// Example: OCR coordinates, or face coords after dividing by metadata dimensions.
//
// "Content": pixel position within the container after scaling (scaleToFit/scaleToCover)
// and centering. Used for DOM overlay positioning (face boxes, OCR text).
//
// "Natural": pixel position in the original full-resolution image file (e.g. 4000×3000).
// Used when cropping or drawing on the source image.
//
// "Metadata pixel space": coordinates from face detection / OCR models, in pixels relative
// to face.imageWidth/imageHeight. Divide by those dimensions to get normalized coords.
export type Point = {
x: number;
y: number;
};
export type Size = {
width: number;
height: number;
};
export type ContentMetrics = {
contentWidth: number;
contentHeight: number;
offsetX: number;
offsetY: number;
}
};
export const scaleToCover = (
dimensions: { width: number; height: number },
container: { width: number; height: number },
): { width: number; height: number } => {
export const scaleToCover = (dimensions: Size, container: Size): Size => {
const scaleX = container.width / dimensions.width;
const scaleY = container.height / dimensions.height;
const scale = Math.max(scaleX, scaleY);
@@ -18,10 +39,7 @@ export const scaleToCover = (
};
};
export const scaleToFit = (
dimensions: { width: number; height: number },
container: { width: number; height: number },
): { width: number; height: number } => {
export const scaleToFit = (dimensions: Size, container: Size): Size => {
const scaleX = container.width / dimensions.width;
const scaleY = container.height / dimensions.height;
const scale = Math.min(scaleX, scaleY);
@@ -31,14 +49,14 @@ export const scaleToFit = (
};
};
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => {
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
if (element instanceof HTMLVideoElement) {
return { width: element.clientWidth, height: element.clientHeight };
}
return { width: element.width, height: element.height };
};
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => {
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
if (element instanceof HTMLVideoElement) {
return { width: element.videoWidth, height: element.videoHeight };
}
@@ -56,3 +74,38 @@ export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement):
offsetY: (client.height - contentHeight) / 2,
};
};
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
if ('contentWidth' in sizeOrMetrics) {
return {
x: point.x * sizeOrMetrics.contentWidth + sizeOrMetrics.offsetX,
y: point.y * sizeOrMetrics.contentHeight + sizeOrMetrics.offsetY,
};
}
return {
x: point.x * sizeOrMetrics.width,
y: point.y * sizeOrMetrics.height,
};
}
export type Rect = {
top: number;
left: number;
width: number;
height: number;
};
export function mapNormalizedRectToContent(
topLeft: Point,
bottomRight: Point,
sizeOrMetrics: Size | ContentMetrics,
): Rect {
const tl = mapNormalizedToContent(topLeft, sizeOrMetrics);
const br = mapNormalizedToContent(bottomRight, sizeOrMetrics);
return {
top: tl.y,
left: tl.x,
width: br.x - tl.x,
height: br.y - tl.y,
};
}