1390 lines
56 KiB
TypeScript
1390 lines
56 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { gatewayService } from '../lib/gatewayService';
|
||
import { wasmLoadDocument, wasmHasDocument, wasmPreviewRenderPaginated, wasmEnsureAuxFont } from '../lib/pdfiumEngine';
|
||
import type { ReflowLayout } from '../lib/pdfiumEngine';
|
||
import { loadPdfFont } from '../lib/fontFaceLoader';
|
||
|
||
export interface OverflowPreviewRegion { pageIndex: number; yTopPt: number; dataUrl: string; }
|
||
export interface OverflowCaret { pageIndex: number; left: number; top: number; height: number; }
|
||
import type { ReflowParagraphPayload, CommitFrame } from './TextEditLayer';
|
||
import type { ReflowFragment } from '../lib/gatewayService';
|
||
import { sanitizeTextColor } from '../lib/colorUtils';
|
||
|
||
interface SeedRun { text: string; fid: string; size: number; color: string; fontName: string; advances?: number[]; }
|
||
interface OrigLine { frags: { text: string; fid: string; size: number; color: string; advances?: number[] }[]; x: number; baselineY: number; }
|
||
interface ParagraphLayout {
|
||
columnLeft: number; columnRight: number; firstBaselineY: number; leading: number;
|
||
oldLineCount: number; align: ReflowAlign; objectIndices: number[]; seedRuns: SeedRun[];
|
||
origLines: OrigLine[];
|
||
}
|
||
export type ReflowAlign = 'left' | 'justify' | 'center' | 'right';
|
||
|
||
function lineAdvances(line: any): { perRun: Record<number, number[]>; anchorX: number } {
|
||
const runs = line?.runs ?? [];
|
||
const seq: Array<{ ri: number; ci: number; ox: number }> = [];
|
||
const aligned: Record<number, boolean> = {};
|
||
for (let ri = 0; ri < runs.length; ri++) {
|
||
const gs = runs[ri].glyphs ?? [];
|
||
const ok = !!runs[ri].text && gs.length === runs[ri].text.length;
|
||
aligned[ri] = ok;
|
||
if (ok) for (let ci = 0; ci < runs[ri].text.length; ci++) seq.push({ ri, ci, ox: gs[ci].origin_x });
|
||
}
|
||
const perRun: Record<number, number[]> = {};
|
||
for (let ri = 0; ri < runs.length; ri++) if (aligned[ri]) perRun[ri] = new Array(runs[ri].text.length).fill(0);
|
||
const anchorX = seq.length ? seq[0].ox : (line?.x ?? 0);
|
||
for (let k = 0; k < seq.length; k++) {
|
||
const { ri, ci, ox } = seq[k];
|
||
const gs = runs[ri]?.glyphs ?? [];
|
||
const g = gs[ci];
|
||
const charW = (g?.bbox_w && g.bbox_w > 0) ? g.bbox_w : (runs[ri]?.font_size ?? 12) * 0.5;
|
||
perRun[ri][ci] = k + 1 < seq.length ? seq[k + 1].ox - ox : charW;
|
||
}
|
||
for (const k of Object.keys(perRun)) {
|
||
const ri = Number(k);
|
||
if (perRun[ri].some((a) => a <= 0)) delete perRun[ri];
|
||
}
|
||
return { perRun, anchorX };
|
||
}
|
||
interface ParagraphEditorProps {
|
||
documentId: string;
|
||
pageIndex: number;
|
||
para: any;
|
||
pushColumnLeft?: number;
|
||
leadingOverride?: number;
|
||
alignOverride?: ReflowAlign;
|
||
columnLeftOverride?: number;
|
||
columnRightOverride?: number;
|
||
caretClick?: { x: number; y: number } | null;
|
||
heightPts: number;
|
||
zoom: number;
|
||
pageWidthPx: number;
|
||
pageHeightPx: number;
|
||
onCommit: (payload: ReflowParagraphPayload) => void;
|
||
onCommitPreview?: (frame: CommitFrame) => void;
|
||
onOverflowPreview?: (regions: OverflowPreviewRegion[]) => void;
|
||
onOverflowCaret?: (caret: OverflowCaret | null) => void;
|
||
onCancel: () => void;
|
||
}
|
||
|
||
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 paraEffSize(lines: any[]): number {
|
||
let nominal = 0;
|
||
for (const line of lines) for (const r of (line.runs ?? [])) {
|
||
const sz = r.font_size && r.font_size > 0 ? r.font_size : (r.h ?? 0) * 0.8;
|
||
if (sz > nominal) nominal = sz;
|
||
}
|
||
return nominal || 12;
|
||
}
|
||
function computeLayout(para: any): ParagraphLayout {
|
||
const lines = para?.lines ?? [];
|
||
const effSize = paraEffSize(lines);
|
||
let columnLeft = Infinity, firstBaselineY = -Infinity;
|
||
const baselines: number[] = [], rightEdges: number[] = [], objectIndices: number[] = [];
|
||
const seedRuns: SeedRun[] = [];
|
||
const origLines: OrigLine[] = [];
|
||
for (let li = 0; li < lines.length; li++) {
|
||
const line = lines[li];
|
||
if (typeof line.baseline_y === 'number') { baselines.push(line.baseline_y); firstBaselineY = Math.max(firstBaselineY, line.baseline_y); }
|
||
columnLeft = Math.min(columnLeft, line.x);
|
||
rightEdges.push(line.x + line.w);
|
||
const lineRuns = line.runs ?? [];
|
||
const { perRun, anchorX } = lineAdvances(line);
|
||
const lineFrags: OrigLine['frags'] = [];
|
||
for (let ri = 0; ri < lineRuns.length; ri++) {
|
||
const r = lineRuns[ri];
|
||
(Array.isArray(r.object_indices) ? r.object_indices : []).forEach((o: number) => objectIndices.push(o));
|
||
const orig = r.text ?? '';
|
||
let text = orig;
|
||
if (li > 0 && ri === 0 && seedRuns.length > 0) {
|
||
const prev = seedRuns[seedRuns.length - 1].text;
|
||
if (prev && !/\s$/.test(prev) && !/^\s/.test(text)) text = ' ' + text;
|
||
}
|
||
const adv = perRun[ri];
|
||
const safeColor = sanitizeTextColor(r.color);
|
||
const rSize = (r.font_size && r.font_size > 0 ? r.font_size : (r.h ?? 0) * 0.8) || effSize;
|
||
seedRuns.push({ text, fid: r.internal_font_id ?? '', size: rSize, color: safeColor, fontName: r.font_name ?? '', advances: adv });
|
||
if (orig) lineFrags.push({ text: orig, fid: r.internal_font_id ?? '', size: rSize, color: safeColor, advances: adv });
|
||
}
|
||
if (lineFrags.length) origLines.push({ frags: lineFrags, x: anchorX, baselineY: line.baseline_y ?? 0 });
|
||
}
|
||
const maxRightEdge = rightEdges.length ? Math.max(...rightEdges) : columnLeft + 250;
|
||
const columnRight = isFinite(maxRightEdge) ? Math.max(maxRightEdge, columnLeft + 250) : columnLeft + 250;
|
||
const deltas: number[] = [];
|
||
for (let i = 0; i < baselines.length - 1; i++) deltas.push(baselines[i] - baselines[i + 1]);
|
||
const domSize = seedRuns.find((r) => r.text.trim())?.size ?? 12;
|
||
// Prefer extracted line box height over browser-ish 1.2×fontSize for single-line paras.
|
||
const extractedLineH = lines.reduce((m: number, ln: any) => Math.max(m, ln?.h ?? 0), 0);
|
||
const leading = deltas.length ? Math.abs(median(deltas)) : (extractedLineH > 0.5 ? extractedLineH : domSize * 1.2);
|
||
const colW = columnRight - columnLeft;
|
||
let align: 'left' | 'justify' = 'left';
|
||
if (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 >= (lines.length - 1) * 0.7) align = 'justify';
|
||
}
|
||
return { columnLeft, columnRight, firstBaselineY, leading, oldLineCount: lines.length, align, objectIndices, seedRuns, origLines };
|
||
}
|
||
|
||
function resolveStyleEl(node: Text, root: HTMLElement): HTMLElement | null {
|
||
let el: HTMLElement | null = node.parentElement;
|
||
while (el && el !== root) {
|
||
if (el.hasAttribute('data-fid')) return el;
|
||
el = el.parentElement;
|
||
}
|
||
const firstStyled = (n: Node): HTMLElement | null => {
|
||
if (n.nodeType !== 1) return null;
|
||
const e = n as HTMLElement;
|
||
if (e.hasAttribute('data-fid')) return e;
|
||
return (e.querySelector?.('[data-fid]') as HTMLElement | null) ?? null;
|
||
};
|
||
const lastStyled = (n: Node): HTMLElement | null => {
|
||
if (n.nodeType !== 1) return null;
|
||
const e = n as HTMLElement;
|
||
if (e.hasAttribute('data-fid')) return e;
|
||
const all = e.querySelectorAll?.('[data-fid]');
|
||
return all && all.length ? (all[all.length - 1] as HTMLElement) : null;
|
||
};
|
||
let cur: Node | null = node;
|
||
while (cur && cur !== root) {
|
||
for (let s = cur.previousSibling; s; s = s.previousSibling) { const r = lastStyled(s); if (r) return r; }
|
||
for (let s = cur.nextSibling; s; s = s.nextSibling) { const r = firstStyled(s); if (r) return r; }
|
||
cur = cur.parentNode;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const BLOCK_TAGS = /^(DIV|P|LI|BLOCKQUOTE|PRE)$/;
|
||
|
||
let markerMeasureCanvas: HTMLCanvasElement | null = null;
|
||
|
||
function mergeSeedAdvances(
|
||
seed: string,
|
||
seedAdv: number[],
|
||
text: string,
|
||
fillWidth: number,
|
||
): number[] {
|
||
// Caret-only: keep LCP/LCS seed metrics; fill edited middle (measured or fallback).
|
||
const out = new Array<number>(text.length);
|
||
if (seedAdv.length !== seed.length) {
|
||
for (let i = 0; i < text.length; i++) out[i] = fillWidth;
|
||
return out;
|
||
}
|
||
let p = 0;
|
||
while (p < seed.length && p < text.length && seed[p] === text[p]) {
|
||
out[p] = seedAdv[p];
|
||
p++;
|
||
}
|
||
let s = 0;
|
||
while (
|
||
s < seed.length - p && s < text.length - p
|
||
&& seed[seed.length - 1 - s] === text[text.length - 1 - s]
|
||
) {
|
||
out[text.length - 1 - s] = seedAdv[seed.length - 1 - s];
|
||
s++;
|
||
}
|
||
for (let i = p; i < text.length - s; i++) out[i] = fillWidth;
|
||
return out;
|
||
}
|
||
|
||
/** Per-char widths via canvas (for caret). Engine uses HarfBuzz for the same middle. */
|
||
function measureCharAdvances(
|
||
text: string,
|
||
sizePt: number,
|
||
family: string,
|
||
weight: number,
|
||
): number[] {
|
||
if (!text) return [];
|
||
if (!markerMeasureCanvas) markerMeasureCanvas = document.createElement('canvas');
|
||
const ctx = markerMeasureCanvas.getContext('2d');
|
||
if (!ctx) return Array.from({ length: text.length }, () => sizePt * 0.5);
|
||
ctx.font = `${weight} ${sizePt}px ${family}`;
|
||
const out: number[] = [];
|
||
for (let i = 0; i < text.length; i++) {
|
||
const w = ctx.measureText(text[i]).width;
|
||
out.push(w > 0 ? w : sizePt * 0.5);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function expandAdvancesForCaret(
|
||
text: string,
|
||
advances: number[] | undefined,
|
||
seedText: string | undefined,
|
||
sizePt: number,
|
||
family: string,
|
||
weight: number,
|
||
): number[] {
|
||
if (advances && advances.length === text.length) return advances.slice();
|
||
const measured = measureCharAdvances(text, sizePt, family, weight);
|
||
if (advances && seedText && advances.length === seedText.length) {
|
||
return mergeSeedAdvances(seedText, advances, text, 0).map((a, i) =>
|
||
a > 0 ? a : (measured[i] ?? sizePt * 0.5));
|
||
}
|
||
if (advances && advances.length > 0 && advances.length < text.length && text.startsWith(
|
||
seedText && seedText.length === advances.length ? seedText : text.slice(0, advances.length),
|
||
)) {
|
||
const seed = seedText && seedText.length === advances.length ? seedText : text.slice(0, advances.length);
|
||
return mergeSeedAdvances(seed, advances, text, 0).map((a, i) =>
|
||
a > 0 ? a : (measured[i] ?? sizePt * 0.5));
|
||
}
|
||
if (advances && advances.length > text.length) return advances.slice(0, text.length);
|
||
return measured;
|
||
}
|
||
|
||
function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: number, domColor: string): ReflowFragment[] {
|
||
const out: ReflowFragment[] = [];
|
||
const pushBreak = () => {
|
||
if (!out.length) return; // ignore a leading break (no preceding line to end)
|
||
const prev = out[out.length - 1];
|
||
if (prev.text === '\n') return; // collapse consecutive structural breaks
|
||
out.push({ text: '\n', internalFontId: prev.internalFontId, fontSize: prev.fontSize, color: prev.color });
|
||
};
|
||
const walk = (node: Node) => {
|
||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||
if (child.nodeType === Node.TEXT_NODE) {
|
||
const raw = child.textContent ?? '';
|
||
if (!raw) continue;
|
||
const styleEl = resolveStyleEl(child as Text, editable);
|
||
const fid = styleEl?.getAttribute('data-fid') || dominantFid;
|
||
const size = parseFloat(styleEl?.getAttribute('data-size') ?? '') || domSize;
|
||
const color = styleEl?.getAttribute('data-color') ?? domColor;
|
||
const parts = raw.split('\n');
|
||
for (let i = 0; i < parts.length; i++) {
|
||
if (i > 0) pushBreak();
|
||
const text = parts[i];
|
||
if (!text) continue;
|
||
const frag: ReflowFragment = { text, internalFontId: fid, fontSize: size, color };
|
||
const aRaw = (child as Text).parentElement === styleEl ? styleEl?.getAttribute('data-advances') : null;
|
||
const seedText = (child as Text).parentElement === styleEl ? styleEl?.getAttribute('data-seed-text') : null;
|
||
if (aRaw) {
|
||
try {
|
||
const a = JSON.parse(aRaw) as number[];
|
||
if (Array.isArray(a) && a.length === text.length) {
|
||
// Unchanged (or already expanded) — keep as-is.
|
||
frag.advances = a;
|
||
} else if (Array.isArray(a) && seedText && a.length === seedText.length && text !== seedText) {
|
||
// Do NOT fill middle with size×0.5 — that locks the engine into fake advances
|
||
// and skips HarfBuzz for new glyphs (thin/narrow typed text). Send seed metrics
|
||
// + advanceSeedText so the engine HB-shapes only the edited middle.
|
||
frag.advances = a;
|
||
frag.advanceSeedText = seedText;
|
||
} else if (Array.isArray(a) && a.length > 0 && a.length < text.length) {
|
||
const seed = (seedText && seedText.length === a.length) ? seedText : text.slice(0, a.length);
|
||
frag.advances = a;
|
||
frag.advanceSeedText = seed;
|
||
} else if (Array.isArray(a) && a.length > text.length) {
|
||
frag.advances = a.slice(0, text.length);
|
||
}
|
||
} catch { /* ignore bad data-advances */ }
|
||
}
|
||
out.push(frag);
|
||
}
|
||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||
const el = child as HTMLElement;
|
||
if (el.tagName === 'BR') { pushBreak(); continue; }
|
||
if (BLOCK_TAGS.test(el.tagName)) pushBreak(); // a block boundary is a line break
|
||
walk(el);
|
||
}
|
||
}
|
||
};
|
||
walk(editable);
|
||
return out;
|
||
}
|
||
|
||
function domTextWithBreaks(editable: HTMLElement): string {
|
||
let s = '';
|
||
const walk = (node: Node) => {
|
||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||
if (child.nodeType === Node.TEXT_NODE) s += child.textContent ?? '';
|
||
else if (child.nodeType === Node.ELEMENT_NODE) {
|
||
const el = child as HTMLElement;
|
||
if (el.tagName === 'BR') s += '\n';
|
||
else { if (BLOCK_TAGS.test(el.tagName) && s && !s.endsWith('\n')) s += '\n'; walk(el); }
|
||
}
|
||
}
|
||
};
|
||
walk(editable);
|
||
return s;
|
||
}
|
||
|
||
function normalizeForCompare(s: string): string {
|
||
return s.replace(/[ \t]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n+$/, '').replace(/^\n+/, '').trim();
|
||
}
|
||
|
||
export type ListKind = 'bullet' | 'ordered';
|
||
|
||
function splitSegmentsByBreak(runs: ReflowFragment[]): ReflowFragment[][] {
|
||
const segs: ReflowFragment[][] = [[]];
|
||
for (const r of runs) {
|
||
const parts = r.text.split('\n');
|
||
for (let i = 0; i < parts.length; i++) {
|
||
if (i > 0) segs.push([]);
|
||
if (parts[i]) segs[segs.length - 1].push({ ...r, text: parts[i] });
|
||
}
|
||
}
|
||
return segs;
|
||
}
|
||
|
||
function measureTextWidth(text: string, sizePt: number, family: string): number {
|
||
if (!markerMeasureCanvas) markerMeasureCanvas = document.createElement('canvas');
|
||
const ctx = markerMeasureCanvas.getContext('2d');
|
||
if (!ctx) return sizePt;
|
||
ctx.font = `${sizePt}px ${family}`;
|
||
return ctx.measureText(text).width;
|
||
}
|
||
|
||
function applyListMarkers(
|
||
runs: ReflowFragment[],
|
||
kind: ListKind,
|
||
dom: { fid: string; size: number; color: string; family: string },
|
||
): { runs: ReflowFragment[]; hangingIndent: number; marker: string } {
|
||
const segs = splitSegmentsByBreak(runs);
|
||
let n = 0;
|
||
let lastMarker = kind === 'ordered' ? '1. ' : '• ';
|
||
const out: ReflowFragment[] = [];
|
||
segs.forEach((seg, si) => {
|
||
if (si > 0) out.push({ text: '\n', internalFontId: dom.fid, fontSize: dom.size, color: dom.color });
|
||
if (!seg.some((r) => r.text.trim())) { out.push(...seg); return; }
|
||
n++;
|
||
const marker = kind === 'ordered' ? `${n}. ` : '• ';
|
||
lastMarker = marker;
|
||
out.push({ text: marker, internalFontId: dom.fid, fontSize: dom.size, color: dom.color });
|
||
out.push(...seg);
|
||
});
|
||
const hangingIndent = measureTextWidth(lastMarker, dom.size, dom.family);
|
||
return { runs: out, hangingIndent, marker: lastMarker };
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* Defensive guard for the pre-compiled WASM engine which may still contain the
|
||
* matrix-override bug (using horizontal scale instead of vertical scale to
|
||
* compute font size). If any line's fontSize is more than 20% smaller than the
|
||
* expected domSize, clamp all lines back to domSize so that:
|
||
* (a) the custom caret renders at the correct vertical position, and
|
||
* (b) the editing bounding box does not visually shrink.
|
||
*
|
||
* The authoritative fix lives in pdfium_edit_reflow.cpp (matrix-override block
|
||
* removed). This guard will become a no-op once a rebuilt WASM is deployed.
|
||
*/
|
||
function sanitizeEngineLayout(
|
||
lay: import('../lib/pdfiumEngine').ReflowLayout | null,
|
||
expectedFontSize: number,
|
||
): import('../lib/pdfiumEngine').ReflowLayout | null {
|
||
if (!lay || !lay.lines) return lay;
|
||
for (const line of lay.lines) {
|
||
console.log('[STAGE_6_SANITIZE_LAYOUT]', { lineFontSize: line.fontSize, expectedFontSize });
|
||
if (line.fontSize && expectedFontSize > 0 && line.fontSize < expectedFontSize * 0.8) {
|
||
line.fontSize = expectedFontSize;
|
||
}
|
||
}
|
||
return lay;
|
||
}
|
||
|
||
function globalCaretOffset(el: HTMLElement, caretRefVal?: number): number {
|
||
if (typeof caretRefVal === 'number') return caretRefVal;
|
||
return (el.textContent ?? '').length;
|
||
}
|
||
|
||
/** Live caret index inside a contentEditable — matches setGlobalCaretOffset / textContent indexing. */
|
||
function getDomCaretOffset(el: HTMLElement): number | null {
|
||
const sel = window.getSelection();
|
||
if (!sel || sel.rangeCount === 0) return null;
|
||
const range = sel.getRangeAt(0);
|
||
if (!el.contains(range.startContainer)) return null;
|
||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||
let acc = 0;
|
||
let node = walker.nextNode() as Text | null;
|
||
while (node) {
|
||
if (node === range.startContainer) return acc + range.startOffset;
|
||
acc += node.textContent?.length ?? 0;
|
||
node = walker.nextNode() as Text | null;
|
||
}
|
||
// Caret after a trailing <br> / at end of empty editor
|
||
return acc;
|
||
}
|
||
|
||
/** Caret metrics from typed runs (seed advances + measured middle). */
|
||
function caretLayoutFromRuns(
|
||
runs: ReflowFragment[],
|
||
opts: {
|
||
columnLeft: number;
|
||
columnRight: number;
|
||
firstBaselineY: number;
|
||
leading: number;
|
||
pageIndex: number;
|
||
fontSize: number;
|
||
/** Ink origin of first line (often slightly left of columnLeft). */
|
||
anchorX0?: number;
|
||
measureFamily?: string;
|
||
fontWeight?: number;
|
||
},
|
||
): ReflowLayout {
|
||
const lines: ReflowLayout['lines'] = [];
|
||
const lineStartX = opts.anchorX0 ?? opts.columnLeft;
|
||
let text = '';
|
||
let adv: number[] = [];
|
||
let x = lineStartX;
|
||
let x0 = lineStartX;
|
||
let baselineY = opts.firstBaselineY;
|
||
const fontSize = opts.fontSize;
|
||
const family = opts.measureFamily ?? 'Arial, sans-serif';
|
||
const weight = opts.fontWeight ?? 400;
|
||
const flush = () => {
|
||
lines.push({
|
||
baselineY,
|
||
x0,
|
||
fontSize,
|
||
text,
|
||
adv: adv.slice(),
|
||
pageIndex: opts.pageIndex,
|
||
});
|
||
text = '';
|
||
adv = [];
|
||
x = opts.columnLeft;
|
||
x0 = opts.columnLeft;
|
||
baselineY -= opts.leading;
|
||
};
|
||
for (const r of runs) {
|
||
if (r.text === '\n') {
|
||
flush();
|
||
continue;
|
||
}
|
||
const size = r.fontSize || fontSize;
|
||
const runAdv = expandAdvancesForCaret(
|
||
r.text, r.advances, r.advanceSeedText, size, family, weight,
|
||
);
|
||
for (let i = 0; i < r.text.length; i++) {
|
||
const a = runAdv[i] ?? size * 0.5;
|
||
if (text.length > 0 && x + a > opts.columnRight + 0.01) flush();
|
||
if (text.length === 0) {
|
||
x0 = lines.length === 0 ? lineStartX : opts.columnLeft;
|
||
x = x0;
|
||
}
|
||
text += r.text[i];
|
||
adv.push(a);
|
||
x += a;
|
||
}
|
||
}
|
||
if (text.length > 0 || lines.length === 0) flush();
|
||
return { columnLeft: opts.columnLeft, anchorPage: opts.pageIndex, lines };
|
||
}
|
||
|
||
function layoutTextMatchesEditor(lay: ReflowLayout | null, editorText: string): boolean {
|
||
if (!lay?.lines?.length) return false;
|
||
if (!lay.lines.every((l) => Array.isArray(l.adv) && l.adv.length === l.text.length)) return false;
|
||
const norm = (s: string) => s.replace(/\s+/g, '');
|
||
return norm(lay.lines.map((l) => l.text).join('')) === norm(editorText);
|
||
}
|
||
|
||
function setGlobalCaretOffset(el: HTMLElement, target: number): void {
|
||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||
let acc = 0;
|
||
let node = walker.nextNode() as Text | null;
|
||
while (node) {
|
||
const len = node.textContent?.length ?? 0;
|
||
if (acc + len >= target) {
|
||
const range = document.createRange();
|
||
range.setStart(node, Math.max(0, Math.min(target - acc, len)));
|
||
range.collapse(true);
|
||
const sel = window.getSelection();
|
||
sel?.removeAllRanges(); sel?.addRange(range);
|
||
return;
|
||
}
|
||
acc += len;
|
||
node = walker.nextNode() as Text | null;
|
||
}
|
||
}
|
||
|
||
function lineStarts(layout: ReflowLayout, fullText: string): number[] {
|
||
const starts: number[] = [];
|
||
let pos = 0;
|
||
for (let i = 0; i < layout.lines.length; i++) {
|
||
if (i > 0) { while (pos < fullText.length && /\s/.test(fullText[pos])) pos++; }
|
||
starts.push(pos);
|
||
pos += layout.lines[i].text.length;
|
||
}
|
||
return starts;
|
||
}
|
||
|
||
/** Caret layout from extracted PDF lines — no WASM reflow. Used on edit-entry so click is a visual no-op. */
|
||
function layoutFromOrigLines(
|
||
origLines: OrigLine[],
|
||
columnLeft: number,
|
||
pageIndex: number,
|
||
fallbackSize: number,
|
||
): ReflowLayout {
|
||
return {
|
||
columnLeft,
|
||
anchorPage: pageIndex,
|
||
lines: origLines.map((l) => {
|
||
const text = l.frags.map((f) => f.text).join('');
|
||
const adv = l.frags.flatMap((f) => {
|
||
if (f.advances && f.advances.length === f.text.length) return f.advances;
|
||
const n = f.text.length;
|
||
const approx = (f.size || fallbackSize) * 0.5;
|
||
return Array.from({ length: n }, () => approx);
|
||
});
|
||
return {
|
||
baselineY: l.baselineY,
|
||
x0: l.x,
|
||
fontSize: l.frags.find((f) => f.size > 1)?.size ?? fallbackSize,
|
||
text,
|
||
adv: adv.length === text.length ? adv : Array.from({ length: text.length }, () => fallbackSize * 0.5),
|
||
pageIndex,
|
||
};
|
||
}),
|
||
};
|
||
}
|
||
|
||
export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||
documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride, columnLeftOverride, columnRightOverride,
|
||
caretClick: _caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCommitPreview, onOverflowPreview, onOverflowCaret, onCancel,
|
||
}) => {
|
||
const layout = useMemo(() => computeLayout(para), [para]);
|
||
const leading = leadingOverride ?? layout.leading;
|
||
const align = alignOverride ?? layout.align;
|
||
const columnLeft = columnLeftOverride ?? layout.columnLeft;
|
||
const columnRight = columnRightOverride ?? layout.columnRight;
|
||
const paraIdRef = useRef<string>('');
|
||
if (!paraIdRef.current) {
|
||
let existing = '';
|
||
for (const ln of (para?.lines ?? [])) {
|
||
for (const r of (ln.runs ?? [])) if (r?.para_id) { existing = r.para_id; break; }
|
||
if (existing) break;
|
||
}
|
||
paraIdRef.current = existing
|
||
|| `p-${globalThis.crypto?.randomUUID?.() ?? (Math.random().toString(36).slice(2) + Date.now().toString(36))}`;
|
||
}
|
||
const editRef = useRef<HTMLDivElement>(null);
|
||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||
const committedRef = useRef(false);
|
||
const initialTextRef = useRef('');
|
||
const rafRef = useRef<number | null>(null);
|
||
const renderingRef = useRef(false);
|
||
const renderDirtyRef = useRef(false);
|
||
const engineLayoutRef = useRef<ReflowLayout | null>(null);
|
||
const initialCaretApplied = useRef(false);
|
||
const editedRef = useRef(false);
|
||
const [hasPreview, setHasPreview] = useState(false);
|
||
const [caretBox, setCaretBox] = useState<{ left: number; top: number; height: number } | null>(null);
|
||
const [edited, setEdited] = useState(false);
|
||
const [wasmFailed, setWasmFailed] = useState(false);
|
||
const [listKind, setListKind] = useState<ListKind | null>(null);
|
||
const [indentLevel, setIndentLevel] = useState(0);
|
||
const INDENT_STEP = 18; // pt per nesting level (whole-block indent; per-item nesting is a known limit)
|
||
|
||
const dominantFid = layout.seedRuns.find((r) => r.text.trim() && r.fid)?.fid
|
||
?? layout.seedRuns.find((r) => r.fid)?.fid ?? '';
|
||
const domRun = layout.seedRuns.find((r) => r.text.trim() && r.fid === dominantFid)
|
||
?? layout.seedRuns.find((r) => r.text.trim());
|
||
const domSize = domRun?.size ?? layout.seedRuns[0]?.size ?? 12;
|
||
const domColor = domRun?.color ?? '#000000';
|
||
const domFontName = domRun?.fontName ?? '';
|
||
|
||
// Extracted glyph/line metrics for overlay geometry (Fix 3 / Fix 4).
|
||
const paraBox = useMemo(() => {
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
let ascent = 0, descent = 0;
|
||
const bl = layout.firstBaselineY;
|
||
for (const ln of (para?.lines ?? []) as any[]) {
|
||
for (const r of (ln.runs ?? [])) {
|
||
for (const g of (r.glyphs ?? [])) {
|
||
const x0 = g.bbox_x, y0 = g.bbox_y, x1 = g.bbox_x + g.bbox_w, y1 = g.bbox_y + g.bbox_h;
|
||
minX = Math.min(minX, x0); minY = Math.min(minY, y0);
|
||
maxX = Math.max(maxX, x1); maxY = Math.max(maxY, y1);
|
||
const gbl = g.origin_y ?? ln.baseline_y ?? bl;
|
||
ascent = Math.max(ascent, y1 - gbl);
|
||
descent = Math.max(descent, gbl - y0);
|
||
}
|
||
}
|
||
}
|
||
const lineH = (para?.lines ?? []).reduce((m: number, ln: any) => Math.max(m, ln?.h ?? 0), 0);
|
||
if (!isFinite(minX)) {
|
||
return { x: layout.columnLeft, y: bl - domSize * 0.8, w: Math.max(0, columnRight - layout.columnLeft),
|
||
h: lineH || domSize * 1.2, ascent: domSize * 0.8, descent: domSize * 0.2, lineHeight: lineH || domSize * 1.2 };
|
||
}
|
||
return {
|
||
x: minX, y: minY, w: maxX - minX, h: maxY - minY,
|
||
ascent: ascent || domSize * 0.8, descent, lineHeight: lineH > 0.5 ? lineH : (maxY - minY),
|
||
};
|
||
}, [para, layout.columnLeft, layout.firstBaselineY, columnRight, domSize]);
|
||
|
||
// Fix 3: line-height from extracted metrics (not size×1.2 / browser normal).
|
||
const lineHeightPt = leadingOverride ?? paraBox.lineHeight;
|
||
const fontPx = domSize * zoom;
|
||
const leadingPx = lineHeightPt * zoom;
|
||
// Fix 4: overlay geometry matches extracted paragraph bbox (not page column).
|
||
const overlayLeftPx = paraBox.x * zoom;
|
||
const overlayWidthPx = Math.max(paraBox.w, 1) * zoom;
|
||
const overlayHeightPx = Math.max(paraBox.h, lineHeightPt) * zoom;
|
||
const firstBaselineScreen = (heightPts - layout.firstBaselineY) * zoom;
|
||
const editorTop = firstBaselineScreen - paraBox.ascent * zoom;
|
||
const bandTop = Math.max(0, Math.min(editorTop - leadingPx * 0.5, firstBaselineScreen - fontPx * 1.15));
|
||
// Keep column metrics for reflow payload / toolbar (editing column can be wider than ink bbox).
|
||
const colLeftPx = columnLeft * zoom;
|
||
const colWidthPx = (columnRight - columnLeft) * zoom;
|
||
|
||
// Fix 1: use extracted PDF font name (loaded via @font-face), not a generic Arial/Times/Courier map.
|
||
let extractedFamily = (domFontName || '').replace(/^[A-Z]{6}\+/, '').trim() || 'sans-serif';
|
||
// FIX: DO NOT split on hyphens here. If the font was downloaded via @font-face (e.g. "Arial-BoldMT"),
|
||
// we must use the exact string "Arial-BoldMT" so the browser maps to the downloaded font, not the OS font.
|
||
|
||
// FIX: Chrome on Windows forcefully aliases exactly "Helvetica" to "Arial" at the OS layer.
|
||
// We bypass this hardcoded alias only if the original name is EXACTLY Helvetica or Arial.
|
||
const rawFamily = extractedFamily.toLowerCase();
|
||
if (rawFamily === 'helvetica' || rawFamily === 'arial') {
|
||
extractedFamily = `"Inter", ${extractedFamily}`;
|
||
}
|
||
|
||
let fallbackFamily = '"Arimo", Arial, sans-serif';
|
||
if (/times|serif/i.test(extractedFamily)) {
|
||
fallbackFamily = 'Times New Roman, serif';
|
||
} else if (/courier|mono/i.test(extractedFamily)) {
|
||
fallbackFamily = 'Courier New, monospace';
|
||
} else {
|
||
fallbackFamily = '"Inter", "Arimo", Arial, sans-serif';
|
||
}
|
||
|
||
const [measureFamily, setMeasureFamily] = useState(
|
||
extractedFamily !== 'sans-serif' ? `'${extractedFamily}', ${fallbackFamily}` : fallbackFamily,
|
||
);
|
||
|
||
// Fix 2: preserve PDF bold/italic weight (BoldMT etc. previously fell through as 400).
|
||
const extractedFontWeight: number = /bold|black|heavy|semibold|demibold/i.test(extractedFamily)
|
||
|| /_B(?:old)?(?:_|$)/i.test(dominantFid)
|
||
? 700
|
||
: 400;
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
if (!dominantFid || !documentId) return;
|
||
loadPdfFont(documentId, dominantFid, extractedFamily, extractedFontWeight).then((family) => {
|
||
if (cancelled || !family) return;
|
||
setMeasureFamily(`'${family}', ${fallbackFamily}`);
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, [documentId, dominantFid, extractedFamily, fallbackFamily, extractedFontWeight]);
|
||
|
||
const buildReflowData = (runs: ReflowFragment[], origLines?: OrigLine[]) => {
|
||
const listActive = listKind !== null;
|
||
let outRuns = runs;
|
||
let effColumnLeft = columnLeft;
|
||
let listFields: Record<string, unknown> = {};
|
||
if (listActive) {
|
||
const { runs: marked, hangingIndent, marker } = applyListMarkers(
|
||
runs, listKind, { fid: dominantFid, size: domSize, color: domColor, family: measureFamily });
|
||
outRuns = marked;
|
||
effColumnLeft = columnLeft + indentLevel * INDENT_STEP;
|
||
listFields = { hangingIndent, listKind, listLevel: indentLevel, listMarker: marker };
|
||
}
|
||
const linePositionData = (!listActive && layout.origLines && layout.origLines.length) ? {
|
||
lineX: layout.origLines.map((l) => l.x),
|
||
lineBaselineY: layout.origLines.map((l) => l.baselineY),
|
||
} : {};
|
||
const linesData = (!listActive && origLines && origLines.length) ? {
|
||
lines: origLines.map((l) => l.frags.map((f) => ({
|
||
text: f.text, internalFontId: f.fid, fontSize: f.size, color: f.color,
|
||
...(f.advances ? { advances: f.advances } : {}),
|
||
}))),
|
||
} : {};
|
||
return {
|
||
objectIndices: layout.objectIndices,
|
||
runs: outRuns.length ? outRuns : [{ text: ' ', internalFontId: dominantFid, fontSize: domSize, color: '#000000' }],
|
||
...linePositionData,
|
||
...linesData,
|
||
columnLeft: effColumnLeft, columnRight,
|
||
pushColumnLeft: pushColumnLeft ?? columnLeft,
|
||
firstBaselineY: layout.firstBaselineY, leading,
|
||
oldLineCount: layout.oldLineCount,
|
||
align: listActive ? 'left' : align,
|
||
paraId: paraIdRef.current,
|
||
...listFields,
|
||
};
|
||
};
|
||
|
||
const buildOpJson = (runs: ReflowFragment[], origLines?: OrigLine[]): string =>
|
||
JSON.stringify({
|
||
version: '1.0',
|
||
operations: [{ id: 'preview', type: 'reflow_paragraph', pageIndex, data: buildReflowData(runs, origLines) }],
|
||
});
|
||
|
||
const caretIndexRef = useRef<number | null>(null);
|
||
|
||
const caretBoxFor = (global: number, lay: ReflowLayout, fullText: string) => {
|
||
if (!lay.lines.length) return null;
|
||
const starts = lineStarts(lay, fullText);
|
||
let li = lay.lines.length - 1;
|
||
for (let i = 0; i < lay.lines.length; i++) {
|
||
if (global <= starts[i] + lay.lines[i].text.length) { li = i; break; }
|
||
}
|
||
const line = lay.lines[li];
|
||
const offset = Math.max(0, Math.min(global - starts[li], line.adv.length));
|
||
let x = line.x0;
|
||
for (let k = 0; k < offset; k++) x += line.adv[k] ?? 0;
|
||
const fpx = line.fontSize * zoom;
|
||
return {
|
||
left: x * zoom, top: (heightPts - line.baselineY) * zoom - fpx * 0.82, height: fpx * 1.04,
|
||
pageIndex: line.pageIndex ?? pageIndex,
|
||
};
|
||
};
|
||
|
||
const globalFromPoint = (clientX: number, clientY: number, lay: ReflowLayout, fullText: string) => {
|
||
const el = editRef.current;
|
||
const container = el?.offsetParent as HTMLElement | null;
|
||
if (!container || !lay.lines.length) return 0;
|
||
const rect = container.getBoundingClientRect();
|
||
const pdfX = (clientX - rect.left) / zoom;
|
||
const localY = clientY - rect.top;
|
||
let li = 0, bestD = Infinity;
|
||
for (let i = 0; i < lay.lines.length; i++) {
|
||
const fpx = lay.lines[i].fontSize * zoom;
|
||
const mid = (heightPts - lay.lines[i].baselineY) * zoom - fpx * 0.35;
|
||
const d = Math.abs(localY - mid);
|
||
if (d < bestD) { bestD = d; li = i; }
|
||
}
|
||
const line = lay.lines[li];
|
||
let x = line.x0, offset = 0;
|
||
for (let k = 0; k < line.adv.length; k++) {
|
||
const next = x + line.adv[k];
|
||
if (pdfX < (x + next) / 2) break;
|
||
x = next; offset = k + 1;
|
||
}
|
||
return lineStarts(lay, fullText)[li] + offset;
|
||
};
|
||
|
||
const positionCaret = () => {
|
||
const el = editRef.current, lay = engineLayoutRef.current;
|
||
if (!el || !lay) return;
|
||
const fullText = el.textContent ?? '';
|
||
// Always follow the live contentEditable caret — caretIndexRef alone stays stuck at last click.
|
||
const live = getDomCaretOffset(el);
|
||
if (typeof live === 'number') caretIndexRef.current = live;
|
||
const idx = typeof caretIndexRef.current === 'number' ? caretIndexRef.current : fullText.length;
|
||
const box = caretBoxFor(idx, lay, fullText);
|
||
if (box && box.pageIndex !== pageIndex) {
|
||
setCaretBox(null);
|
||
onOverflowCaret?.({ pageIndex: box.pageIndex, left: box.left, top: box.top, height: box.height });
|
||
} else {
|
||
setCaretBox(box);
|
||
onOverflowCaret?.(null);
|
||
}
|
||
};
|
||
|
||
const rgbaToDataUrl = (rgba: Uint8ClampedArray, w: number, h: number): string => {
|
||
const c = document.createElement('canvas'); c.width = w; c.height = h;
|
||
const ctx = c.getContext('2d'); if (!ctx) return '';
|
||
const img = ctx.createImageData(w, h); img.data.set(rgba); ctx.putImageData(img, 0, 0);
|
||
return c.toDataURL('image/png');
|
||
};
|
||
|
||
const renderPreview = async () => {
|
||
const el = editRef.current;
|
||
if (!el) return;
|
||
|
||
// Edit-entry (click before typing): do NOT run identity reflow.
|
||
// Reflow rebuilds text objects (often runPerChar) and looks different from the PDF
|
||
// even when the string is unchanged. Keep the page bitmap visible; caret uses extracted metrics.
|
||
if (!editedRef.current) {
|
||
const origLay = layoutFromOrigLines(layout.origLines, columnLeft, pageIndex, domSize);
|
||
engineLayoutRef.current = origLay;
|
||
if (!initialCaretApplied.current) {
|
||
initialCaretApplied.current = true;
|
||
const fullLen = (el.textContent ?? '').length;
|
||
setGlobalCaretOffset(el, fullLen);
|
||
}
|
||
positionCaret();
|
||
setHasPreview(true);
|
||
return;
|
||
}
|
||
|
||
// Typing preview: use gateway (same engine as save). Browser WASM is stale and
|
||
// changes font/width/height on keystroke; save then snaps back to the native look.
|
||
const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1;
|
||
const dpi = Math.round(96 * zoom * dpr);
|
||
const runs = extractFlatRuns(el, dominantFid, domSize, domColor);
|
||
const data = buildReflowData(runs);
|
||
const yTopPt = bandTop / zoom;
|
||
const operations = [{
|
||
id: 'preview',
|
||
type: 'reflow_paragraph' as const,
|
||
pageIndex,
|
||
data,
|
||
}];
|
||
|
||
console.log('[STAGE_1_EDITABLE_RUN]', { dominantFid, domSize, fontPx, domFontName, edited: true });
|
||
console.log('[STAGE_2_RENDER_PREVIEW_PAYLOAD]', { operations });
|
||
|
||
const preview = await gatewayService.previewEdits({
|
||
documentId,
|
||
operations,
|
||
pageIndex,
|
||
dpi,
|
||
yTopPt,
|
||
});
|
||
|
||
let width = 0;
|
||
let height = 0;
|
||
let rawLay: ReflowLayout | null = null;
|
||
let drawSource: HTMLCanvasElement | HTMLImageElement | null = null;
|
||
let usedGateway = false;
|
||
|
||
if (preview?.pngBase64 && preview.width > 0 && preview.height > 0) {
|
||
try {
|
||
const img = new Image();
|
||
await new Promise<void>((resolve, reject) => {
|
||
img.onload = () => resolve();
|
||
img.onerror = () => reject(new Error('preview png decode failed'));
|
||
img.src = `data:image/png;base64,${preview.pngBase64}`;
|
||
});
|
||
width = preview.width;
|
||
height = preview.height;
|
||
rawLay = preview.layout;
|
||
drawSource = img;
|
||
usedGateway = true;
|
||
onOverflowPreview?.([]);
|
||
} catch {
|
||
usedGateway = false;
|
||
}
|
||
}
|
||
|
||
if (!usedGateway) {
|
||
// Fallback only if gateway preview is unavailable.
|
||
const fids = new Set<string>([dominantFid, ...layout.seedRuns.map((r) => r.fid)].filter(Boolean));
|
||
if (!wasmHasDocument(documentId)) {
|
||
const bytes = await gatewayService.getDocumentRaw(documentId);
|
||
if (bytes) await wasmLoadDocument(documentId, bytes);
|
||
}
|
||
if (!wasmHasDocument(documentId)) return;
|
||
await Promise.all([...fids].map((f) => wasmEnsureAuxFont(documentId, f)));
|
||
const opJson = buildOpJson(runs);
|
||
const { regions, layout: wasmLay } = await wasmPreviewRenderPaginated(documentId, pageIndex, dpi, opJson, yTopPt);
|
||
if (regions.length === 0) return;
|
||
rawLay = wasmLay;
|
||
const r0 = regions[0];
|
||
width = r0.width;
|
||
height = r0.height;
|
||
if (!r0.rgba || width <= 0 || height <= 0) return;
|
||
const offscreen = document.createElement('canvas');
|
||
offscreen.width = width;
|
||
offscreen.height = height;
|
||
const offCtx = offscreen.getContext('2d');
|
||
if (!offCtx) return;
|
||
const imgData = offCtx.createImageData(width, height);
|
||
imgData.data.set(r0.rgba);
|
||
offCtx.putImageData(imgData, 0, 0);
|
||
drawSource = offscreen;
|
||
onOverflowPreview?.(regions.slice(1).map((rg) => ({
|
||
pageIndex: rg.pageIndex, yTopPt: rg.yTopPt, dataUrl: rgbaToDataUrl(rg.rgba, rg.width, rg.height),
|
||
})));
|
||
}
|
||
|
||
console.log('[STAGE_7_PREVIEW_DRAW]', {
|
||
fontSize: domSize,
|
||
fontPx,
|
||
via: usedGateway ? 'gateway' : 'wasm-fallback',
|
||
width,
|
||
height,
|
||
});
|
||
|
||
// Gateway PNG is authoritative for glyphs; caret needs per-glyph advances.
|
||
// Only trust STAGE_5 (layoutSource=reflow). Extract layouts use bbox widths and lag the caret.
|
||
const anchorX0 = layout.origLines[0]?.x ?? columnLeft;
|
||
const caretOpts = {
|
||
columnLeft,
|
||
columnRight,
|
||
firstBaselineY: layout.firstBaselineY,
|
||
leading,
|
||
pageIndex,
|
||
fontSize: domSize,
|
||
anchorX0,
|
||
measureFamily,
|
||
fontWeight: extractedFontWeight,
|
||
};
|
||
const engineLay = rawLay as (ReflowLayout & { layoutSource?: string }) | null;
|
||
const caretLay = usedGateway
|
||
? (engineLay?.layoutSource === 'reflow' && layoutTextMatchesEditor(engineLay, el.textContent ?? '')
|
||
? engineLay
|
||
: caretLayoutFromRuns(runs, caretOpts))
|
||
: rawLay;
|
||
const lay = sanitizeEngineLayout(caretLay, domSize);
|
||
engineLayoutRef.current = lay;
|
||
const cv = previewCanvasRef.current;
|
||
if (cv && drawSource) {
|
||
const displayW = pageWidthPx;
|
||
const displayH = Math.max(0, pageHeightPx - bandTop);
|
||
const targetW = Math.round(displayW * dpr);
|
||
const targetH = Math.round(displayH * dpr);
|
||
if (cv.width !== targetW) cv.width = targetW;
|
||
if (cv.height !== targetH) cv.height = targetH;
|
||
cv.style.width = `${displayW}px`;
|
||
cv.style.height = `${displayH}px`;
|
||
|
||
const ctx = cv.getContext('2d');
|
||
if (ctx) {
|
||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||
ctx.save();
|
||
ctx.scale(dpr, dpr);
|
||
ctx.drawImage(drawSource, 0, 0, displayW, displayH);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
if (!hasPreview) setHasPreview(true);
|
||
if (!initialCaretApplied.current) {
|
||
initialCaretApplied.current = true;
|
||
const fullLen = (el.textContent ?? '').length;
|
||
setGlobalCaretOffset(el, fullLen);
|
||
}
|
||
positionCaret();
|
||
};
|
||
|
||
const scheduleRender = () => {
|
||
if (rafRef.current != null) return;
|
||
rafRef.current = window.requestAnimationFrame(async () => {
|
||
rafRef.current = null;
|
||
if (renderingRef.current) { renderDirtyRef.current = true; return; }
|
||
renderingRef.current = true;
|
||
do {
|
||
renderDirtyRef.current = false;
|
||
try { await renderPreview(); } catch { }
|
||
} while (renderDirtyRef.current);
|
||
renderingRef.current = false;
|
||
});
|
||
};
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
// Edit-entry caret is sync (no reflow). Preload WASM in background for first keystroke.
|
||
renderPreview();
|
||
(async () => {
|
||
if (!wasmHasDocument(documentId)) {
|
||
const bytes = await gatewayService.getDocumentRaw(documentId);
|
||
if (bytes && !cancelled) await wasmLoadDocument(documentId, bytes);
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
if (rafRef.current != null) window.cancelAnimationFrame(rafRef.current);
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [documentId]);
|
||
|
||
useEffect(() => () => { onOverflowPreview?.([]); onOverflowCaret?.(null); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
useEffect(() => {
|
||
const el = editRef.current;
|
||
if (!el) return;
|
||
el.innerHTML = '';
|
||
// Preserve PDF hard line breaks (not a single CSS-wrapped flow).
|
||
const lineFrags = layout.origLines.length
|
||
? layout.origLines.map((l) => l.frags.map((f) => ({
|
||
text: f.text, fid: f.fid, size: f.size, color: f.color, fontName: domFontName, advances: f.advances,
|
||
})))
|
||
: [layout.seedRuns];
|
||
lineFrags.forEach((frags, li) => {
|
||
if (li > 0) el.appendChild(document.createElement('br'));
|
||
for (const r of frags) {
|
||
if (!r.text) continue;
|
||
const effSize = !r.text.trim() || r.size <= 1 ? domSize : r.size;
|
||
const span = document.createElement('span');
|
||
span.setAttribute('data-fid', r.fid || dominantFid);
|
||
span.setAttribute('data-size', String(effSize));
|
||
span.setAttribute('data-color', r.color);
|
||
span.setAttribute('data-fontname', ('fontName' in r ? (r as { fontName?: string }).fontName : domFontName) || '');
|
||
if (r.advances && r.advances.length === r.text.length) {
|
||
span.setAttribute('data-advances', JSON.stringify(r.advances));
|
||
span.setAttribute('data-seed-text', r.text);
|
||
}
|
||
span.style.fontSize = `${effSize * zoom}px`;
|
||
span.style.color = 'transparent';
|
||
(span.style as CSSStyleDeclaration & { webkitTextFillColor?: string }).webkitTextFillColor = 'transparent';
|
||
span.textContent = r.text;
|
||
el.appendChild(span);
|
||
}
|
||
});
|
||
el.focus();
|
||
initialTextRef.current = normalizeForCompare(domTextWithBreaks(el));
|
||
// Caret layout after DOM seed is ready (documentId effect may have run first on an empty editor).
|
||
if (!editedRef.current) {
|
||
engineLayoutRef.current = layoutFromOrigLines(layout.origLines, columnLeft, pageIndex, domSize);
|
||
initialCaretApplied.current = true;
|
||
setGlobalCaretOffset(el, (el.textContent ?? '').length);
|
||
positionCaret();
|
||
setHasPreview(true);
|
||
}
|
||
|
||
// --- Edit-entry overlay vs extracted PDF paragraph (no reflow / no typing) ---
|
||
try {
|
||
const lines = (para?.lines ?? []) as any[];
|
||
let pdfMinX = Infinity, pdfMinY = Infinity, pdfMaxX = -Infinity, pdfMaxY = -Infinity;
|
||
let pdfBaseline = layout.firstBaselineY;
|
||
let pdfAscent = 0, pdfDescent = 0;
|
||
let pdfFontName = domFontName, pdfFontSize = domSize;
|
||
for (const ln of lines) {
|
||
for (const r of (ln.runs ?? [])) {
|
||
pdfFontName = r.font_name || pdfFontName;
|
||
pdfFontSize = Math.max(pdfFontSize, r.font_size && r.font_size > 0 ? r.font_size : (r.h ?? 0) * 0.8);
|
||
for (const g of (r.glyphs ?? [])) {
|
||
const x0 = g.bbox_x, y0 = g.bbox_y, x1 = g.bbox_x + g.bbox_w, y1 = g.bbox_y + g.bbox_h;
|
||
pdfMinX = Math.min(pdfMinX, x0); pdfMinY = Math.min(pdfMinY, y0);
|
||
pdfMaxX = Math.max(pdfMaxX, x1); pdfMaxY = Math.max(pdfMaxY, y1);
|
||
const bl = g.origin_y ?? ln.baseline_y ?? pdfBaseline;
|
||
pdfAscent = Math.max(pdfAscent, y1 - bl);
|
||
pdfDescent = Math.max(pdfDescent, bl - y0);
|
||
}
|
||
}
|
||
}
|
||
const pdfW = isFinite(pdfMaxX) ? pdfMaxX - pdfMinX : 0;
|
||
const pdfH = isFinite(pdfMaxY) ? pdfMaxY - pdfMinY : 0;
|
||
const pdfLineH = lines[0]?.h ?? pdfH;
|
||
const expectedBold = /bold|black|heavy/i.test(pdfFontName) || /Bold/i.test(dominantFid);
|
||
|
||
// Wait one frame so layout/scroll metrics settle after focus + children.
|
||
requestAnimationFrame(() => {
|
||
const cs = window.getComputedStyle(el);
|
||
const rect = el.getBoundingClientRect();
|
||
const pdf = {
|
||
bbox: { x: pdfMinX, y: pdfMinY, w: pdfW, h: pdfH },
|
||
lineHeightPt: pdfLineH,
|
||
ascentPt: pdfAscent,
|
||
descentPt: pdfDescent,
|
||
widthPt: pdfW,
|
||
heightPt: pdfH,
|
||
fontName: pdfFontName,
|
||
fontSizePt: pdfFontSize,
|
||
baselineY: pdfBaseline,
|
||
expectedFontWeight: expectedBold ? 700 : 400,
|
||
};
|
||
const overlayApplied = {
|
||
fontFamily: measureFamily,
|
||
fontSizePx: fontPx,
|
||
fontSizePt: domSize,
|
||
fontWeight: extractedFontWeight,
|
||
lineHeightPx: leadingPx,
|
||
lineHeightPt: lineHeightPt,
|
||
letterSpacing: '(not set)',
|
||
widthPx: overlayWidthPx,
|
||
widthPt: paraBox.w,
|
||
heightPx: overlayHeightPx,
|
||
heightPt: paraBox.h,
|
||
topPx: editorTop,
|
||
leftPx: overlayLeftPx,
|
||
ascentHeuristicPt: paraBox.ascent,
|
||
transform: 'none',
|
||
};
|
||
const overlayMeasured = {
|
||
fontFamily: cs.fontFamily,
|
||
fontSize: cs.fontSize,
|
||
fontWeight: cs.fontWeight,
|
||
lineHeight: cs.lineHeight,
|
||
letterSpacing: cs.letterSpacing,
|
||
width: cs.width,
|
||
height: cs.height,
|
||
transform: cs.transform,
|
||
scrollWidth: el.scrollWidth,
|
||
scrollHeight: el.scrollHeight,
|
||
clientWidth: el.clientWidth,
|
||
clientHeight: el.clientHeight,
|
||
boundingRect: { width: rect.width, height: rect.height, top: rect.top, left: rect.left },
|
||
};
|
||
|
||
const checks: { property: string; pdf: string | number; overlay: string | number; match: boolean }[] = [];
|
||
const pdfFamilyKey = (pdf.fontName || '').replace(/^[A-Z]{6}\+/, '');
|
||
const familyApplied = measureFamily;
|
||
const familyMatches =
|
||
!!pdfFamilyKey
|
||
&& (familyApplied.includes(pdfFamilyKey)
|
||
|| familyApplied.replace(/['"]/g, '').split(',')[0].trim() === pdfFamilyKey);
|
||
checks.push({
|
||
property: 'font-family',
|
||
pdf: pdf.fontName,
|
||
overlay: familyApplied,
|
||
match: familyMatches,
|
||
});
|
||
checks.push({
|
||
property: 'font-weight',
|
||
pdf: pdf.expectedFontWeight,
|
||
overlay: parseInt(cs.fontWeight, 10) || cs.fontWeight,
|
||
match: !expectedBold || (parseInt(cs.fontWeight, 10) || 0) >= 600,
|
||
});
|
||
checks.push({
|
||
property: 'font-size (pt)',
|
||
pdf: pdf.fontSizePt,
|
||
overlay: overlayApplied.fontSizePt,
|
||
match: Math.abs(pdf.fontSizePt - overlayApplied.fontSizePt) < 0.05,
|
||
});
|
||
checks.push({
|
||
property: 'line-height / leading (pt)',
|
||
// Multi-line CSS uses baseline gap (leadingOverride); ink line.h is not the CSS line-height.
|
||
pdf: leadingOverride ?? pdf.lineHeightPt,
|
||
overlay: overlayApplied.lineHeightPt,
|
||
match: Math.abs((leadingOverride ?? pdf.lineHeightPt) - overlayApplied.lineHeightPt) < 0.5,
|
||
});
|
||
checks.push({
|
||
property: 'ascent used for overlay top (pt)',
|
||
pdf: pdf.ascentPt,
|
||
overlay: overlayApplied.ascentHeuristicPt,
|
||
match: Math.abs(pdf.ascentPt - overlayApplied.ascentHeuristicPt) < 0.5,
|
||
});
|
||
checks.push({
|
||
property: 'height (pt)',
|
||
pdf: pdf.heightPt,
|
||
overlay: overlayApplied.heightPt,
|
||
match: Math.abs(pdf.heightPt - overlayApplied.heightPt) < 0.5,
|
||
});
|
||
checks.push({
|
||
property: 'width (pt)',
|
||
pdf: pdf.widthPt,
|
||
overlay: overlayApplied.widthPt,
|
||
match: Math.abs(pdf.widthPt - overlayApplied.widthPt) < 1.0,
|
||
});
|
||
checks.push({
|
||
property: 'letter-spacing',
|
||
pdf: '0 / normal',
|
||
overlay: cs.letterSpacing,
|
||
match: cs.letterSpacing === 'normal' || cs.letterSpacing === '0px',
|
||
});
|
||
checks.push({
|
||
property: 'transform',
|
||
pdf: 'none',
|
||
overlay: cs.transform,
|
||
match: !cs.transform || cs.transform === 'none',
|
||
});
|
||
|
||
const first = checks.find((c) => !c.match) ?? null;
|
||
console.log('[EDIT_ENTRY_OVERLAY_COMPARE]', {
|
||
pdf,
|
||
overlayApplied,
|
||
overlayMeasured,
|
||
checks,
|
||
firstPropertyThatChanges: first,
|
||
});
|
||
});
|
||
} catch (e) {
|
||
console.warn('[EDIT_ENTRY_OVERLAY_COMPARE] failed', e);
|
||
}
|
||
}, []);
|
||
|
||
// Never paint HTML over the PDF on edit-entry. Fallback HTML was a common
|
||
// source of font/width/leading jumps when WASM was slow or unavailable.
|
||
const fallbackVisible = false;
|
||
useEffect(() => {
|
||
const el = editRef.current;
|
||
if (!el) return;
|
||
el.querySelectorAll<HTMLElement>('span[data-fid]').forEach((span) => {
|
||
span.style.color = 'transparent';
|
||
(span.style as CSSStyleDeclaration & { webkitTextFillColor?: string }).webkitTextFillColor = 'transparent';
|
||
});
|
||
}, [fallbackVisible]);
|
||
|
||
// Diagnostics only — does not drive a visible HTML fallback.
|
||
useEffect(() => {
|
||
if (hasPreview) return;
|
||
const t = window.setTimeout(() => setWasmFailed(true), 4000);
|
||
return () => window.clearTimeout(t);
|
||
}, [hasPreview]);
|
||
|
||
const composingRef = useRef(false);
|
||
const ignoreInputUntilRef = useRef(Date.now() + 150);
|
||
|
||
const onInput = () => {
|
||
// Ignore spurious input events from contentEditable population / focus.
|
||
if (Date.now() < ignoreInputUntilRef.current) return;
|
||
editedRef.current = true;
|
||
if (!edited) setEdited(true);
|
||
if (composingRef.current) return;
|
||
// Optimistic caret layout so the cursor tracks keystrokes while gateway preview is in flight.
|
||
const elNow = editRef.current;
|
||
if (elNow) {
|
||
const liveRuns = extractFlatRuns(elNow, dominantFid, domSize, domColor);
|
||
engineLayoutRef.current = caretLayoutFromRuns(liveRuns, {
|
||
columnLeft,
|
||
columnRight,
|
||
firstBaselineY: layout.firstBaselineY,
|
||
leading,
|
||
pageIndex,
|
||
fontSize: domSize,
|
||
anchorX0: layout.origLines[0]?.x ?? columnLeft,
|
||
measureFamily,
|
||
fontWeight: extractedFontWeight,
|
||
});
|
||
}
|
||
positionCaret();
|
||
scheduleRender();
|
||
};
|
||
const onCompositionStart = () => { composingRef.current = true; };
|
||
const onCompositionEnd = () => {
|
||
composingRef.current = false;
|
||
editedRef.current = true;
|
||
if (!edited) setEdited(true);
|
||
positionCaret();
|
||
scheduleRender();
|
||
};
|
||
|
||
const onClickEditor = (e: React.MouseEvent) => {
|
||
const lay = engineLayoutRef.current, el = editRef.current;
|
||
if (!lay || !el || fallbackVisible) return;
|
||
const targetIdx = globalFromPoint(e.clientX, e.clientY, lay, el.textContent ?? '');
|
||
caretIndexRef.current = targetIdx;
|
||
setGlobalCaretOffset(el, targetIdx);
|
||
positionCaret();
|
||
};
|
||
|
||
const commit = () => {
|
||
if (committedRef.current) return;
|
||
committedRef.current = true;
|
||
const el = editRef.current;
|
||
if (!el) { onCancel(); return; }
|
||
const nowText = normalizeForCompare(domTextWithBreaks(el));
|
||
if (nowText === initialTextRef.current && listKind === null) { onCancel(); return; }
|
||
const flat = extractFlatRuns(el, dominantFid, domSize, domColor);
|
||
if (flat.length === 0) { onCancel(); return; }
|
||
const cv = previewCanvasRef.current;
|
||
if (cv && hasPreview && edited && onCommitPreview) {
|
||
try {
|
||
onCommitPreview({
|
||
pageIndex,
|
||
dataUrl: cv.toDataURL('image/png'),
|
||
left: 0, top: bandTop, width: pageWidthPx, height: Math.max(0, pageHeightPx - bandTop),
|
||
});
|
||
} catch { }
|
||
}
|
||
onOverflowPreview?.([]);
|
||
onOverflowCaret?.(null);
|
||
onCommit(buildReflowData(flat) as ReflowParagraphPayload);
|
||
};
|
||
const cancel = () => { committedRef.current = true; onOverflowPreview?.([]); onOverflowCaret?.(null); onCancel(); };
|
||
|
||
const markEditedAndRender = () => {
|
||
editedRef.current = true;
|
||
if (!edited) setEdited(true);
|
||
positionCaret();
|
||
scheduleRender();
|
||
};
|
||
|
||
const insertHardBreak = () => {
|
||
const el = editRef.current;
|
||
if (!el) return;
|
||
document.execCommand('insertLineBreak'); // inserts a <br>; extractFlatRuns maps it to '\n'
|
||
markEditedAndRender();
|
||
};
|
||
|
||
const toggleList = (kind: ListKind) => {
|
||
setListKind((prev) => (prev === kind ? null : kind));
|
||
if (listKind === null) setIndentLevel(0);
|
||
editRef.current?.focus();
|
||
markEditedAndRender();
|
||
};
|
||
const changeIndent = (delta: number) => {
|
||
setIndentLevel((lv) => Math.max(0, Math.min(5, lv + delta)));
|
||
editRef.current?.focus();
|
||
markEditedAndRender();
|
||
};
|
||
|
||
useEffect(() => { if (edited) scheduleRender(); /* eslint-disable-next-line */ }, [listKind, indentLevel]);
|
||
|
||
return (
|
||
<>
|
||
<style>{`@keyframes pe-caret-blink{0%,49%{opacity:1}50%,100%{opacity:0}}`}</style>
|
||
{fallbackVisible && (
|
||
<div
|
||
className="absolute z-[36] bg-white"
|
||
style={{ left: overlayLeftPx - 2, top: editorTop - 2, width: overlayWidthPx + 4, height: overlayHeightPx + 4 }}
|
||
/>
|
||
)}
|
||
<canvas
|
||
ref={previewCanvasRef}
|
||
className="absolute z-[37]"
|
||
style={{
|
||
left: 0, top: bandTop, width: pageWidthPx, height: Math.max(0, pageHeightPx - bandTop),
|
||
pointerEvents: 'none', display: edited && hasPreview ? 'block' : 'none',
|
||
}}
|
||
/>
|
||
|
||
{!fallbackVisible && caretBox && (
|
||
<div
|
||
className="absolute z-[39]"
|
||
style={{ left: caretBox.left, top: caretBox.top, height: caretBox.height, width: 1.6, background: '#2563eb', animation: 'pe-caret-blink 1s step-end infinite', pointerEvents: 'none' }}
|
||
/>
|
||
)}
|
||
|
||
{!fallbackVisible && (
|
||
<div
|
||
className="absolute z-[40] flex items-center gap-0.5 rounded-md border border-slate-200 bg-white px-1 py-0.5 shadow-md"
|
||
style={{ left: colLeftPx, top: Math.max(0, editorTop - 34) }}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
>
|
||
<button
|
||
type="button"
|
||
title="Bullet list"
|
||
onClick={() => toggleList('bullet')}
|
||
className={`px-1.5 py-0.5 rounded text-sm leading-none ${listKind === 'bullet' ? 'bg-blue-100 text-blue-700' : 'text-slate-600 hover:bg-slate-100'}`}
|
||
>• List</button>
|
||
<button
|
||
type="button"
|
||
title="Numbered list"
|
||
onClick={() => toggleList('ordered')}
|
||
className={`px-1.5 py-0.5 rounded text-sm leading-none ${listKind === 'ordered' ? 'bg-blue-100 text-blue-700' : 'text-slate-600 hover:bg-slate-100'}`}
|
||
>1. List</button>
|
||
{listKind !== null && (
|
||
<>
|
||
<span className="mx-0.5 h-4 w-px bg-slate-200" />
|
||
<button type="button" title="Decrease indent (Shift+Tab)" onClick={() => changeIndent(-1)}
|
||
className="px-1.5 py-0.5 rounded text-sm leading-none text-slate-600 hover:bg-slate-100 disabled:opacity-40" disabled={indentLevel === 0}>⇤</button>
|
||
<button type="button" title="Increase indent (Tab)" onClick={() => changeIndent(1)}
|
||
className="px-1.5 py-0.5 rounded text-sm leading-none text-slate-600 hover:bg-slate-100 disabled:opacity-40" disabled={indentLevel >= 5}>⇥</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
ref={editRef}
|
||
contentEditable
|
||
suppressContentEditableWarning
|
||
spellCheck={false}
|
||
data-fid={dominantFid}
|
||
data-size={String(domSize)}
|
||
data-color={domColor}
|
||
data-fontname={domFontName}
|
||
onInput={onInput}
|
||
onCompositionStart={onCompositionStart}
|
||
onCompositionEnd={onCompositionEnd}
|
||
onClick={onClickEditor}
|
||
onKeyUp={positionCaret}
|
||
onKeyDown={(e) => {
|
||
if (e.nativeEvent.isComposing || composingRef.current) return;
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (e.shiftKey) commit();
|
||
else insertHardBreak();
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault(); cancel();
|
||
} else if (e.key === 'Tab' && listKind !== null) {
|
||
e.preventDefault(); changeIndent(e.shiftKey ? -1 : 1);
|
||
}
|
||
}}
|
||
onBlur={commit}
|
||
onPaste={(e) => { e.preventDefault(); document.execCommand('insertText', false, e.clipboardData.getData('text/plain')); }}
|
||
className="absolute z-[38] outline-none"
|
||
style={{
|
||
left: overlayLeftPx, top: editorTop, width: overlayWidthPx, height: overlayHeightPx,
|
||
fontSize: `${fontPx}px`, lineHeight: `${leadingPx}px`, fontFamily: measureFamily,
|
||
fontWeight: extractedFontWeight,
|
||
caretColor: 'transparent',
|
||
color: 'transparent',
|
||
// WebKit may ignore `color: transparent` on contentEditable without this.
|
||
WebkitTextFillColor: 'transparent',
|
||
background: 'transparent',
|
||
whiteSpace: 'pre-wrap', wordBreak: 'normal', overflowWrap: 'break-word', overflow: 'hidden',
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
};
|