Files
pdf/frontend/src/viewer/ParagraphEditor.tsx
T
2026-08-06 14:58:44 +05:30

1390 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
}}
/>
</>
);
};