fix: reflow for bullets

This commit is contained in:
Furqan-14
2026-06-16 14:13:14 +05:30
parent a84ff13170
commit d53f059952
10 changed files with 532 additions and 108 deletions
Binary file not shown.
+1 -6
View File
@@ -23,12 +23,7 @@ function getModule(): Promise<PdfiumModule | null> {
if (!modulePromise) {
modulePromise = (async () => {
try {
// Vite refuses to import /public files as modules ("can only be referenced via HTML
// tags"). So fetch the Emscripten module as TEXT and import it via a Blob URL — Vite
// never sees it as an import, and the browser loads it as a normal ES module.
// Cache-buster: bump V whenever the engine is rebuilt so the browser can never serve a
// stale .mjs/.wasm (a normal hard-reload sometimes keeps the multi-MB .wasm cached).
const V = '20260615e';
const V = '20260616a';
const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' });
if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`);
const blobUrl = URL.createObjectURL(new Blob([await resp.text()], { type: 'text/javascript' }));
+21 -37
View File
@@ -5,12 +5,6 @@ import type { ReflowLayout } from '../lib/pdfiumEngine';
import type { ReflowParagraphPayload } from './TextEditLayer';
import type { ReflowFragment } from '../lib/gatewayService';
// Live paragraph editor with a PIXEL-PERFECT preview. A transparent contentEditable handles
// input + caret (native), while the in-browser WASM PDFium engine renders the edited paragraph
// band IDENTICALLY to the page (same C++ engine) and paints it underneath. So clicking in
// doesn't change the look, and typing reflows in the document's true rendering. Commit goes to
// the gateway (authoritative). Falls back to plain browser text if the WASM engine is unavailable.
interface SeedRun { text: string; fid: string; size: number; color: string; fontName: string; }
interface ParagraphLayout {
columnLeft: number; columnRight: number; firstBaselineY: number; leading: number;
@@ -20,11 +14,10 @@ interface ParagraphEditorProps {
documentId: string;
pageIndex: number;
para: any;
// Bullet-item reflow: full-width push-down left, and overrides for spacing/alignment that the
// sub-paragraph can't infer on its own (a single-line item has no leading; bullets aren't justified).
pushColumnLeft?: number;
leadingOverride?: number;
alignOverride?: 'left' | 'justify';
columnRightOverride?: number;
caretClick?: { x: number; y: number } | null;
heightPts: number;
zoom: number;
@@ -76,18 +69,15 @@ function computeLayout(para: any): ParagraphLayout {
return { columnLeft, columnRight, firstBaselineY, leading, oldLineCount: lines.length, align, objectIndices, seedRuns };
}
// Flat styled runs from the editable's current text (each text node → a fragment). The engine
// wraps these into lines itself (greedy break within the column), so the wrap + justification of
// the rendered image and the exported caret layout always agree — no browser layout involved.
function extractFlatRuns(editable: HTMLElement, dominantFid: string): ReflowFragment[] {
function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: number, domColor: string): ReflowFragment[] {
const out: ReflowFragment[] = [];
const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT);
let node = walker.nextNode() as Text | null;
while (node) {
const el = node.parentElement;
const fid = el?.getAttribute('data-fid') || dominantFid;
const size = parseFloat(el?.getAttribute('data-size') ?? '12') || 12;
const color = el?.getAttribute('data-color') ?? '#000000';
const size = parseFloat(el?.getAttribute('data-size') ?? '') || domSize;
const color = el?.getAttribute('data-color') ?? domColor;
const text = node.textContent ?? '';
if (text) out.push({ text, internalFontId: fid, fontSize: size, color });
node = walker.nextNode() as Text | null;
@@ -95,9 +85,6 @@ function extractFlatRuns(editable: HTMLElement, dominantFid: string): ReflowFrag
return out;
}
// The paragraph's ORIGINAL line breaks as fragments (one inner array per line). Used for the
// initial render so opening the editor shows the document's existing wrapping verbatim — the
// engine only re-wraps once the user actually edits.
function buildOriginalLines(para: any, dominantFid: string): ReflowFragment[][] {
const out: ReflowFragment[][] = [];
for (const line of para?.lines ?? []) {
@@ -160,7 +147,7 @@ function lineStarts(layout: ReflowLayout, fullText: string): number[] {
}
export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride,
documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride, columnRightOverride,
caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCancel,
}) => {
const layout = useMemo(() => computeLayout(para), [para]);
@@ -168,6 +155,8 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
// paragraph uses what computeLayout inferred.
const leading = leadingOverride ?? layout.leading;
const align = alignOverride ?? layout.align;
// Wrap/commit against the true right margin when provided (bullet items), else the inferred one.
const columnRight = columnRightOverride ?? layout.columnRight;
const editRef = useRef<HTMLDivElement>(null);
const committedRef = useRef(false);
const initialTextRef = useRef('');
@@ -175,49 +164,40 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
const blobUrlRef = useRef<string | null>(null);
const engineLayoutRef = useRef<ReflowLayout | null>(null);
const initialCaretApplied = useRef(false);
// Until the user actually edits, the preview keeps the document's ORIGINAL line breaks (no
// re-wrap), so merely opening the editor doesn't shift anything. Re-wrap kicks in on first edit.
const editedRef = useRef(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [caretBox, setCaretBox] = useState<{ left: number; top: number; height: number } | null>(null);
// True once the user actually edits. Until then we show the UNTOUCHED original page (pixel-
// perfect) rather than the reflow render, so merely opening the editor changes nothing.
const [edited, setEdited] = useState(false);
// Falls back to the plain (visible) browser editor only if the WASM engine fails to produce
// a render in a few seconds — so the user is never stuck editing invisible text.
const [wasmFailed, setWasmFailed] = useState(false);
// The font most of the paragraph uses — empty-fid runs (spaces, freshly typed text) adopt it
// so the engine emits them in the real document font instead of a generic fallback.
const dominantFid = layout.seedRuns.find((r) => r.text.trim() && r.fid)?.fid
?? layout.seedRuns.find((r) => r.fid)?.fid ?? '';
const domSize = layout.seedRuns.find((r) => r.text.trim())?.size ?? layout.seedRuns[0]?.size ?? 12;
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 ?? '';
const fontPx = domSize * zoom;
const leadingPx = leading * zoom;
const colLeftPx = layout.columnLeft * zoom;
const colWidthPx = (layout.columnRight - layout.columnLeft) * zoom;
const colWidthPx = (columnRight - layout.columnLeft) * zoom;
const firstBaselineScreen = (heightPts - layout.firstBaselineY) * zoom;
const editorTop = firstBaselineScreen - (leadingPx + fontPx * 0.7) / 2;
// The WASM preview repaints the page from the paragraph top down (reflow pushes content below).
const bandTop = Math.max(0, editorTop - leadingPx * 0.5);
// Reflow op JSON for the engine. With `lines` (initial render) the engine emits those exact
// breaks — opening the editor changes nothing. Without `lines` (after an edit) it greedy-wraps
// within the column, and its render + exported caret layout agree exactly (no browser layout).
const buildOpJson = (runs: ReflowFragment[], originalLines?: ReflowFragment[][]): string => JSON.stringify({
version: '1.0',
operations: [{ id: 'preview', type: 'reflow_paragraph', pageIndex, data: {
objectIndices: layout.objectIndices,
runs: runs.length ? runs : [{ text: ' ', internalFontId: dominantFid, fontSize: domSize, color: '#000000' }],
...(originalLines && originalLines.length ? { lines: originalLines } : {}),
columnLeft: layout.columnLeft, columnRight: layout.columnRight,
columnLeft: layout.columnLeft, columnRight,
pushColumnLeft: pushColumnLeft ?? layout.columnLeft,
firstBaselineY: layout.firstBaselineY, leading,
oldLineCount: layout.oldLineCount, align,
} }],
});
// Caret screen box (px in the page container) for a global char offset, from the engine layout.
const caretBoxFor = (global: number, lay: ReflowLayout, fullText: string) => {
if (!lay.lines.length) return null;
const starts = lineStarts(lay, fullText);
@@ -272,7 +252,7 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
const dpi = Math.round(72 * zoom);
// Before the first edit, keep the document's original line breaks (no re-wrap on open).
const originalLines = editedRef.current ? undefined : buildOriginalLines(para, dominantFid);
const { blob, layout: lay } = await wasmPreviewRender(documentId, pageIndex, dpi, buildOpJson(extractFlatRuns(el, dominantFid), originalLines));
const { blob, layout: lay } = await wasmPreviewRender(documentId, pageIndex, dpi, buildOpJson(extractFlatRuns(el, dominantFid, domSize, domColor), originalLines));
if (!blob) { console.warn('[ParagraphEditor] WASM preview returned null'); return; }
engineLayoutRef.current = lay;
const url = URL.createObjectURL(blob);
@@ -372,12 +352,12 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
if (!el) { onCancel(); return; }
const nowText = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
if (nowText === initialTextRef.current) { onCancel(); return; }
const flat = extractFlatRuns(el, dominantFid);
const flat = extractFlatRuns(el, dominantFid, domSize, domColor);
if (flat.length === 0) { onCancel(); return; }
// Commit with FLAT runs (no lines) so the gateway wraps identically to the live preview.
onCommit({
objectIndices: layout.objectIndices, runs: flat,
columnLeft: layout.columnLeft, columnRight: layout.columnRight,
columnLeft: layout.columnLeft, columnRight,
pushColumnLeft: pushColumnLeft ?? layout.columnLeft,
firstBaselineY: layout.firstBaselineY, leading,
oldLineCount: layout.oldLineCount, align,
@@ -421,6 +401,10 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
contentEditable
suppressContentEditableWarning
spellCheck={false}
data-fid={dominantFid}
data-size={String(domSize)}
data-color={domColor}
data-fontname={domFontName}
onInput={onInput}
onClick={onClickEditor}
onKeyUp={positionCaret}
+35 -8
View File
@@ -58,16 +58,42 @@ export function isBulletMarker(text?: string): boolean {
// or a leading non-bullet block). Returns a sub-paragraph whose lines exclude the bullet marker
// (so it stays put) with the text column starting at the hanging indent, plus the full-width
// pushColumnLeft and the paragraph's true leading.
export function buildBulletItem(para: any, runLineIndex: number): { subPara: any; pushColumnLeft: number; leading: number } | null {
export function buildBulletItem(para: any, runLineIndex: number): { subPara: any; pushColumnLeft: number; leading: number; columnRight: number } | null {
const lines = para?.lines ?? [];
if (!lines.length) return null;
const colLeft = Math.min(...lines.map((l: any) => l.x));
const isStart = (l: any) => isBulletMarker((l.runs ?? [])[0]?.text);
// Item = from the nearest marker line at/above the click (or paragraph start) to before the next.
// True text right margin = the widest line across the WHOLE paragraph (sibling bullets reach the
// real margin even when the clicked item's own longest line falls a few pt short). Reflowing
// within the item's own narrower right edge gives zero slack, so a hair-wider re-measurement
// strands the last word onto a new line (e.g. "SOLID," dropping below). Use the true margin.
const colRight = Math.max(...lines.map((l: any) => l.x + l.w));
// A paragraph can mix logical blocks the model groups together: an intro line, a bold
// sub-heading, then bullets. Split on (a) bullet markers AND (b) a flush-left line whose
// leading font/weight differs from the line above it — a structural transition (intro→heading,
// heading→body). Bullet CONTINUATION lines hang at the text indent (not flush-left) and a
// same-font wrapped intro keeps the same font, so neither is mis-split — pure bullet lists and
// plain paragraphs behave exactly as before; only genuine mixed structure reflows per block.
const leadFontKey = (l: any): string => {
const r = (l.runs ?? []).find((x: any) => (x.text ?? '').trim() && !isBulletMarker(x.text));
return r?.internal_font_id ?? '';
};
// "Flush-left" = at the block's marker column, clearly LEFT of the bullet hanging indent (where
// continuation lines and bullet text begin). Use the midpoint between colLeft and that hanging
// indent as the threshold so a wrapped bullet continuation is never mistaken for a new heading.
const hangX = Math.min(...lines.map((l: any) => l.x).filter((x: number) => x > colLeft + 1));
const flushThresh = isFinite(hangX) ? colLeft + (hangX - colLeft) * 0.5 : colLeft + 4;
const flushLeft = (l: any) => l.x <= flushThresh;
const isStart = (idx: number): boolean => {
const l = lines[idx];
if (isBulletMarker((l.runs ?? [])[0]?.text)) return true;
if (idx === 0) return true;
return flushLeft(l) && leadFontKey(l) !== '' && leadFontKey(l) !== leadFontKey(lines[idx - 1]);
};
// Item = from the nearest block-start line at/above the click (or paragraph start) to the next.
let start = runLineIndex;
while (start > 0 && !isStart(lines[start])) start--;
while (start > 0 && !isStart(start)) start--;
let end = runLineIndex + 1;
while (end < lines.length && !isStart(lines[end])) end++;
while (end < lines.length && !isStart(end)) end++;
const itemLines = lines.slice(start, end);
if (!itemLines.length) return null;
@@ -92,7 +118,7 @@ export function buildBulletItem(para: any, runLineIndex: number): { subPara: any
? { ...l, runs: textRuns, x: textIndent, w: (l.x + l.w) - textIndent }
: l);
}
return { subPara: { ...para, lines: subLines }, pushColumnLeft: colLeft, leading };
return { subPara: { ...para, lines: subLines }, pushColumnLeft: colLeft, leading, columnRight: colRight };
}
// True only for a genuine FLOWING paragraph — multiple lines that fill the column from a common
@@ -284,7 +310,7 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
// When set, the live reflow editor is open. `para` is the (sub-)paragraph to reflow — the whole
// paragraph for a flowing block, or a single bullet item (marker stripped) for a list.
const [paraEdit, setParaEdit] = useState<{
para: any; pushColumnLeft?: number; leading?: number; align?: 'left' | 'justify';
para: any; pushColumnLeft?: number; leading?: number; align?: 'left' | 'justify'; columnRight?: number;
} | null>(null);
// Screen coords of the click that opened the paragraph editor, so the caret lands there
// (instead of jumping to the paragraph start).
@@ -338,7 +364,7 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
const item = buildBulletItem(para, run.lineIndex);
if (item) {
setCaretClick(click);
setParaEdit({ para: item.subPara, pushColumnLeft: item.pushColumnLeft, leading: item.leading, align: 'left' });
setParaEdit({ para: item.subPara, pushColumnLeft: item.pushColumnLeft, leading: item.leading, align: 'left', columnRight: item.columnRight });
return;
}
// else: couldn't scope an item → fall through to per-line in-place editing
@@ -395,6 +421,7 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
pushColumnLeft={paraEdit.pushColumnLeft}
leadingOverride={paraEdit.leading}
alignOverride={paraEdit.align}
columnRightOverride={paraEdit.columnRight}
caretClick={caretClick}
heightPts={heightPts}
zoom={zoom}