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

732 lines
27 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { gatewayService } from '../lib/gatewayService';
import { loadPdfFont, releaseDocumentFonts } from '../lib/fontFaceLoader';
import { sanitizeTextColor } from '../lib/colorUtils';
import { wasmEnsureAuxFont, wasmHasDocument, wasmLoadDocument, wasmPreviewRenderRegion } from '../lib/pdfiumEngine';
import { ParagraphEditor } from './ParagraphEditor';
import type { ReflowAlign } from './ParagraphEditor';
export interface EditableRun {
text: string;
x: number;
y: number;
w: number;
h: number;
baselineY: number;
fontSize: number;
objectIndices: number[];
internalFontId: string;
fontName: string;
color: string;
paraIndex: number;
lineIndex: number;
runIndex: number;
fontFidelity: string; // "exact" | "partial" | "substituted"
}
export interface ReflowFragment {
text: string;
internalFontId: string;
fontSize: number;
color: string;
advances?: number[];
}
export interface CommitFrame {
pageIndex: number;
dataUrl: string;
left: number; top: number; width: number; height: number;
}
export interface ReflowParagraphPayload {
objectIndices: number[];
runs: ReflowFragment[];
columnLeft: number;
columnRight: number;
firstBaselineY: number;
leading: number;
oldLineCount: number;
align: ReflowAlign;
pushColumnLeft?: number;
paraId?: string;
lines?: ReflowFragment[][];
lineBaselineY?: number[];
lineX?: number[];
}
export function isBulletMarker(text?: string): boolean {
if (!text) return false;
const t = text.trim();
return t.length <= 2 && /^[•▪◦‣·●○■□*‒–—\-]$/.test(t);
}
export function buildBulletItem(para: any, runLineIndex: number): { subPara: any; pushColumnLeft: number; leading: number; columnRight: number } | null {
const lines = para?.lines ?? [];
if (!lines.length) return null;
const colLeft = Math.min(...lines.map((l: any) => l.x));
const colRight = Math.max(...lines.map((l: any) => l.x + l.w));
const leadFontKey = (l: any): string => {
const r = (l.runs ?? []).find((x: any) => (x.text ?? '').trim() && !isBulletMarker(x.text));
return r?.internal_font_id ?? '';
};
const hangX = Math.min(...lines.map((l: any) => l.x).filter((x: number) => x > colLeft + 1));
const flushThresh = isFinite(hangX) ? colLeft + (hangX - colLeft) * 0.5 : colLeft + 4;
const flushLeft = (l: any) => l.x <= flushThresh;
const isStart = (idx: number): boolean => {
const l = lines[idx];
if (isBulletMarker((l.runs ?? [])[0]?.text)) return true;
if (idx === 0) return true;
return flushLeft(l) && leadFontKey(l) !== '' && leadFontKey(l) !== leadFontKey(lines[idx - 1]);
};
let start = runLineIndex;
while (start > 0 && !isStart(start)) start--;
let end = runLineIndex + 1;
while (end < lines.length && !isStart(end)) end++;
const itemLines = lines.slice(start, end);
if (!itemLines.length) return null;
const hasBl = (i: number) => typeof lines[i].baseline_y === 'number';
const dlt = (i: number) => Math.abs(lines[i].baseline_y - lines[i + 1].baseline_y);
const itemDeltas: number[] = [];
for (let i = start; i + 1 < end; i++) if (hasBl(i) && hasBl(i + 1)) itemDeltas.push(dlt(i));
const wrapDeltas: number[] = [];
const allDeltas: number[] = [];
for (let i = 0; i + 1 < lines.length; i++) {
if (!hasBl(i) || !hasBl(i + 1)) continue;
allDeltas.push(dlt(i));
if (!flushLeft(lines[i + 1])) wrapDeltas.push(dlt(i));
}
const leading = itemDeltas.length ? median(itemDeltas)
: wrapDeltas.length ? median(wrapDeltas)
: allDeltas.length ? median(allDeltas)
: (itemLines[0].runs?.[0]?.font_size ?? 12) * 1.2;
const firstRuns = itemLines[0].runs ?? [];
let subLines = itemLines;
if (isBulletMarker(firstRuns[0]?.text)) {
let ti = 1;
while (ti < firstRuns.length && !(firstRuns[ti].text ?? '').trim()) ti++;
const textRuns = firstRuns.slice(ti);
if (!textRuns.length) return null;
const textIndent = textRuns[0].x;
subLines = itemLines.map((l: any, idx: number) => idx === 0
? { ...l, runs: textRuns, x: textIndent, w: (l.x + l.w) - textIndent }
: l);
}
return { subPara: { ...para, lines: subLines }, pushColumnLeft: colLeft, leading, columnRight: colRight };
}
function pageContentRight(model: any): number {
let right = -Infinity;
for (const p of model?.paragraphs ?? []) {
for (const l of p.lines ?? []) {
if ((l.runs ?? []).some((r: any) => (r.text ?? '').trim())) right = Math.max(right, l.x + l.w);
}
}
return isFinite(right) ? right : 0;
}
function pageContentLeft(model: any): number {
let left = Infinity;
for (const p of model?.paragraphs ?? []) {
for (const l of p.lines ?? []) {
if ((l.runs ?? []).some((r: any) => (r.text ?? '').trim())) left = Math.min(left, l.x);
}
}
return isFinite(left) ? left : 0;
}
function headingAlign(line: any, model: any): ReflowAlign {
const pl = pageContentLeft(model), pr = pageContentRight(model);
const w = pr - pl;
if (w <= 1 || !line) return 'left';
const leftGap = line.x - pl;
const rightGap = pr - (line.x + line.w);
const tol = w * 0.06;
if (Math.abs(leftGap - rightGap) <= tol && leftGap > tol && rightGap > tol) return 'center';
if (rightGap <= tol && leftGap > tol * 2) return 'right';
return 'left';
}
function isFlowingParagraph(para: any): boolean {
const lines = para?.lines ?? [];
if (lines.length < 2) return false;
const lefts = lines.map((l: any) => l.x);
const colLeft = Math.min(...lefts);
const colRight = Math.max(...lines.map((l: any) => l.x + l.w));
const colW = colRight - colLeft;
if (colW <= 0) return false;
let reaching = 0;
for (let i = 0; i < lines.length - 1; i++) {
if (lines[i].x + lines[i].w >= colRight - colW * 0.06) reaching++;
}
const filled = reaching >= (lines.length - 1) * 0.7;
const consistentLeft = Math.max(...lefts.map((x: number) => Math.abs(x - colLeft))) < colW * 0.12;
return filled && consistentLeft;
}
function tableCells(line: any): any[] {
return (line?.runs ?? []).filter((r: any) => (r.text ?? '').trim());
}
function paraEm(para: any): number {
let capH = 0;
for (const l of (para?.lines ?? [])) for (const r of (l.runs ?? [])) for (const g of (r.glyphs ?? [])) {
if ((g.bbox_h ?? 0) > capH) capH = g.bbox_h;
}
return capH > 0 ? capH / 0.7 : 10;
}
function lineLargeGaps(line: any, em: number): number {
const cells = tableCells(line);
let n = 0;
for (let k = 0; k + 1 < cells.length; k++) {
if (cells[k + 1].x - (cells[k].x + cells[k].w) > em * 0.6) n++;
}
return n;
}
function isTableParagraph(para: any, model: any): boolean {
const lines = para?.lines ?? [];
if (!lines.length) return false;
const em = paraEm(para);
if (lines.length > 1) {
let columnar = 0;
for (const l of lines) if (tableCells(l).length >= 2 && lineLargeGaps(l, em) >= 1) columnar++;
return columnar >= Math.max(2, Math.ceil(lines.length * 0.6));
}
const cells = tableCells(lines[0]);
if (cells.length < 2) return false;
const gaps = lineLargeGaps(lines[0], em);
if (gaps >= 2) return true;
if (gaps < 1) return false;
const cols = cells.map((c: any) => c.x);
for (const p of (model?.paragraphs ?? [])) {
if (p === para) continue;
for (const l of (p.lines ?? [])) {
const oc = tableCells(l);
if (oc.length >= 2 && cols.filter((x: number) => oc.some((r: any) => Math.abs(r.x - x) <= em * 0.6)).length >= 2) return true;
}
}
return false;
}
interface TextEditLayerProps {
documentId: string;
pageIndex: number;
totalPages: number;
width: number;
height: number;
zoom: number;
pageImageUrl?: string;
onEditText?: (pageIndex: number, run: EditableRun, newText: string, disableJustify?: boolean) => void;
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
onOverflowPreview?: (regions: import('./ParagraphEditor').OverflowPreviewRegion[]) => void;
onOverflowCaret?: (caret: import('./ParagraphEditor').OverflowCaret | null) => void;
onCommitPreview?: (frame: CommitFrame) => void;
}
let measureCanvas: HTMLCanvasElement | null = null;
function caretIndexFromX(text: string, cssFont: string, x: number): number {
if (x <= 0) return 0;
if (!measureCanvas) measureCanvas = document.createElement('canvas');
const ctx = measureCanvas.getContext('2d');
if (!ctx) return text.length;
ctx.font = cssFont;
let acc = 0;
for (let i = 0; i < text.length; i++) {
const w = ctx.measureText(text[i]).width;
if (acc + w / 2 >= x) return i;
acc += w;
}
return text.length;
}
function fallbackFamily(fontName: string): string {
const n = (fontName || '').toLowerCase();
if (n.includes('times') || (n.includes('serif') && !n.includes('sans'))) {
return '"Times New Roman", Times, Georgia, serif';
}
if (n.includes('courier') || n.includes('mono')) {
return '"Courier New", Courier, monospace';
}
return 'Arial, "Helvetica Neue", Helvetica, sans-serif';
}
function flattenRuns(model: any): EditableRun[] {
const runs: EditableRun[] = [];
const paragraphs = model?.paragraphs ?? [];
for (let pi = 0; pi < paragraphs.length; pi++) {
const lines = paragraphs[pi].lines ?? [];
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
const baselineY = typeof line.baseline_y === 'number' ? line.baseline_y : 0;
const lineRuns = line.runs ?? [];
for (let ri = 0; ri < lineRuns.length; ri++) {
const r = lineRuns[ri];
const objectIndices: number[] = Array.isArray(r.object_indices) ? r.object_indices : [];
if (typeof r.text === 'string' && r.text.trim() && r.w > 0 && r.h > 0 && objectIndices.length > 0) {
runs.push({
text: r.text,
x: r.x, y: r.y, w: r.w, h: r.h,
baselineY,
fontSize: r.font_size ?? r.h,
objectIndices,
internalFontId: r.internal_font_id ?? '',
fontName: r.font_name ?? '',
color: sanitizeTextColor(r.color),
paraIndex: pi, lineIndex: li, runIndex: ri,
fontFidelity: r.font_fidelity ?? 'exact',
});
}
}
}
}
return runs;
}
function median(xs: number[]): number {
if (xs.length === 0) return 0;
const s = [...xs].sort((a, b) => a - b);
return s[Math.floor(s.length / 2)];
}
function displayFontSize(r: EditableRun): number {
const t = r.text || '';
const hasDescender = /[gjpqy(),;\[\]{}₀-₉]/.test(t);
const hasAscender = /[bdfhklt]/.test(t);
const frac = hasDescender ? 0.92 : hasAscender ? 0.75 : 0.70;
return Math.max(r.fontSize, r.h / frac);
}
function buildReflowPayload(model: any, run: EditableRun, newText: string): ReflowParagraphPayload | null {
const para = model?.paragraphs?.[run.paraIndex];
if (!para || !Array.isArray(para.lines)) return null;
const runs: ReflowParagraphPayload['runs'] = [];
const objectIndices: number[] = [];
let columnLeft = Infinity, columnRight = -Infinity, firstBaselineY = -Infinity;
const baselines: number[] = [];
const rightEdges: number[] = [];
for (let li = 0; li < para.lines.length; li++) {
const line = para.lines[li];
if (typeof line.baseline_y === 'number') baselines.push(line.baseline_y);
columnLeft = Math.min(columnLeft, line.x);
columnRight = Math.max(columnRight, line.x + line.w);
rightEdges.push(line.x + line.w);
if (typeof line.baseline_y === 'number') firstBaselineY = Math.max(firstBaselineY, line.baseline_y);
for (let ri = 0; ri < (line.runs ?? []).length; ri++) {
const r = line.runs[ri];
const oi: number[] = Array.isArray(r.object_indices) ? r.object_indices : [];
objectIndices.push(...oi);
const isEdited = li === run.lineIndex && ri === run.runIndex;
let text = isEdited ? newText : (r.text ?? '');
if (li > 0 && ri === 0 && runs.length > 0) {
const prev = runs[runs.length - 1].text;
if (prev && !/\s$/.test(prev) && !/^\s/.test(text)) text = ' ' + text;
}
runs.push({
text,
internalFontId: r.internal_font_id ?? '',
fontSize: r.font_size ?? run.fontSize,
color: typeof r.color === 'string' ? r.color : '#000000',
});
}
}
if (runs.length === 0 || objectIndices.length === 0) return null;
const deltas: number[] = [];
for (let i = 0; i < baselines.length - 1; i++) deltas.push(baselines[i] - baselines[i + 1]);
const leading = deltas.length ? Math.abs(median(deltas)) : run.fontSize * 1.2;
const colW = columnRight - columnLeft;
let align: 'left' | 'justify' = 'left';
if (para.lines.length >= 2) {
let reaching = 0;
for (let i = 0; i < rightEdges.length - 1; i++) {
if (rightEdges[i] >= columnRight - colW * 0.04) reaching++;
}
if (reaching >= (para.lines.length - 1) * 0.7) align = 'justify';
}
let paraId = '';
for (const ln of para.lines ?? []) { for (const r of ln.runs ?? []) if (r?.para_id) { paraId = r.para_id; break; } if (paraId) break; }
return {
objectIndices, runs, columnLeft, columnRight, firstBaselineY,
leading, oldLineCount: para.lines.length, align,
...(paraId ? { paraId } : {}),
};
}
export const TextEditLayer: React.FC<TextEditLayerProps> = ({
documentId,
pageIndex,
totalPages,
width,
height,
zoom,
onEditText,
onReflowParagraph,
onCommitPreview,
onOverflowPreview,
onOverflowCaret,
}) => {
const [runs, setRuns] = useState<EditableRun[]>([]);
const [editing, setEditing] = useState<number | null>(null);
const [value, setValue] = useState('');
const [fontFamily, setFontFamily] = useState('inherit');
const committedRef = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
const caretIdxRef = useRef<number | null>(null);
const modelRef = useRef<any>(null);
const [paraEdit, setParaEdit] = useState<{
para: any; pushColumnLeft?: number; leading?: number; align?: ReflowAlign; columnLeft?: number; columnRight?: number;
anchorPageIndex?: number;
} | null>(null);
const [caretClick, setCaretClick] = useState<{ x: number; y: number } | null>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const [cellPreviewReady, setCellPreviewReady] = useState(false);
const cellRafRef = useRef<number | null>(null);
const cellRenderingRef = useRef(false);
const cellPendingRef = useRef<{ run: EditableRun; text: string } | null>(null);
useEffect(() => {
let cancelled = false;
setEditing(null);
setParaEdit(null);
gatewayService
.getPageModel(documentId, pageIndex)
.then((model) => {
if (!cancelled) { modelRef.current = model; setRuns(flattenRuns(model)); }
})
.catch(() => {
if (!cancelled) { modelRef.current = null; setRuns([]); }
});
return () => {
cancelled = true;
};
}, [documentId, pageIndex]);
useEffect(() => {
return () => releaseDocumentFonts(documentId);
}, [documentId]);
const heightPts = height / zoom;
const rectOf = (r: EditableRun) => ({
left: r.x * zoom,
top: (heightPts - (r.y + r.h)) * zoom,
width: r.w * zoom,
height: r.h * zoom,
});
const getParaId = (para: any): string => {
for (const ln of para?.lines ?? []) for (const r of ln.runs ?? []) if (r?.para_id) return r.para_id as string;
return '';
};
const stitchByParaId = async (pid: string): Promise<{ para: any; anchorPageIndex: number } | null> => {
const pieces: { page: number; lines: any[] }[] = [];
for (let pg = 0; pg < totalPages; pg++) {
const model = pg === pageIndex
? modelRef.current
: await gatewayService.getPageModel(documentId, pg).catch(() => null);
if (!model) continue;
const lines: any[] = [];
for (const p of model.paragraphs ?? [])
for (const ln of p.lines ?? [])
if ((ln.runs ?? []).some((r: any) => r?.para_id === pid)) lines.push(ln);
if (lines.length) pieces.push({ page: pg, lines });
}
if (pieces.length === 0) return null;
pieces.sort((a, b) => a.page - b.page);
const aLines = pieces[0].lines;
const colLeft = Math.min(...aLines.map((l: any) => l.x));
const firstBaseline = Math.max(...aLines.map((l: any) => (typeof l.baseline_y === 'number' ? l.baseline_y : -Infinity)));
const ubl = [...new Set(aLines.map((l: any) => Math.round((l.baseline_y ?? 0) * 10) / 10))].sort((a, b) => b - a);
const leading = ubl.length > 1 ? Math.abs(ubl[0] - ubl[1]) : 14;
const combined: any[] = [...aLines];
let idx = aLines.length;
for (let pi = 1; pi < pieces.length; pi++) {
for (const ln of pieces[pi].lines) {
combined.push({
...ln, x: colLeft, baseline_y: firstBaseline - idx * leading,
runs: (ln.runs ?? []).map((r: any) => ({ ...r, object_indices: [] })),
});
idx++;
}
}
return { para: { lines: combined }, anchorPageIndex: pieces[0].page };
};
const openParaEdit = async (edit: {
para: any; pushColumnLeft?: number; leading?: number; align?: ReflowAlign; columnLeft?: number; columnRight?: number;
}) => {
const pid = getParaId(edit.para);
if (pid) {
try {
const stitched = await stitchByParaId(pid);
if (stitched) { setParaEdit({ ...edit, para: stitched.para, anchorPageIndex: stitched.anchorPageIndex }); return; }
} catch { }
}
setParaEdit({ ...edit, anchorPageIndex: pageIndex });
};
const openEditor = (i: number, clickX: number, clientX?: number, clientY?: number) => {
const run = runs[i];
const para = modelRef.current?.paragraphs?.[run.paraIndex];
if (Array.isArray(para?.lines) && para.lines.length >= 1 && onReflowParagraph && !isTableParagraph(para, modelRef.current)) {
const click = clientX != null && clientY != null ? { x: clientX, y: clientY } : null;
if (para.lines.length > 1) {
if (isFlowingParagraph(para)) {
setCaretClick(click);
openParaEdit({ para });
return;
}
const item = buildBulletItem(para, run.lineIndex);
if (item) {
setCaretClick(click);
openParaEdit({ para: item.subPara, pushColumnLeft: item.pushColumnLeft, leading: item.leading, align: 'left', columnRight: item.columnRight });
return;
}
} else {
const editableRuns = (para.lines[0].runs ?? []).filter((r: any) => (r.text ?? '').trim()).length;
if (editableRuns > 1) {
setCaretClick(click);
const al = headingAlign(para.lines[0], modelRef.current);
if (al === 'center' || al === 'right') {
openParaEdit({
para, align: al,
columnLeft: pageContentLeft(modelRef.current),
columnRight: pageContentRight(modelRef.current),
});
} else {
openParaEdit({ para, columnRight: pageContentRight(modelRef.current) });
}
return;
}
}
}
committedRef.current = false;
const fb = fallbackFamily(run.fontName);
caretIdxRef.current = caretIndexFromX(run.text, `${displayFontSize(run) * zoom}px ${fb}`, clickX);
setEditing(i);
setValue(run.text);
setFontFamily(fb);
const isSubsetFont = /^[A-Z]{6}\+/.test(run.internalFontId || '');
if (run.internalFontId && !isSubsetFont) {
loadPdfFont(documentId, run.internalFontId).then((family) => {
if (family) setFontFamily(`'${family}', ${fb}`);
});
}
};
const commit = () => {
if (editing === null || committedRef.current) return;
const run = runs[editing];
const next = value;
const para = modelRef.current?.paragraphs?.[run.paraIndex];
const isTable = isTableParagraph(para, modelRef.current);
committedRef.current = true;
setEditing(null);
if (next === run.text) return;
if (onReflowParagraph && !isTable && isFlowingParagraph(para)) {
const payload = buildReflowPayload(modelRef.current, run, next);
if (payload) { onReflowParagraph(pageIndex, payload); return; }
}
onEditText?.(pageIndex, run, next, shouldDisableJustify(para));
};
const cancel = () => {
committedRef.current = true;
setEditing(null);
};
const cellBand = (r: EditableRun) => {
const box = rectOf(r);
const pad = displayFontSize(r) * zoom * 0.45 + 4;
const topScreen = Math.max(0, box.top - pad);
const botScreen = Math.min(height, box.top + box.height + pad);
return { topScreen, heightScreen: Math.max(1, botScreen - topScreen) };
};
const shouldDisableJustify = (para: any): boolean => {
if (isTableParagraph(para, modelRef.current)) return true;
const lines = para?.lines ?? [];
if (lines.length === 1) {
const n = (lines[0].runs ?? []).filter((r: any) => (r.text ?? '').trim()).length;
if (n > 1) return true;
}
return false;
};
const renderCellPreview = async (r: EditableRun, text: string) => {
if (!wasmHasDocument(documentId)) return;
if (r.internalFontId && r.fontFidelity !== 'exact') await wasmEnsureAuxFont(documentId, r.internalFontId);
const para = modelRef.current?.paragraphs?.[r.paraIndex];
const op = JSON.stringify({
version: '1.0',
operations: [{
id: 'cellpreview', type: 'replace_text', pageIndex,
data: {
objectIndices: r.objectIndices, text,
internalFontId: r.internalFontId || '', fontSize: -1.0, disableJustify: shouldDisableJustify(para),
},
}],
});
const dpi = Math.round(72 * zoom);
const { topScreen, heightScreen } = cellBand(r);
const res = await wasmPreviewRenderRegion(documentId, pageIndex, dpi, op, topScreen / zoom, heightScreen / zoom);
if (!res.rgba || res.width <= 0 || res.height <= 0) return;
const cv = previewCanvasRef.current;
if (!cv) return;
if (cv.width !== res.width) cv.width = res.width;
if (cv.height !== res.height) cv.height = res.height;
const ctx = cv.getContext('2d');
if (ctx) { const img = ctx.createImageData(res.width, res.height); img.data.set(res.rgba); ctx.putImageData(img, 0, 0); }
setCellPreviewReady(true);
};
const scheduleCellRender = (r: EditableRun, text: string) => {
cellPendingRef.current = { run: r, text };
if (cellRafRef.current != null || cellRenderingRef.current) return;
cellRafRef.current = window.requestAnimationFrame(async () => {
cellRafRef.current = null;
cellRenderingRef.current = true;
let job = cellPendingRef.current;
while (job) {
cellPendingRef.current = null;
try { await renderCellPreview(job.run, job.text); } catch { /* keep DOM fallback */ }
job = cellPendingRef.current;
}
cellRenderingRef.current = false;
});
};
useEffect(() => {
if (editing === null) { setCellPreviewReady(false); return; }
const r = runs[editing];
if (!r) return;
let cancelled = false;
(async () => {
if (!wasmHasDocument(documentId)) {
const bytes = await gatewayService.getDocumentRaw(documentId);
if (bytes && !cancelled) await wasmLoadDocument(documentId, bytes);
}
if (!cancelled) scheduleCellRender(r, value);
})();
return () => { cancelled = true; };
}, [editing, value, documentId, pageIndex, zoom]);
useEffect(() => () => { if (cellRafRef.current != null) window.cancelAnimationFrame(cellRafRef.current); }, []);
const run = editing !== null ? runs[editing] : null;
return (
<div className="absolute top-0 left-0 z-[35]" style={{ width: `${width}px`, height: `${height}px` }}>
{paraEdit !== null && (
<ParagraphEditor
documentId={documentId}
pageIndex={paraEdit.anchorPageIndex ?? pageIndex}
para={paraEdit.para}
pushColumnLeft={paraEdit.pushColumnLeft}
leadingOverride={paraEdit.leading}
alignOverride={paraEdit.align}
columnLeftOverride={paraEdit.columnLeft}
columnRightOverride={paraEdit.columnRight}
caretClick={caretClick}
heightPts={heightPts}
zoom={zoom}
pageWidthPx={width}
pageHeightPx={height}
onCommitPreview={onCommitPreview}
onOverflowPreview={onOverflowPreview}
onOverflowCaret={onOverflowCaret}
onCommit={(payload) => { const ap = paraEdit.anchorPageIndex ?? pageIndex; setParaEdit(null); onReflowParagraph?.(ap, payload); }}
onCancel={() => setParaEdit(null)}
/>
)}
{editing === null && paraEdit === null &&
runs.map((r, i) => {
const box = rectOf(r);
return (
<div
key={i}
onClick={(e) => openEditor(i, e.nativeEvent.offsetX, e.clientX, e.clientY)}
className="absolute cursor-text hover:bg-[rgba(37,99,235,0.06)]"
style={{ left: box.left, top: box.top, width: box.width, height: box.height }}
/>
);
})}
{run && (() => {
const fpx = displayFontSize(run) * zoom;
const baselineScreen = (heightPts - run.baselineY) * zoom;
const inputTop = baselineScreen - 0.8 * fpx;
const box = rectOf(run);
const band = cellBand(run);
const fidelityNote = run.fontFidelity === 'substituted'
? "Original font isn't embedded for editing — using the closest match."
: run.fontFidelity === 'partial'
? 'Reusing the document font; brand-new characters may use a close match.'
: '';
return (
<>
{/* White cover for the original cell until the engine preview is ready. */}
<div
className="absolute z-[36] bg-white"
style={{ left: box.left - 2, top: box.top - 2, width: box.width + 4, height: box.height + 4 }}
/>
{fidelityNote && (
<div
className="absolute z-[38] rounded bg-amber-50 border border-amber-300 text-amber-800 text-[11px] px-1.5 py-0.5 shadow-sm pointer-events-none whitespace-nowrap"
style={{ left: box.left, top: Math.max(0, box.top - 22) }}
>
{fidelityNote}
</div>
)}
{/* Engine-rendered preview band (covers the whole row; only the edited cell changes). */}
<canvas
ref={previewCanvasRef}
className="absolute z-[36] pointer-events-none"
style={{ left: 0, top: band.topScreen, width: `${width}px`, height: `${band.heightScreen}px`, display: cellPreviewReady ? 'block' : 'none' }}
/>
<input
ref={inputRef}
autoFocus
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onFocus={(e) => {
const idx = caretIdxRef.current;
if (idx != null) {
e.currentTarget.setSelectionRange(idx, idx);
caretIdxRef.current = null;
}
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
}}
spellCheck={false}
className="absolute z-[37] m-0 border-0 bg-transparent p-0 outline-none whitespace-pre"
style={{
left: box.left,
top: inputTop,
width: Math.max(box.width + 120, 60),
height: fpx,
lineHeight: `${fpx}px`,
fontFamily,
fontSize: `${fpx}px`,
color: cellPreviewReady ? 'transparent' : run.color,
caretColor: run.color,
}}
/>
</>
);
})()}
</div>
);
};