Skip to content

Commit cfe5736

Browse files
authored
Merge pull request #116 from cloakyard/dev
Release: seven new editor tools + QA fixes
2 parents a0afee2 + 1a0bf58 commit cfe5736

30 files changed

Lines changed: 3508 additions & 350 deletions

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@
2525

2626
Drop a PDF and it opens in a single, canvas-based **editor** — a Photoshop-like workspace for one document at a time:
2727

28-
- **✏️ Annotate & sign** — draw, highlight, shapes, text, signatures, fill & flatten forms
29-
- **📄 Pages** — reorder, rotate, delete, crop, N-up, OCR, plus split / extract / contact-sheet on export
30-
- **🔒 Privacy** — redact (burned into the page), find & box text, scrub hidden data, edit or strip metadata
31-
- **🏷️ Stamps & numbering** — watermarks, page numbers, headers & footers, Bates numbering, bookmarks
28+
- **✏️ Annotate & sign** — draw, highlight, shapes, text, signatures, fill & flatten forms (or auto-fill flat printed ones)
29+
- **📄 Pages** — reorder, rotate, delete, crop (auto-trim & straighten), N-up & booklet, OCR, plus split / extract / contact-sheet on export
30+
- **🔒 Privacy** — redact (burned into the page), select or find & box text, erase regions (fill / blend / pixelate), scrub hidden data, edit or strip metadata
31+
- **🏷️ Stamps & numbering** — watermarks (with dynamic date/page tokens), QR & barcode stamps, page numbers, headers & footers, Bates numbering, bookmarks
3232

3333
Export to PDF, images (ZIP), a contact sheet, or split pages — with optional compress / grayscale / flatten / repair / strip-metadata.
3434

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@
2020
"@langchain/core": "^1.2.0",
2121
"@langchain/langgraph": "^1.4.4",
2222
"@llamaindex/liteparse-wasm": "^2.1.1",
23-
"@pdfme/pdf-lib": "^6.1.8",
23+
"@pdfme/pdf-lib": "^6.1.9",
2424
"jszip": "^3.10.1",
2525
"lucide-react": "^1.21.0",
2626
"motion": "^12.40.0",
2727
"node-forge": "^1.4.0",
2828
"ogl": "^1.0.11",
2929
"pdfjs-dist": "^6.0.227",
30+
"qrcode-generator": "^2.0.4",
3031
"react": "^19.2.7",
3132
"react-dom": "^19.2.7",
3233
"react-markdown": "^10.1.0",

pnpm-lock.yaml

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

src/editor/MobileEditorSurface.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ export function MobileEditorSurface() {
8686
if (activeTool) headingRef.current?.focus({ preventScroll: true });
8787
}, [activeTool]);
8888

89+
// The body is a single persistent scroll box reused across every view, so a
90+
// scroll offset left over from one panel survives into the next — which would
91+
// open the next tool (or the picker, hiding its "Reset to original" top row)
92+
// mid-scrolled. Snap it back to the top on any view change: when the sheet
93+
// opens and whenever the active tool switches.
94+
const bodyRef = useRef<HTMLDivElement>(null);
95+
useEffect(() => {
96+
if (open) bodyRef.current?.scrollTo({ top: 0 });
97+
}, [open, activeTool]);
98+
8999
return (
90100
<div
91101
data-testid="mobile-tool-sheet"
@@ -155,10 +165,18 @@ export function MobileEditorSurface() {
155165
</div>
156166

157167
{/* Body — fills the space under the header inside the 40% cap and scrolls,
158-
so long panels stay reachable. Hidden scrollbar (gesture/wheel scroll);
159-
collapses with the sheet when closed. */}
168+
so long panels stay reachable. Hidden scrollbar (gesture/wheel scroll).
169+
Closed, its max-height collapses to 0 (in step with the outer slide) so
170+
nothing peeks below the pinned header — the outer's 64px cap alone left
171+
a gap that clipped the first body row (e.g. the "Reset to original"
172+
label). The vertical padding is open-only: with border-box, pt/pb would
173+
hold the body open to ~12px even at max-h-0, re-exposing the peek. flex-1
174+
fills the open sheet; the max-h-screen cap never binds (outer is capped). */}
160175
<div
161-
className="no-scrollbar min-h-0 flex-1 overflow-y-auto px-4 pb-2 pt-1"
176+
ref={bodyRef}
177+
className={`no-scrollbar min-h-0 overflow-y-auto px-4 ease-[cubic-bezier(0.22,1,0.36,1)] motion-safe:transition-[max-height] motion-safe:duration-300 ${
178+
open ? "max-h-screen flex-1 pb-2 pt-1" : "max-h-0"
179+
}`}
162180
aria-label={view.kind === "tool" ? "Tool controls" : "Tools"}
163181
>
164182
{view.kind === "tool" ? (

src/editor/panels/AutoFillTool.tsx

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
// AutoFillTool.tsx — Fill forms that have NO interactive fields. It reads the
2+
// page text layer, finds the blanks (underline runs + "Label:" + space), lists
3+
// them as editable inputs, and draws your answers back into the PDF. A saved
4+
// profile (name / email / phone / address / date / company) fills the matching
5+
// blanks in one tap. The companion Fill-form tool handles real AcroForm fields;
6+
// this is for printed / exported / (OCR'd) scanned forms. Detected fields +
7+
// values live in the tool slice so the Stage can outline them on the page.
8+
9+
import { Loader2, ScanLine, UserCog } from "lucide-react";
10+
import { useCallback, useEffect, useState } from "react";
11+
import { extractTextGeometry } from "../../utils/layout-extract.ts";
12+
import {
13+
detectFlatFields,
14+
fillFlatFormFields,
15+
type FieldFill,
16+
type FlatField,
17+
type ProfileKey,
18+
} from "../../utils/pdf-operations.ts";
19+
import { docToFile } from "../doc.ts";
20+
import { useEditorActions, useEditorRead, useToolSlice } from "../EditorContext.tsx";
21+
import { useStageProps } from "../stage.tsx";
22+
import { Labeled, TextField } from "./controls.tsx";
23+
import { PrimaryAction } from "./PrimaryAction.tsx";
24+
25+
const TOOL_ID = "auto-fill";
26+
const PROFILE_STORAGE = "cloakpdf-autofill-profile";
27+
28+
type Profile = Partial<Record<ProfileKey, string>>;
29+
30+
const PROFILE_FIELDS: { key: ProfileKey; label: string; placeholder: string }[] = [
31+
{ key: "name", label: "Full name", placeholder: "Jane Doe" },
32+
{ key: "email", label: "Email", placeholder: "jane@example.com" },
33+
{ key: "phone", label: "Phone", placeholder: "+1 555 0100" },
34+
{ key: "address", label: "Address", placeholder: "1 Main St, Anytown" },
35+
{ key: "date", label: "Date", placeholder: "2026-06-20" },
36+
{ key: "company", label: "Company", placeholder: "Acme Inc." },
37+
];
38+
39+
function loadProfile(): Profile {
40+
try {
41+
const raw = localStorage.getItem(PROFILE_STORAGE);
42+
return raw ? (JSON.parse(raw) as Profile) : {};
43+
} catch {
44+
return {};
45+
}
46+
}
47+
48+
function saveProfile(p: Profile): void {
49+
try {
50+
localStorage.setItem(PROFILE_STORAGE, JSON.stringify(p));
51+
} catch {
52+
/* storage unavailable — ignore */
53+
}
54+
}
55+
56+
interface AutoFillSlice {
57+
fields: FlatField[];
58+
/** Field index → entered value. */
59+
values: Record<number, string>;
60+
searched: boolean;
61+
}
62+
63+
function readSlice(slice: Record<string, unknown>): AutoFillSlice {
64+
return {
65+
fields: (slice.fields as FlatField[]) ?? [],
66+
values: (slice.values as Record<number, string>) ?? {},
67+
searched: (slice.searched as boolean) ?? false,
68+
};
69+
}
70+
71+
export function Stage() {
72+
const { fields, values } = readSlice(useToolSlice(TOOL_ID));
73+
const paintOverlay = useCallback(
74+
(ctx: CanvasRenderingContext2D, w: number, h: number, pageIndex: number) => {
75+
ctx.save();
76+
for (let i = 0; i < fields.length; i++) {
77+
const f = fields[i];
78+
if (f.pageIndex !== pageIndex) continue;
79+
const x = f.rect.xPct * w;
80+
const y = f.rect.yPct * h;
81+
const bw = f.rect.wPct * w;
82+
const bh = f.rect.hPct * h;
83+
// A tinted blank with a dashed outline so empty fields are visible.
84+
ctx.fillStyle = "rgba(37, 99, 235, 0.10)";
85+
ctx.fillRect(x, y, bw, bh);
86+
ctx.strokeStyle = "rgba(37, 99, 235, 0.7)";
87+
ctx.lineWidth = 1;
88+
ctx.setLineDash([3, 2]);
89+
ctx.strokeRect(x, y, bw, bh);
90+
ctx.setLineDash([]);
91+
const val = values[i];
92+
if (val) {
93+
ctx.fillStyle = "#0f172a";
94+
const size = Math.max(9, Math.min(16, bh * 0.85));
95+
ctx.font = `${size}px Helvetica, Arial, sans-serif`;
96+
ctx.textBaseline = "middle";
97+
ctx.fillText(val, x + 2, y + bh / 2, bw - 4);
98+
}
99+
}
100+
ctx.restore();
101+
},
102+
[fields, values],
103+
);
104+
useStageProps({ cursor: "default", paintOverlay });
105+
return null;
106+
}
107+
108+
export function Panel() {
109+
const { doc } = useEditorRead();
110+
const { applyTransform, patchToolState, setSelectedPage, setViewMode } = useEditorActions();
111+
const { fields, values, searched } = readSlice(useToolSlice(TOOL_ID));
112+
113+
const [detecting, setDetecting] = useState(false);
114+
const [profileOpen, setProfileOpen] = useState(false);
115+
const [profile, setProfile] = useState<Profile>({});
116+
useEffect(() => setProfile(loadProfile()), []);
117+
118+
const detect = useCallback(async () => {
119+
if (!doc) return;
120+
setDetecting(true);
121+
try {
122+
const pages = await extractTextGeometry(docToFile(doc), { ocr: false });
123+
const found = detectFlatFields(pages);
124+
// Pre-fill any field whose label maps to a saved profile value.
125+
const saved = loadProfile();
126+
const seed: Record<number, string> = {};
127+
found.forEach((f, i) => {
128+
if (f.profileKey && saved[f.profileKey]) seed[i] = saved[f.profileKey] as string;
129+
});
130+
patchToolState(TOOL_ID, { fields: found, values: seed, searched: true });
131+
} catch {
132+
patchToolState(TOOL_ID, { fields: [], values: {}, searched: true });
133+
} finally {
134+
setDetecting(false);
135+
}
136+
}, [doc, patchToolState]);
137+
138+
const autofill = useCallback(() => {
139+
const saved = loadProfile();
140+
const next = { ...values };
141+
fields.forEach((f, i) => {
142+
if (f.profileKey && saved[f.profileKey]) next[i] = saved[f.profileKey] as string;
143+
});
144+
patchToolState(TOOL_ID, { values: next });
145+
}, [fields, values, patchToolState]);
146+
147+
const setValue = (i: number, v: string) =>
148+
patchToolState(TOOL_ID, { values: { ...values, [i]: v } });
149+
150+
const setProfileField = (key: ProfileKey, v: string) => {
151+
const next = { ...profile, [key]: v };
152+
setProfile(next);
153+
saveProfile(next);
154+
};
155+
156+
const filledCount = fields.filter((_, i) => (values[i] ?? "").trim()).length;
157+
// Read from the in-memory profile state (kept in sync on edit) rather than
158+
// hitting localStorage on every render.
159+
const hasProfileMatch = fields.some((f) => f.profileKey && profile[f.profileKey]);
160+
161+
const apply = () => {
162+
const fills: FieldFill[] = fields
163+
.map((f, i) => ({ pageIndex: f.pageIndex, rect: f.rect, text: values[i] ?? "" }))
164+
.filter((f) => f.text.trim());
165+
if (fills.length === 0) return;
166+
void applyTransform(async (d) => ({
167+
bytes: await fillFlatFormFields(docToFile(d), fills),
168+
label: `Fill ${fills.length} field${fills.length === 1 ? "" : "s"}`,
169+
})).then(() => patchToolState(TOOL_ID, { fields: [], values: {}, searched: false }));
170+
};
171+
172+
return (
173+
<div className="flex flex-col gap-4">
174+
<p className="text-sm text-slate-500 dark:text-dark-text-muted">
175+
Fill a printed form that has no interactive fields. Detect the blanks, type your answers (or
176+
autofill from your profile), then apply. Scanned forms: run OCR first.
177+
</p>
178+
179+
<button
180+
type="button"
181+
onClick={() => void detect()}
182+
disabled={detecting || !doc}
183+
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
184+
>
185+
{detecting ? (
186+
<Loader2 className="h-4 w-4 animate-spin" />
187+
) : (
188+
<ScanLine className="h-4 w-4" />
189+
)}
190+
{detecting ? "Scanning…" : searched ? "Detect fields again" : "Detect fields"}
191+
</button>
192+
193+
{/* Saved profile editor */}
194+
<div className="rounded-lg border border-slate-200 dark:border-dark-border">
195+
<button
196+
type="button"
197+
onClick={() => setProfileOpen((o) => !o)}
198+
className="flex w-full items-center gap-2 px-3 py-2 text-sm font-medium text-slate-700 dark:text-dark-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded-lg"
199+
>
200+
<UserCog className="h-4 w-4 text-primary-600" />
201+
Your profile
202+
<span className="ml-auto text-xs text-slate-400">{profileOpen ? "Hide" : "Edit"}</span>
203+
</button>
204+
{profileOpen && (
205+
<div className="flex flex-col gap-2 border-t border-slate-200 dark:border-dark-border p-3">
206+
{PROFILE_FIELDS.map((pf) => (
207+
<TextField
208+
key={pf.key}
209+
label={pf.label}
210+
value={profile[pf.key] ?? ""}
211+
onChange={(v) => setProfileField(pf.key, v)}
212+
placeholder={pf.placeholder}
213+
/>
214+
))}
215+
<p className="text-xs text-slate-400 dark:text-dark-text-muted">
216+
Saved only on this device, for autofill. Never uploaded.
217+
</p>
218+
</div>
219+
)}
220+
</div>
221+
222+
{searched && !detecting && (
223+
<>
224+
{fields.length === 0 ? (
225+
<div className="rounded-lg bg-slate-50 dark:bg-dark-bg px-3 py-3 text-xs text-slate-500 dark:text-dark-text-muted">
226+
No blank fields detected on this document's text layer. If it's a scan, run OCR first.
227+
</div>
228+
) : (
229+
<div className="flex flex-col gap-3">
230+
<div className="flex items-center justify-between">
231+
<span className="text-xs font-medium uppercase tracking-[0.12em] text-slate-400 dark:text-dark-text-muted">
232+
{fields.length} field{fields.length === 1 ? "" : "s"} · {filledCount} filled
233+
</span>
234+
{hasProfileMatch && (
235+
<button
236+
type="button"
237+
onClick={autofill}
238+
className="text-xs font-medium text-primary-600 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded"
239+
>
240+
Autofill from profile
241+
</button>
242+
)}
243+
</div>
244+
245+
<div className="thin-scrollbar flex max-h-72 flex-col gap-2 overflow-y-auto pr-1">
246+
{fields.map((f, i) => (
247+
<Labeled key={`${f.pageIndex}-${i}`} label={f.label || "Field"} normalCase>
248+
<div className="flex items-center gap-1.5">
249+
<input
250+
type="text"
251+
value={values[i] ?? ""}
252+
onChange={(e) => setValue(i, e.target.value)}
253+
className="min-w-0 flex-1 rounded-lg border border-slate-200 dark:border-dark-border bg-white dark:bg-dark-surface px-2.5 py-1.5 text-sm text-slate-800 dark:text-dark-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
254+
/>
255+
<button
256+
type="button"
257+
onClick={() => {
258+
setSelectedPage(f.pageIndex);
259+
setViewMode("focus");
260+
}}
261+
className="shrink-0 rounded px-1.5 text-xs text-slate-400 hover:text-primary-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
262+
>
263+
p{f.pageIndex + 1}
264+
</button>
265+
</div>
266+
</Labeled>
267+
))}
268+
</div>
269+
270+
<PrimaryAction
271+
label={`Fill ${filledCount} field${filledCount === 1 ? "" : "s"}`}
272+
onApply={apply}
273+
disabled={filledCount === 0}
274+
/>
275+
</div>
276+
)}
277+
</>
278+
)}
279+
</div>
280+
);
281+
}

0 commit comments

Comments
 (0)