feat(web): OCR overlay interactivity during zoom (#27039)

Change-Id: Id62e1a0264df2de0f3177a59b24bc5176a6a6964
This commit is contained in:
Min Idzelis
2026-03-30 20:19:53 -04:00
committed by GitHub
parent a2e3635ac9
commit 18201a26d9
14 changed files with 770 additions and 246 deletions
+118 -28
View File
@@ -1,11 +1,18 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { createZoomImageWheel } from '@zoom-image/core';
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
// Minimal touch shape — avoids importing DOM TouchEvent which isn't available in all TS targets.
type TouchEventLike = {
touches: Iterable<{ clientX: number; clientY: number }> & { length: number };
targetTouches: ArrayLike<unknown>;
};
const asTouchEvent = (event: Event) => event as unknown as TouchEventLike;
export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTMLElement }) => {
const zoomInstance = createZoomImageWheel(node, {
maxZoom: 10,
initialState: assetViewerManager.zoomState,
zoomTarget: null,
zoomTarget: options?.zoomTarget,
});
const unsubscribes = [
@@ -13,47 +20,130 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
];
const onInteractionStart = (event: Event) => {
if (options?.disabled) {
event.stopImmediatePropagation();
const controller = new AbortController();
const { signal } = controller;
node.addEventListener('pointerdown', () => assetViewerManager.cancelZoomAnimation(), { capture: true, signal });
// Intercept events in capture phase to prevent zoom-image from seeing interactions on
// overlay elements (e.g. OCR text boxes), preserving browser defaults like text selection.
const isOverlayEvent = (event: Event) => !!(event.target as HTMLElement).closest('[data-overlay-interactive]');
const isOverlayAtPoint = (x: number, y: number) =>
!!document.elementFromPoint(x, y)?.closest('[data-overlay-interactive]');
// Pointer event interception: track pointers that start on overlays and intercept the entire gesture.
const overlayPointers = new Set<number>();
const interceptedPointers = new Set<number>();
const interceptOverlayPointerDown = (event: PointerEvent) => {
if (isOverlayEvent(event) || isOverlayAtPoint(event.clientX, event.clientY)) {
overlayPointers.add(event.pointerId);
interceptedPointers.add(event.pointerId);
event.stopPropagation();
} else if (overlayPointers.size > 0) {
// Split gesture (e.g. pinch with one finger on overlay) — intercept entirely.
interceptedPointers.add(event.pointerId);
event.stopPropagation();
}
assetViewerManager.cancelZoomAnimation();
};
const interceptOverlayPointerEvent = (event: PointerEvent) => {
if (interceptedPointers.has(event.pointerId)) {
event.stopPropagation();
}
};
const interceptOverlayPointerEnd = (event: PointerEvent) => {
overlayPointers.delete(event.pointerId);
if (interceptedPointers.delete(event.pointerId)) {
event.stopPropagation();
}
};
node.addEventListener('pointerdown', interceptOverlayPointerDown, { capture: true, signal });
node.addEventListener('pointermove', interceptOverlayPointerEvent, { capture: true, signal });
node.addEventListener('pointerup', interceptOverlayPointerEnd, { capture: true, signal });
node.addEventListener('pointerleave', interceptOverlayPointerEnd, { capture: true, signal });
node.addEventListener('wheel', onInteractionStart, { capture: true });
node.addEventListener('pointerdown', onInteractionStart, { capture: true });
// Touch event interception for overlay touches or split gestures (pinch across container boundary).
// Once intercepted, stays intercepted until all fingers are lifted.
let touchGestureIntercepted = false;
const interceptOverlayTouchEvent = (event: Event) => {
if (touchGestureIntercepted) {
event.stopPropagation();
return;
}
const { touches, targetTouches } = asTouchEvent(event);
if (touches && targetTouches) {
if (touches.length > targetTouches.length) {
touchGestureIntercepted = true;
event.stopPropagation();
return;
}
for (const touch of touches) {
if (isOverlayAtPoint(touch.clientX, touch.clientY)) {
touchGestureIntercepted = true;
event.stopPropagation();
return;
}
}
} else if (isOverlayEvent(event)) {
event.stopPropagation();
}
};
const resetTouchGesture = (event: Event) => {
const { touches } = asTouchEvent(event);
if (touches.length === 0) {
touchGestureIntercepted = false;
}
};
node.addEventListener('touchstart', interceptOverlayTouchEvent, { capture: true, signal });
node.addEventListener('touchmove', interceptOverlayTouchEvent, { capture: true, signal });
node.addEventListener('touchend', resetTouchGesture, { capture: true, signal });
// Suppress Safari's synthetic dblclick on double-tap. Without this, zoom-image's touchstart
// handler zooms to maxZoom (10x), then Safari's synthetic dblclick triggers photo-viewer's
// handler which conflicts. Chrome does not fire synthetic dblclick on touch.
// Wheel and dblclick interception on overlay elements.
// Dblclick also intercepted for all touch double-taps (Safari fires synthetic dblclick
// on double-tap, which conflicts with zoom-image's touch zoom handler).
let lastPointerWasTouch = false;
const trackPointerType = (event: PointerEvent) => {
lastPointerWasTouch = event.pointerType === 'touch';
};
const suppressTouchDblClick = (event: MouseEvent) => {
if (lastPointerWasTouch) {
event.stopImmediatePropagation();
}
};
node.addEventListener('pointerdown', trackPointerType, { capture: true });
node.addEventListener('dblclick', suppressTouchDblClick, { capture: true });
node.addEventListener('pointerdown', (event) => (lastPointerWasTouch = event.pointerType === 'touch'), {
capture: true,
signal,
});
node.addEventListener(
'wheel',
(event) => {
if (isOverlayEvent(event)) {
event.stopPropagation();
}
},
{ capture: true, signal },
);
node.addEventListener(
'dblclick',
(event) => {
if (lastPointerWasTouch || isOverlayEvent(event)) {
event.stopImmediatePropagation();
}
},
{ capture: true, signal },
);
// Allow zoomed content to render outside the container bounds
if (options?.zoomTarget) {
options.zoomTarget.style.willChange = 'transform';
}
node.style.overflow = 'visible';
// Prevent browser handling of touch gestures so zoom-image can manage them
node.style.touchAction = 'none';
return {
update(newOptions?: { disabled?: boolean }) {
update(newOptions?: { zoomTarget?: HTMLElement }) {
options = newOptions;
if (newOptions?.zoomTarget !== undefined) {
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
}
},
destroy() {
controller.abort();
if (options?.zoomTarget) {
options.zoomTarget.style.willChange = '';
}
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
node.removeEventListener('wheel', onInteractionStart, { capture: true });
node.removeEventListener('pointerdown', onInteractionStart, { capture: true });
node.removeEventListener('pointerdown', trackPointerType, { capture: true });
node.removeEventListener('dblclick', suppressTouchDblClick, { capture: true });
zoomInstance.cleanup();
},
};