feat(web): improve OCR overlay text fitting, reactivity, and accessibility (#26678)

- Precise font sizing using canvas measureText instead of character-count heuristic
- Fix overlay repositioning on viewport resize by computing metrics from reactive state instead of DOM reads
- Fix animation delay on resize by using transition-colors instead of transition-all
- Add keyboard accessibility: OCR boxes are focusable via Tab with reading-order sort
- Show text on focus (same styling as hover) with proper ARIA attributes
This commit is contained in:
Min Idzelis
2026-03-13 23:04:55 -04:00
committed by GitHub
parent 0581b49750
commit 48fe111daa
3 changed files with 149 additions and 8 deletions
@@ -1,6 +1,6 @@
<script lang="ts">
import type { OcrBox } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxMatrix } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxMatrix, calculateFittedFontSize } from '$lib/utils/ocr-utils';
type Props = {
ocrBox: OcrBox;
@@ -11,16 +11,35 @@
const dimensions = $derived(calculateBoundingBoxMatrix(ocrBox.points));
const transform = $derived(`matrix3d(${dimensions.matrix.join(',')})`);
// Fits almost all strings within the box, depends on font family
const fontSize = $derived(
`max(var(--text-sm), min(var(--text-6xl), ${(1.4 * dimensions.width) / ocrBox.text.length}px))`,
calculateFittedFontSize(ocrBox.text, dimensions.width, dimensions.height, ocrBox.verticalMode) + 'px',
);
const verticalStyle = $derived.by(() => {
switch (ocrBox.verticalMode) {
case 'cjk': {
return ' writing-mode: vertical-rl;';
}
case 'rotated': {
return ' writing-mode: vertical-rl; text-orientation: sideways;';
}
default: {
return '';
}
}
});
</script>
<div class="absolute left-0 top-0">
<div
class="absolute flex items-center justify-center text-transparent text-sm border-2 border-blue-500 bg-blue-500/10 px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text transition-all hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3"
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;"
class="absolute flex items-center justify-center text-transparent border-2 border-blue-500 bg-blue-500/10 pointer-events-auto cursor-text select-text transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3 focus:z-1 focus:text-white focus:bg-black/60 focus:border-blue-600 focus:border-3 focus:outline-none {ocrBox.verticalMode ===
'none'
? 'px-2 py-1 whitespace-nowrap'
: 'px-1 py-2'}"
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;{verticalStyle}"
tabindex="0"
role="button"
aria-label={ocrBox.text}
>
{ocrBox.text}
</div>
@@ -73,7 +73,8 @@
}
const natural = getNaturalSize(assetViewerManager.imgRef);
const scaled = scaleToFit(natural, container);
const scaled = scaleToFit(natural, { width: containerWidth, height: containerHeight });
return {
contentWidth: scaled.width,
contentHeight: scaled.height,