Skip to content

Commit a8d15aa

Browse files
authored
Merge pull request #112 from cloakyard/dev
Merge dev into main: editor fixes + dependency updates
2 parents e69e0f4 + af75bf0 commit a8d15aa

23 files changed

Lines changed: 511 additions & 128 deletions

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@
1717
"dependencies": {
1818
"@huggingface/transformers": "^4.2.0",
1919
"@langchain/community": "^1.1.29",
20-
"@langchain/core": "^1.1.49",
21-
"@langchain/langgraph": "^1.4.2",
22-
"@llamaindex/liteparse-wasm": "^2.0.8",
20+
"@langchain/core": "^1.2.0",
21+
"@langchain/langgraph": "^1.4.4",
22+
"@llamaindex/liteparse-wasm": "^2.1.0",
2323
"@pdfme/pdf-lib": "^6.1.6",
2424
"jszip": "^3.10.1",
25-
"lucide-react": "^1.20.0",
25+
"lucide-react": "^1.21.0",
2626
"motion": "^12.40.0",
2727
"node-forge": "^1.4.0",
2828
"ogl": "^1.0.11",

pnpm-lock.yaml

Lines changed: 44 additions & 44 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/editor/EditorContext.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ export function EditorProvider({
177177
// in, post-redesign) so the no-doc fallback never flashes before loadFile runs.
178178
const [loading, setLoading] = useState(initialFile != null);
179179
const [busyLabel, setBusyLabel] = useState<string | null>(null);
180+
// Synchronous in-flight flag for runBusy's re-entry lock (busyLabel is async
181+
// state and updates a frame late, too slow to block a fast second invocation).
182+
const busyRef = useRef(false);
180183
const [error, setError] = useState<string | null>(null);
181184
// The dropped PDF turned out to be password-protected — pdf-lib/PDF.js can't
182185
// parse it, so we surface the PDF Password tool instead of a raw load error.
@@ -257,6 +260,17 @@ export function EditorProvider({
257260
fn: (setLabel: (label: string) => void) => void | Promise<void>,
258261
): Promise<void> => {
259262
return new Promise<void>((resolve, reject) => {
263+
// Re-entry lock: every byte transform (Apply) and background task funnels
264+
// through here, all branching off docRef.current. A second op started
265+
// before the first resolves would race on the same base and the last
266+
// commit would silently drop the other's edit. The busy overlay blocks
267+
// most double-clicks, but not the 2-rAF window below nor tools that don't
268+
// read busyLabel — so guard synchronously. Re-entrant calls no-op.
269+
if (busyRef.current) {
270+
resolve();
271+
return;
272+
}
273+
busyRef.current = true;
260274
setError(null); // clear any stale error from a prior operation
261275
setBusyLabel(label);
262276
requestAnimationFrame(() => {
@@ -276,6 +290,7 @@ export function EditorProvider({
276290
setError(e instanceof Error ? e.message : "Something went wrong. Please try again.");
277291
reject(e);
278292
} finally {
293+
busyRef.current = false;
279294
setBusyLabel(null);
280295
}
281296
});
@@ -357,6 +372,10 @@ export function EditorProvider({
357372
/** Commit a new doc state to history and make it live. */
358373
const commitDoc = useCallback((next: CanvasDoc, label: string) => {
359374
setDoc(next);
375+
// A transform can shrink the page count (delete / extract / split) below the
376+
// focused index; clamp it so the stage doesn't land on a missing page and
377+
// render blank.
378+
setSelectedPageState((p) => Math.min(p, Math.max(0, next.pageCount - 1)));
360379
historyRef.current.push({
361380
label,
362381
bytes: next.bytes,
@@ -381,6 +400,7 @@ export function EditorProvider({
381400
const cur = docRef.current;
382401
if (!entry || !cur) return;
383402
setDoc({ ...cur, bytes: entry.bytes, pages: entry.pages, objects: entry.objects });
403+
setSelectedPageState((p) => Math.min(p, Math.max(0, entry.pages.length - 1)));
384404
setHistoryVersion((v) => v + 1);
385405
}, []);
386406

@@ -410,6 +430,7 @@ export function EditorProvider({
410430
const cur = docRef.current;
411431
if (!base || !cur) return;
412432
setDoc({ ...cur, bytes: base.bytes, pages: base.pages, objects: base.objects });
433+
setSelectedPageState((p) => Math.min(p, Math.max(0, base.pages.length - 1)));
413434
toolCheckpointRef.current = historyRef.current.index();
414435
setHistoryVersion((v) => v + 1);
415436
}, []);

src/editor/PdfStage.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,43 @@ function measureInlineWidth(text: string, font: string): number {
4040
return _measureCtx.measureText(text).width;
4141
}
4242

43+
/** Decode a page's preview thumbnail into an offscreen canvas so the overlay
44+
* painters can sample its pixels (Smart-Erase paints a true fill / mosaic
45+
* preview from it). Re-decodes when the URL changes (page switch, re-render);
46+
* willReadFrequently because the erase preview reads it back with getImageData.
47+
* Same-origin blob: URL, so the canvas is never tainted. */
48+
function usePageBitmap(thumbUrl: string | null | undefined): HTMLCanvasElement | null {
49+
const [canvas, setCanvas] = useState<HTMLCanvasElement | null>(null);
50+
useEffect(() => {
51+
// Drop the prior page's bitmap up front so a sampler (erase preview) never
52+
// reads the wrong page during the async decode; it falls back to the
53+
// placeholder for the frame or two until the new page decodes.
54+
setCanvas(null);
55+
if (!thumbUrl) return;
56+
let cancelled = false;
57+
const img = new Image();
58+
img.decoding = "async";
59+
img.onload = () => {
60+
if (cancelled) return;
61+
const c = document.createElement("canvas");
62+
c.width = img.naturalWidth;
63+
c.height = img.naturalHeight;
64+
const ctx = c.getContext("2d", { willReadFrequently: true });
65+
if (!ctx) return;
66+
ctx.drawImage(img, 0, 0);
67+
setCanvas(c);
68+
};
69+
img.onerror = () => {
70+
if (!cancelled) setCanvas(null);
71+
};
72+
img.src = thumbUrl;
73+
return () => {
74+
cancelled = true;
75+
};
76+
}, [thumbUrl]);
77+
return canvas;
78+
}
79+
4380
/** Coarse-pointer (touch) primary input — phones/tablets, where the fit-to-screen
4481
* page renders small and the OS soft keyboard is in play. `pointer: coarse` is
4582
* true only when the PRIMARY input is touch (a touchscreen laptop with a
@@ -279,6 +316,9 @@ export function PdfStage() {
279316
);
280317

281318
const page = doc?.pages[selectedPage] ?? null;
319+
// Decoded raster of the focused page — fed to the overlay painters so erase
320+
// marks render as a true fill / mosaic preview rather than a flat placeholder.
321+
const pageBitmap = usePageBitmap(page?.thumbUrl);
282322

283323
// Fit-contain: size the page box to the largest rect with the page's exact
284324
// aspect ratio that fits the available area — never stretches, in either
@@ -334,9 +374,9 @@ export function PdfStage() {
334374
// Always-on base layer: the pending destructive marks (redaction / erase),
335375
// so they stay visible no matter which tool is active — they aren't burned
336376
// into the page until export. The active tool's overlay paints on top.
337-
paintDestructiveMarks(ctx, width, height, selectedPage, doc?.objects ?? []);
338-
stageProps.paintOverlay?.(ctx, width, height, selectedPage);
339-
}, [stageProps, selectedPage, doc?.objects]);
377+
paintDestructiveMarks(ctx, width, height, selectedPage, doc?.objects ?? [], pageBitmap);
378+
stageProps.paintOverlay?.(ctx, width, height, selectedPage, pageBitmap);
379+
}, [stageProps, selectedPage, doc?.objects, pageBitmap]);
340380

341381
// Always call the freshest repaint without re-subscribing the observer.
342382
const repaintRef = useRef(repaint);

0 commit comments

Comments
 (0)