732 lines
27 KiB
TypeScript
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>
|
|
);
|
|
};
|