225 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
};
|