Files
pdf/frontend/src/viewer/StreamEditLayer.tsx
T

225 lines
7.2 KiB
TypeScript

import React, { useEffect, useState, useRef } from 'react';
import { gatewayService } from '../lib/gatewayService';
import type { TextObjectResponse } from '../lib/gatewayService';
import { loadPdfFont } from '../lib/fontFaceLoader';
const _measureCache = new Map<string, { width: number; ascent: number; descent: number }>();
let _measureCtx: CanvasRenderingContext2D | null = null;
function clearMeasureCache() { _measureCache.clear(); }
function measureText(text: string, fontFamily: string, sizePx: number): { width: number; ascent: number; descent: number } | null {
const key = `${sizePx}|${fontFamily}|${text}`;
const hit = _measureCache.get(key);
if (hit) return hit;
if (!_measureCtx) _measureCtx = document.createElement('canvas').getContext('2d');
if (!_measureCtx) return null;
_measureCtx.font = `${sizePx}px ${fontFamily}`;
const tm = _measureCtx.measureText(text);
const res = {
width: tm.width,
ascent: tm.actualBoundingBoxAscent || sizePx * 0.8,
descent: tm.actualBoundingBoxDescent || sizePx * 0.2,
};
_measureCache.set(key, res);
return res;
}
interface StreamEditLayerProps {
documentId: string;
pageIndex: number;
width: number;
height: number;
zoom: number;
onEditSuccess: () => void;
onDocumentChanged?: (newDocumentId: string) => void;
}
export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
documentId,
pageIndex,
width,
height,
zoom,
onEditSuccess,
onDocumentChanged,
}) => {
const [objects, setObjects] = useState<TextObjectResponse[]>([]);
const [loading, setLoading] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [value, setValue] = useState('');
const [fontsReady, setFontsReady] = useState(false);
const [fontFamilyMap, setFontFamilyMap] = useState<Record<string, string>>({});
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let active = true;
setLoading(true);
gatewayService.getTextObjects(documentId, pageIndex)
.then(res => {
if (active) setObjects(res);
})
.catch(err => {
console.error('Failed to load text objects', err);
})
.finally(() => {
if (active) setLoading(false);
});
return () => { active = false; };
}, [documentId, pageIndex]);
const heightPts = height / zoom;
const openEditor = (index: number) => {
setEditingIndex(index);
setValue(objects[index].text);
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 10);
};
const commit = async () => {
if (editingIndex === null) return;
const obj = objects[editingIndex];
const newText = value;
const idx = editingIndex;
setEditingIndex(null);
if (newText === obj.text) return;
try {
const res = await gatewayService.updateTextObject(documentId, pageIndex, idx, newText);
if (res.newDocumentId && onDocumentChanged) {
onDocumentChanged(res.newDocumentId);
} else {
setObjects(prev => {
const next = [...prev];
next[idx] = { ...next[idx], text: newText };
return next;
});
onEditSuccess();
}
} catch (e: any) {
console.error('Failed to update text:', e.message);
}
};
const cancel = () => {
setEditingIndex(null);
};
const uniqueFonts = Array.from(new Set(objects.map(o => o.fontName).filter(Boolean)));
const uniqueFontsKey = uniqueFonts.join('|');
useEffect(() => {
if (!uniqueFonts.length) return;
let active = true;
Promise.all(uniqueFonts.map(async (fn) => {
const cssFamily = await loadPdfFont(documentId, fn);
return { fn, cssFamily };
})).then((results) => {
if (!active) return;
let changed = false;
const newMap: Record<string, string> = { ...fontFamilyMap };
for (const res of results) {
if (res.cssFamily && newMap[res.fn] !== res.cssFamily) {
newMap[res.fn] = res.cssFamily;
changed = true;
}
}
if (changed) {
setFontFamilyMap(newMap);
clearMeasureCache();
setFontsReady(v => !v);
}
});
return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uniqueFontsKey, documentId]);
if (loading) {
return null;
}
return (
<div className="absolute top-0 left-0 z-[40]" style={{ width: `${width}px`, height: `${height}px` }}>
{objects.map((obj, i) => {
const pdfX = obj.tm[4];
const pdfY = obj.tm[5];
const left = pdfX * zoom;
const baselineScreenTop = (heightPts - pdfY) * zoom;
const scaleX = obj.tm ? Math.abs(obj.tm[0]) : 1;
const scaleY = obj.tm ? Math.abs(obj.tm[3]) : 1;
const fontSizeScreen = obj.fontSize * scaleY * zoom;
const isEditing = editingIndex === i;
const cssFam = obj.fontName ? fontFamilyMap[obj.fontName] : null;
const fontFamily = cssFam ? `'${cssFam}', sans-serif` : 'sans-serif';
void fontsReady;
const m = measureText(obj.text, fontFamily, obj.fontSize);
const boxWidth = m ? Math.max(m.width * scaleX * zoom, 6) : Math.max(obj.text.length * fontSizeScreen * 0.5, 20);
const ascentScreen = (m ? m.ascent : obj.fontSize * 0.8) * scaleY * zoom;
const descentScreen = (m ? m.descent : obj.fontSize * 0.2) * scaleY * zoom;
const top = baselineScreenTop - ascentScreen;
const boxHeight = ascentScreen + descentScreen;
return (
<div key={i}>
{!isEditing ? (
<div
onClick={() => openEditor(i)}
className="absolute cursor-text border border-blue-500/30 bg-blue-500/10 hover:bg-blue-500/30 hover:border-blue-500 transition-colors"
style={{
left,
top,
width: boxWidth,
height: boxHeight,
borderRadius: '2px',
}}
title={`Font: ${obj.fontName}, Size: ${obj.fontSize}`}
>
<span className="opacity-0">{obj.text}</span>
</div>
) : (
<div
className="absolute z-[41] bg-white shadow-lg border border-blue-500 rounded"
style={{
left: left - 2,
top: top - 2,
minWidth: (boxWidth + 4) * 10,
transform: 'scale(0.1)',
transformOrigin: 'top left',
}}
>
<input
ref={inputRef}
type="text"
value={value}
onChange={e => setValue(e.target.value)}
onBlur={commit}
onKeyDown={e => {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
}}
className="w-full bg-transparent outline-none m-0 px-[10px]"
style={{
fontFamily,
fontSize: `${fontSizeScreen * 10}px`,
lineHeight: `${boxHeight * 10}px`,
}}
/>
</div>
)}
</div>
);
})}
</div>
);
};