fix: reflow for bullets
This commit is contained in:
Binary file not shown.
@@ -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' }));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user