feat(editor): guard unsaved edits and rework the editor toolbar

This commit is contained in:
Anthony Stirling
2026-09-02 12:22:41 +01:00
parent 2cf355c5cd
commit 3ca95f16df
47 changed files with 4033 additions and 1394 deletions
@@ -6341,7 +6341,6 @@ REVERSE_ORDER = "Flip the document so the last page becomes first and so on."
SIDE_STITCH_BOOKLET_SORT = "Arrange pages for sidestitch booklet printing (optimized for binding on the side)."
[pdfTextEditor]
confirmReplaceDirty = "You have unsaved changes. Replace the open document and discard them?"
download = "Download"
downloadTooltip = "Save and download the edited PDF"
save = "Save PDF"
@@ -6356,6 +6355,13 @@ freetext = "Annotation text - not page text, so it can't be edited here"
stamp = "Stamp annotation - not page text, so it can't be edited here"
widget = "Form field - not page text, so it can't be edited here"
[pdfTextEditor.discard]
body = "Opening {{name}} closes the document you are editing. Your unsaved changes will be lost."
cancel = "Cancel"
confirm = "Discard and open"
hint = "Cancel, then Save PDF to keep them in your workspace first."
title = "Discard unsaved changes?"
[pdfTextEditor.drop]
hint = "Releases on the editor stage replace any open document."
title = "Drop a PDF to open"
@@ -6375,10 +6381,8 @@ noMatches = "No matches"
previous = "Previous match"
replace = "Replace"
replaceAll = "Replace all"
replaced = " · {{count}} replaced"
replaced = "{{count}} replaced"
replacePlaceholder = "Replace with"
title = "Find & replace"
typeToSearch = "Type to search"
wholeWord = "Whole word"
[pdfTextEditor.fontPicker]
@@ -6422,29 +6426,16 @@ ariaLabel = "Keyboard shortcuts"
title = "Keyboard shortcuts"
tooltip = "Keyboard shortcuts (?)"
[pdfTextEditor.help.arrangement]
alignDesc = "Align edges L / centre / R / T / mid / B"
alignKey = "Toolbar align"
distributeDesc = "Equal horizontal / vertical spacing (3+)"
distributeKey = "Toolbar distribute"
frontBackDesc = "Bring to front / send to back"
frontBackKey = "Toolbar front/back"
heading = "Object arrangement"
lockDesc = "Lock / unlock selection (session-only)"
lockKey = "Lock button"
orderDesc = "Bring forward / send backward (one step)"
orderKey = "Toolbar ↑ ↓"
[pdfTextEditor.help.clipboard]
copyDesc = "Copy selected text"
copyKey = "Ctrl+C"
copyKey = "Ctrl + C"
cutDesc = "Cut selected (copy + delete)"
cutKey = "Ctrl+X"
cutKey = "Ctrl + X"
heading = "Clipboard"
pasteDesc = "Paste clipboard text as new run"
pasteKey = "Ctrl+V"
pasteKey = "Ctrl + V"
pastePlainDesc = "Paste as plain text"
pastePlainKey = "Ctrl+Shift+V"
pastePlainKey = "Ctrl + Shift + V"
[pdfTextEditor.help.document]
escDesc = "Clear selection / close find / close help"
@@ -6453,76 +6444,42 @@ heading = "Document"
helpDesc = "This help"
helpKey = "? / F1"
saveDesc = "Save to your workspace"
saveKey = "Ctrl+S"
saveKey = "Ctrl + S"
[pdfTextEditor.help.editing]
clickDesc = "Edit text"
clickKey = "Click"
deleteDesc = "Remove selected"
deleteKey = "Delete"
duplicateDesc = "Duplicate selected"
duplicateKey = "Ctrl+D"
groupDesc = "Group selected runs (Group button)"
groupKey = "Ctrl+M"
duplicateKey = "Ctrl + D"
groupDesc = "Group selected runs into one paragraph"
groupKey = "Ctrl + M"
heading = "Editing"
marqueeDesc = "Marquee multi-select"
marqueeKey = "Ctrl+Shift+drag"
marqueeKey = "Ctrl + Shift + Drag"
moveDesc = "Move text run"
moveKey = "Ctrl+Click + drag"
moveKey = "Ctrl + Click + Drag"
selectAllDesc = "Select all"
selectAllKey = "Ctrl+A"
selectAllKey = "Ctrl + A"
shiftClickDesc = "Add / remove a run from selection"
shiftClickKey = "Ctrl+Click / Shift+Click"
shiftClickKey = "Ctrl + Click / Shift + Click"
undoRedoDesc = "Undo / Redo"
undoRedoKey = "Ctrl+Z / Ctrl+Y"
ungroupDesc = "Ungroup paragraph: select it, click Ungroup"
ungroupKey = "-"
undoRedoKey = "Ctrl + Z / Ctrl + Y"
[pdfTextEditor.help.find]
enterFindDesc = "Next match"
enterFindKey = "Enter (in find)"
enterReplaceDesc = "Replace one (Shift = Replace All)"
enterReplaceKey = "Enter (in replace)"
heading = "Find & Replace"
nextDesc = "Next match (Shift = previous)"
nextKey = "F3 / Ctrl+G"
heading = "Find & replace"
nextDesc = "Next match (add Shift for previous)"
nextKey = "F3 / Ctrl + G"
openDesc = "Open find bar (and replace)"
openKey = "Ctrl+F"
[pdfTextEditor.help.formatting]
caseDesc = "Change case (upper/lower/title/sentence)"
caseKey = "Toolbar case (Aa)"
colourDesc = "Change fill colour"
colourKey = "Toolbar colour"
fontFamilyDesc = "Swap to base-14 font"
fontFamilyKey = "Toolbar font family"
fontSizeDesc = "Change font size"
fontSizeKey = "Toolbar font size"
heading = "Text formatting"
italicDesc = "Italic"
italicKey = "Toolbar I"
[pdfTextEditor.help.image]
flipDesc = "Flip horizontally or vertically"
flipKey = "Toolbar flip"
heading = "Image"
moveDesc = "Move image"
moveKey = "Drag"
resizeDesc = "Resize image"
resizeKey = "Corner drag"
rotateDesc = "Rotate 90° clockwise or counter-clockwise"
rotateKey = "Toolbar rotate"
openKey = "Ctrl + F"
[pdfTextEditor.help.navigation]
firstLastDesc = "First / last page"
firstLastKey = "Ctrl+Home / Ctrl+End"
firstLastKey = "Ctrl + Home / Ctrl + End"
heading = "Navigation"
pageDesc = "Next / previous page"
pageKey = "PageDown / PageUp"
toolbarZoomDesc = "Manual zoom + Fit to width"
toolbarZoomKey = "Toolbar zoom"
pageDesc = "Previous / next page"
pageKey = "PageUp / PageDown"
zoomDesc = "Zoom in / out"
zoomKey = "Ctrl+Wheel"
zoomKey = "Ctrl + Wheel"
[pdfTextEditor.inspector]
document = "Document"
@@ -6579,6 +6536,7 @@ title = "Saving will change this PDF"
[pdfTextEditor.settings]
advanced = "Advanced"
find = "Find in document"
findTooltip = "Find (Ctrl+F)"
view = "View"
[pdfTextEditor.sidebar]
@@ -6619,6 +6577,7 @@ pickPrompt = "Pick a PDF from the Files panel on the left to begin editing."
renderingPreview = "Rendering preview"
[pdfTextEditor.toolbar]
addTextTooltip = "Add a text box - then click the page"
advancedColour = "Advanced colour"
advancedColourTooltip = "Advanced colour (glyph outline)"
alignBottom = "Align bottom"
@@ -6647,10 +6606,12 @@ flipHorizontal = "Flip horizontal"
flipVertical = "Flip vertical"
fontColour = "Font colour"
fontSize = "Font size"
formatting = "Text formatting"
italic = "Italic"
italicUnavailable = "This font has no italic version. Load your device fonts or pick another font family."
lock = "Lock selection"
lockTooltip = "Lock selection - prevents accidental edits"
more = "More actions"
order = "Order"
outlineColour = "Outline colour"
outlineWidth = "Outline width (0 = none)"
@@ -155,15 +155,19 @@ test.describe("PDF text editor - edge gestures", () => {
expect(await textOf("Open Source")).toBe(before);
});
test("the insert verbs live in the panel, not the canvas strip", async ({
test("the insert verbs live in the toolbar, not the panel", async ({
page,
}) => {
await open(page);
const panel = page.locator('[data-sidebar="tool-panel"]');
await expect(panel.getByTestId("pdf-editor-add-text")).toBeVisible();
await expect(panel.getByTestId("pdf-editor-add-image")).toBeVisible();
// Insert is a verb aimed at the page, so it sits above the page - the
// panel is for the properties of whatever is already selected.
const toolbar = page.getByTestId("pdf-editor-toolbar");
await expect(toolbar.getByTestId("pdf-editor-add-text")).toBeVisible();
await expect(toolbar.getByTestId("pdf-editor-add-image")).toBeVisible();
await expect(
page.getByTestId("pdf-editor-toolbar").getByTestId("pdf-editor-add-text"),
page
.locator('[data-sidebar="tool-panel"]')
.getByTestId("pdf-editor-add-text"),
).toHaveCount(0);
});
});
@@ -0,0 +1,203 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
/**
* Emptying a text box must actually empty it, in the PDF as well as the model.
*
* `FPDFText_SetText` traps ("unreachable", a raw WASM abort) when handed an
* empty string. An Add-text box is base-14 from the start, so its edits take
* the in-place SetText fast path - and clearing one threw inside PDFium AFTER
* the model had already been set to "". The throw escaped the React handler
* with nothing shown, leaving the model saying empty while the PDFium object
* still held every character. Saving then brought the "deleted" text back.
*/
const SAMPLE_PDF = path.join(
import.meta.dirname,
"../test-fixtures/sample.pdf",
);
const SECRET = "SHOULDVANISH";
async function openEditor(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE_PDF);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
}
/** Drop a new text box on page 0 and return its run id. */
async function addTextBox(page: Page): Promise<string> {
await page.getByTestId("pdf-editor-add-text").click();
await page
.getByTestId("pdf-editor-page-0")
.click({ position: { x: 200, y: 600 } });
await page.waitForTimeout(400);
return page.evaluate(() => {
const runs = (
window as unknown as {
__editor_store: {
getState: () => { pages: Array<{ runs: Array<{ id: string }> }> };
};
}
).__editor_store.getState().pages[0].runs;
return runs[runs.length - 1].id;
});
}
/** Select everything in a run overlay, delete it, and optionally retype. */
async function clearRun(
page: Page,
runId: string,
retype?: string,
): Promise<void> {
await page.evaluate(
({ id, text }) => {
const el = document.querySelector<HTMLElement>(
`[data-testid="pdf-editor-run-${id}"]`,
);
if (!el) throw new Error(`run ${id} not in DOM`);
el.focus();
const selection = window.getSelection();
if (!selection) throw new Error("no Selection api");
const range = document.createRange();
range.selectNodeContents(el);
selection.removeAllRanges();
selection.addRange(range);
document.execCommand("delete", false);
if (text) document.execCommand("insertText", false, text);
},
{ id: runId, text: retype },
);
await page.waitForTimeout(500);
}
async function typeInto(page: Page, runId: string, text: string) {
await page.evaluate(
({ id, t }) => {
const el = document.querySelector<HTMLElement>(
`[data-testid="pdf-editor-run-${id}"]`,
);
if (!el) throw new Error(`run ${id} not in DOM`);
el.focus();
const selection = window.getSelection();
if (!selection) throw new Error("no Selection api");
const range = document.createRange();
range.selectNodeContents(el);
selection.removeAllRanges();
selection.addRange(range);
document.execCommand("delete", false);
document.execCommand("insertText", false, t);
},
{ id: runId, t: text },
);
await page.waitForTimeout(500);
}
/** Every run's text on page 0, as the model currently has it. */
async function pageText(page: Page): Promise<string> {
return page.evaluate(() =>
(
window as unknown as {
__editor_store: {
getState: () => {
pages: Array<{ runs: Array<{ text: string }> }>;
};
};
}
).__editor_store
.getState()
.pages.flatMap((p) => p.runs)
.map((r) => r.text)
.join(" | "),
);
}
/** Download the edited PDF and re-open it in the editor. */
async function saveAndReopen(page: Page): Promise<void> {
const downloadPromise = page.waitForEvent("download");
await page.getByTestId("pdf-editor-download").click();
const download = await downloadPromise;
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(chunk as Buffer);
await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({
name: "round.pdf",
mimeType: "application/pdf",
buffer: Buffer.concat(chunks),
});
await expect(
page.locator('[data-testid^="pdf-editor-run-p0-"]').first(),
).toBeVisible({ timeout: 30_000 });
await page.waitForTimeout(600);
}
test.describe("PDF text editor - emptying a text box", () => {
test("clearing an Add-text box removes the text from the saved PDF", async ({
page,
}) => {
test.setTimeout(180_000);
await openEditor(page);
const runId = await addTextBox(page);
await typeInto(page, runId, SECRET);
expect(await pageText(page)).toContain(SECRET);
await clearRun(page, runId);
expect(
await pageText(page),
"the model must show the box as empty",
).not.toContain(SECRET);
// The real check: the PDF, not the model. A trap inside the in-place write
// left the object untouched, so the "deleted" text came back on save.
await saveAndReopen(page);
expect(
await pageText(page),
"text deleted before saving must not reappear in the saved PDF",
).not.toContain(SECRET);
});
test("clearing then retyping saves what was typed, not what was cleared", async ({
page,
}) => {
test.setTimeout(180_000);
await openEditor(page);
const runId = await addTextBox(page);
await typeInto(page, runId, SECRET);
await clearRun(page, runId, "kept");
expect(await pageText(page)).toContain("kept");
await saveAndReopen(page);
const after = await pageText(page);
expect(after).toContain("kept");
expect(after).not.toContain(SECRET);
});
test("clearing raises no error and stays undoable", async ({ page }) => {
test.setTimeout(180_000);
await openEditor(page);
const runId = await addTextBox(page);
await typeInto(page, runId, SECRET);
// Past the 600ms coalescing window, so the clear is its own undo step
// rather than merging into the typing that preceded it.
await page.waitForTimeout(900);
await clearRun(page, runId);
// A trap used to reach the store's recovery path, which rebuilds every
// page and throws the whole undo history away.
expect(await page.getByTestId("pdf-editor-error").count()).toBe(0);
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(500);
expect(await pageText(page), "undo must bring the text back").toContain(
SECRET,
);
});
});
@@ -0,0 +1,150 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
/**
* The find bar's layout, the way a code editor builds one.
*
* Two rows fenced off from the page by their own rules, the match count and
* the option toggles inside the search field, and navigation beside it. The
* toggles being inside the field is the part that needs a test: Mantine makes
* an input's section inert by default so clicks fall through to the input, so
* a real button placed there silently stops responding.
*/
const SAMPLE_PDF = path.join(
import.meta.dirname,
"../test-fixtures/sample.pdf",
);
async function openFind(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE_PDF);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.getByTestId("pdf-editor-open-find").click();
await expect(page.getByTestId("pdf-editor-find-bar")).toBeVisible();
}
test.describe("PDF text editor - find bar", () => {
test("the toolbar's search button opens AND closes the bar", async ({
page,
}) => {
test.setTimeout(120_000);
await openFind(page);
const search = page.getByTestId("pdf-editor-open-find");
// It is a toggle, and says so while the bar is up.
await expect(search).toHaveAttribute("aria-pressed", "true");
await search.click();
await expect(
page.getByTestId("pdf-editor-find-bar"),
"re-clicking search must put the find bar away",
).toBeHidden();
await expect(search).toHaveAttribute("aria-pressed", "false");
await search.click();
await expect(page.getByTestId("pdf-editor-find-bar")).toBeVisible();
});
test("the close button sits at the bar's far right, clear of the arrows", async ({
page,
}) => {
test.setTimeout(120_000);
await openFind(page);
const close = await page.getByTestId("pdf-editor-find-close").boundingBox();
const next = await page.getByTestId("pdf-editor-find-next").boundingBox();
const bar = await page.getByTestId("pdf-editor-find-bar").boundingBox();
expect(close).not.toBeNull();
expect(next).not.toBeNull();
expect(bar).not.toBeNull();
// Well clear of the match arrows, and hard against the bar's own edge.
expect(close!.x).toBeGreaterThan(next!.x + next!.width + 40);
expect(bar!.x + bar!.width - (close!.x + close!.width)).toBeLessThan(24);
});
test("the option toggles inside the field actually toggle", async ({
page,
}) => {
test.setTimeout(120_000);
await openFind(page);
for (const id of [
"pdf-editor-find-match-case",
"pdf-editor-find-whole-word",
"pdf-editor-find-ignore-accents",
]) {
const toggle = page.getByTestId(id);
await expect(toggle).toHaveAttribute("aria-pressed", "false");
await toggle.click();
await expect(
toggle,
`${id} did not respond to a click - is the input section inert?`,
).toHaveAttribute("aria-pressed", "true");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "false");
}
});
test("match case narrows the results it reports", async ({ page }) => {
test.setTimeout(120_000);
await openFind(page);
await page.getByTestId("pdf-editor-find-input").fill("AS");
const count = page.getByTestId("pdf-editor-find-count");
// The fixture's cells are lowercase "as", so a case-blind search finds them.
await expect(count).toContainText(/of/);
await page.getByTestId("pdf-editor-find-match-case").click();
await expect(count).toContainText(/no matches/i);
});
test("the count sits inside the field and navigation beside it", async ({
page,
}) => {
test.setTimeout(120_000);
await openFind(page);
await page.getByTestId("pdf-editor-find-input").fill("as");
await expect(page.getByTestId("pdf-editor-find-count")).toContainText(/of/);
const field = await page.getByTestId("pdf-editor-find-input").boundingBox();
const count = await page.getByTestId("pdf-editor-find-count").boundingBox();
const next = await page.getByTestId("pdf-editor-find-next").boundingBox();
expect(field).not.toBeNull();
expect(count).not.toBeNull();
expect(next).not.toBeNull();
// Count within the field's box; navigation entirely to its right.
expect(count!.x).toBeGreaterThan(field!.x);
expect(count!.x + count!.width).toBeLessThanOrEqual(
field!.x + field!.width + 1,
);
expect(next!.x).toBeGreaterThan(field!.x + field!.width);
});
test("the replace row lines up with the find row", async ({ page }) => {
test.setTimeout(120_000);
await openFind(page);
const find = await page.getByTestId("pdf-editor-find-input").boundingBox();
const replace = await page
.getByTestId("pdf-editor-replace-input")
.boundingBox();
expect(find).not.toBeNull();
expect(replace).not.toBeNull();
// Both fields end at the same x, so the two rows do not step.
expect(
Math.abs(find!.x + find!.width - (replace!.x + replace!.width)),
).toBeLessThanOrEqual(2);
});
test("Escape in the find field closes the bar", async ({ page }) => {
test.setTimeout(120_000);
await openFind(page);
await page.getByTestId("pdf-editor-find-input").press("Escape");
await expect(page.getByTestId("pdf-editor-find-bar")).toBeHidden();
});
});
@@ -206,8 +206,11 @@ test.describe("PDF text editor - inspector layout", () => {
page,
}) => {
await open(page, 0);
await expect(page.getByTestId("pdf-editor-save")).toBeVisible();
await expect(page.getByTestId("pdf-editor-download")).toBeVisible();
// Save finishes the job, so it sits at the foot of the side panel - the
// same place every other tool puts its primary action.
const footer = page.getByTestId("pdf-editor-panel-actions");
await expect(footer.getByTestId("pdf-editor-save")).toBeVisible();
await expect(footer.getByTestId("pdf-editor-download")).toBeVisible();
// Zoom sits over the pages it scales, not in the far rail.
const zoom = page.getByTestId("pdf-editor-zoom-controls");
await expect(zoom).toBeVisible();
@@ -224,7 +227,7 @@ test.describe("PDF text editor - inspector layout", () => {
}) => {
await open(page);
// Find and the shortcuts sheet are everyday controls, not settings: they
// sit in the panel header, one click from anywhere.
// sit in the toolbar, one click from anywhere.
await expect(page.getByTestId("pdf-editor-open-find")).toBeVisible();
await expect(page.getByTestId("pdf-editor-help")).toBeVisible();
@@ -250,7 +253,7 @@ test.describe("PDF text editor - inspector layout", () => {
).toBeVisible();
});
test("Add text toggles its label and inserts from the panel", async ({
test("Add text toggles its label and inserts from the toolbar", async ({
page,
}) => {
await open(page, 0);
@@ -0,0 +1,134 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import fs from "fs";
import path from "path";
/**
* A signed PDF must come out of the editor still carrying its signed revision.
*
* The tool claims that saving a signed document APPENDS a revision rather than
* rewriting the file, which is the only way the bytes a signature covers stay
* intact. Nothing checked that end to end, so this reads the file the editor
* actually hands back and compares it, byte for byte, with what went in.
*/
const SIGNED = path.join(
import.meta.dirname,
"../test-fixtures/signed-sample.pdf",
);
async function openSigned(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SIGNED);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
}
/** Type into the first run overlay so the save is a real, edited save. */
async function makeAnEdit(page: Page): Promise<void> {
await page.evaluate(() => {
const el = document.querySelector<HTMLElement>(
'[data-testid^="pdf-editor-run-"]',
);
if (!el) throw new Error("no run overlay in the DOM");
el.focus();
const selection = window.getSelection();
if (!selection) throw new Error("no Selection api");
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
document.execCommand("insertText", false, "Z");
});
await expect(page.getByTestId("pdf-editor-dirty-dot")).toBeVisible({
timeout: 15_000,
});
}
test.describe("PDF text editor - signed save stays signed", () => {
test("an edited signed PDF is the original plus an appended revision", async ({
page,
}) => {
test.setTimeout(180_000);
await openSigned(page);
await makeAnEdit(page);
const downloadPromise = page.waitForEvent("download");
await page.getByTestId("pdf-editor-download").click();
// Signed documents warn before saving; confirm to get the file.
await page.getByTestId("pdf-editor-save-risk-confirm").click();
const download = await downloadPromise;
const saved = await download.path();
expect(saved).not.toBeNull();
const before = fs.readFileSync(SIGNED);
const after = fs.readFileSync(saved!);
// An incremental save may only ADD. If the editor had rewritten the file,
// every byte the signature covers would have moved and it would no longer
// verify for the revision it signed.
expect(
after.length,
"the saved file is shorter than the revision it must preserve",
).toBeGreaterThanOrEqual(before.length);
expect(
after.subarray(0, before.length).equals(before),
"the original signed revision was rewritten, not appended to",
).toBe(true);
// And it really is an edited save, not a byte-identical passthrough.
expect(after.length).toBeGreaterThan(before.length);
});
test("the signature survives as far as PDFium is concerned", async ({
page,
}) => {
test.setTimeout(180_000);
await openSigned(page);
const before = await page.evaluate(() =>
(
window as unknown as {
__editor_store: {
document: {
module: { FPDF_GetSignatureCount: (doc: number) => number };
docPtr: number;
};
};
}
).__editor_store.document.module.FPDF_GetSignatureCount(
(
window as unknown as {
__editor_store: { document: { docPtr: number } };
}
).__editor_store.document.docPtr,
),
);
expect(before, "fixture must actually carry a signature").toBeGreaterThan(
0,
);
});
test("an unedited signed PDF saves byte-identical", async ({ page }) => {
test.setTimeout(180_000);
await openSigned(page);
const downloadPromise = page.waitForEvent("download");
await page.getByTestId("pdf-editor-download").click();
await page.getByTestId("pdf-editor-save-risk-confirm").click();
const download = await downloadPromise;
const saved = await download.path();
const before = fs.readFileSync(SIGNED);
const after = fs.readFileSync(saved!);
expect(
after.equals(before),
"an untouched document must come back exactly as it went in",
).toBe(true);
});
});
@@ -0,0 +1,206 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
/**
* The editor's actions live in the canvas top bar, not the side panel.
*
* Insert, Find, help and Save used to sit in the tool panel, which put the two
* most-used verbs at the bottom of a scrolling column on the far side of the
* screen. These pin them where a document editor's toolbar has always been.
*/
const SAMPLE_PDF = path.join(
import.meta.dirname,
"../test-fixtures/sample.pdf",
);
async function openEditor(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE_PDF);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
}
test.describe("PDF text editor - top bar", () => {
test("carries the document actions that used to live in the panel", async ({
page,
}) => {
test.setTimeout(120_000);
await openEditor(page);
const toolbar = page.getByTestId("pdf-editor-toolbar");
await expect(toolbar).toBeVisible();
for (const id of [
"pdf-editor-undo",
"pdf-editor-redo",
"pdf-editor-add-text",
"pdf-editor-add-image",
"pdf-editor-open-find",
"pdf-editor-help",
]) {
await expect(
toolbar.locator(`[data-testid="${id}"]`),
`${id} must be in the top bar`,
).toBeVisible();
}
});
test("names the open document, and marks it unsaved once edited", async ({
page,
}) => {
test.setTimeout(120_000);
await openEditor(page);
await expect(page.getByTestId("pdf-editor-filename")).toContainText(
"sample.pdf",
);
expect(await page.getByTestId("pdf-editor-dirty-dot").count()).toBe(0);
// A real keystroke through the contenteditable overlay, not a store poke.
await page.evaluate(() => {
const el = document.querySelector<HTMLElement>(
'[data-testid^="pdf-editor-run-"]',
);
if (!el) throw new Error("no run overlay in the DOM");
el.focus();
const selection = window.getSelection();
if (!selection) throw new Error("no Selection api");
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
document.execCommand("insertText", false, "X");
});
await expect(page.getByTestId("pdf-editor-dirty-dot")).toBeVisible({
timeout: 15_000,
});
});
test("each control is stated exactly once", async ({ page }) => {
test.setTimeout(120_000);
await openEditor(page);
// The page verbs live in the toolbar, Save lives in the panel footer, and
// neither is repeated in the other.
for (const id of [
"pdf-editor-add-text",
"pdf-editor-open-find",
"pdf-editor-help",
"pdf-editor-save",
"pdf-editor-filename",
]) {
expect(
await page.locator(`[data-testid="${id}"]`).count(),
`${id} must exist exactly once`,
).toBe(1);
}
});
test("Save sits in the panel footer, like every other tool", async ({
page,
}) => {
test.setTimeout(120_000);
await openEditor(page);
const footer = page.getByTestId("pdf-editor-panel-actions");
await expect(footer).toBeVisible();
await expect(
footer.locator('[data-testid="pdf-editor-save"]'),
).toBeVisible();
await expect(
footer.locator('[data-testid="pdf-editor-download"]'),
).toBeVisible();
// ...and nowhere else. A primary action in two places is a primary action
// the user has to choose between.
expect(
await page
.getByTestId("pdf-editor-toolbar")
.locator('[data-testid="pdf-editor-save"]')
.count(),
).toBe(0);
});
test("a phone-width layout also gets find and help in the panel", async ({
page,
}) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 480, height: 900 });
await openEditor(page);
// Here the panel covers the canvas outright, so the toolbar's own find and
// shortcuts buttons are not merely elsewhere - they are off screen.
await expect(page.getByTestId("pdf-editor-panel-actions")).toBeVisible({
timeout: 15_000,
});
await expect(page.getByTestId("pdf-editor-save")).toBeVisible();
await expect(page.getByTestId("pdf-editor-open-find-panel")).toBeVisible();
await expect(page.getByTestId("pdf-editor-help-panel")).toBeVisible();
});
test("a narrow bar folds insert, find and help into one menu", async ({
page,
}) => {
test.setTimeout(120_000);
// Wide enough that the canvas still shows, narrow enough that the toolbar
// cannot hold the contextual formatting group AND four standalone buttons.
await page.setViewportSize({ width: 1150, height: 900 });
await openEditor(page);
const bar = page.getByTestId("pdf-editor-toolbar");
await expect(bar).toHaveAttribute("data-compact", "true");
// The individual buttons are gone; nothing is merely scrolled off-screen.
expect(await page.getByTestId("pdf-editor-add-text").count()).toBe(0);
expect(await page.getByTestId("pdf-editor-open-find").count()).toBe(0);
// Undo/redo never folds away, and Save is not on the bar to fold.
await expect(bar.locator('[data-testid="pdf-editor-undo"]')).toBeVisible();
await expect(page.getByTestId("pdf-editor-panel-actions")).toBeVisible();
await page.getByTestId("pdf-editor-overflow-menu").click();
for (const id of [
"pdf-editor-add-text",
"pdf-editor-add-image",
"pdf-editor-open-find",
"pdf-editor-help",
]) {
await expect(
page.getByTestId(id),
`${id} must be reachable from the overflow menu`,
).toBeVisible();
}
});
test("a wide bar keeps every control on show", async ({ page }) => {
test.setTimeout(120_000);
await openEditor(page);
await expect(page.getByTestId("pdf-editor-toolbar")).toHaveAttribute(
"data-compact",
"false",
);
expect(await page.getByTestId("pdf-editor-overflow-menu").count()).toBe(0);
});
test("Find opens beside the pages, not in the panel", async ({ page }) => {
test.setTimeout(120_000);
await openEditor(page);
await page.getByTestId("pdf-editor-open-find").click();
const findBar = page.getByTestId("pdf-editor-find-bar");
await expect(findBar).toBeVisible();
// Docked under the toolbar, above the page stack.
const bar = await findBar.boundingBox();
const stage = await page.getByTestId("pdf-editor-stage").boundingBox();
expect(bar).not.toBeNull();
expect(stage).not.toBeNull();
expect(bar!.y).toBeLessThan(stage!.y);
});
});
@@ -0,0 +1,176 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
/**
* Opening a second document must not silently bin the first one's edits.
*
* Every route in disposes the open PDFium document and its whole undo history,
* so a stray click in the file switcher used to throw away everything the user
* had typed with no warning at all.
*/
const SAMPLE_PDF = path.join(
import.meta.dirname,
"../test-fixtures/sample.pdf",
);
const PARAGRAPH_PDF = path.join(
import.meta.dirname,
"../test-fixtures/paragraph-sample.pdf",
);
async function openEditorWithTwoFiles(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await uploadFiles(page, [SAMPLE_PDF, PARAGRAPH_PDF]);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
}
/** Type into the first run overlay, the way a user would. */
async function makeAnEdit(page: Page): Promise<void> {
await page.evaluate(() => {
const el = document.querySelector<HTMLElement>(
'[data-testid^="pdf-editor-run-"]',
);
if (!el) throw new Error("no run overlay in the DOM");
el.focus();
const selection = window.getSelection();
if (!selection) throw new Error("no Selection api");
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
document.execCommand("insertText", false, "GUARDED");
});
await expect(page.getByTestId("pdf-editor-dirty-dot")).toBeVisible({
timeout: 15_000,
});
}
async function pickOtherFile(page: Page, opened: string): Promise<string> {
const other = opened === "sample.pdf" ? "paragraph-sample.pdf" : "sample.pdf";
await page.getByTestId("pdf-editor-file-switcher").click();
await page
.getByTestId("pdf-editor-file-switch")
.filter({ hasText: new RegExp(`^${other}$`) })
.first()
.click();
return other;
}
test.describe("PDF text editor - unsaved-changes guard", () => {
test("switching files with unsaved edits asks first", async ({ page }) => {
test.setTimeout(180_000);
await openEditorWithTwoFiles(page);
const opened = (
await page.getByTestId("pdf-editor-filename").innerText()
).trim();
await makeAnEdit(page);
const other = await pickOtherFile(page, opened);
// The modal root stays mounted, so assert on what the user actually sees.
await expect(
page.getByTestId("pdf-editor-discard-confirm"),
"switching files must not discard edits without asking",
).toBeVisible({ timeout: 10_000 });
// The prompt names the file it would open, so the answer is informed.
await expect(
page
.getByTestId("pdf-editor-discard-modal")
.locator(".mantine-Modal-body"),
).toContainText(other);
});
test("cancelling keeps the document, its edits and its history", async ({
page,
}) => {
test.setTimeout(180_000);
await openEditorWithTwoFiles(page);
const opened = (
await page.getByTestId("pdf-editor-filename").innerText()
).trim();
await makeAnEdit(page);
await pickOtherFile(page, opened);
await page.getByTestId("pdf-editor-discard-cancel").click();
await expect(page.getByTestId("pdf-editor-discard-confirm")).toBeHidden();
await expect(page.getByTestId("pdf-editor-filename")).toContainText(opened);
await expect(page.getByTestId("pdf-editor-dirty-dot")).toBeVisible();
// The edit itself is still in the model, not just the dirty flag.
const stillThere = await page.evaluate(() =>
(
window as unknown as {
__editor_store: {
getState: () => {
pages: Array<{ runs: Array<{ text: string }> }>;
};
};
}
).__editor_store
.getState()
.pages.flatMap((p) => p.runs)
.some((r) => r.text.includes("GUARDED")),
);
expect(stillThere).toBe(true);
});
test("confirming opens the other file", async ({ page }) => {
test.setTimeout(180_000);
await openEditorWithTwoFiles(page);
const opened = (
await page.getByTestId("pdf-editor-filename").innerText()
).trim();
await makeAnEdit(page);
const other = await pickOtherFile(page, opened);
await page.getByTestId("pdf-editor-discard-confirm").click();
await expect(page.getByTestId("pdf-editor-filename")).toHaveText(other, {
timeout: 60_000,
});
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
});
test("a clean document switches with no prompt at all", async ({ page }) => {
test.setTimeout(180_000);
await openEditorWithTwoFiles(page);
const opened = (
await page.getByTestId("pdf-editor-filename").innerText()
).trim();
const other = await pickOtherFile(page, opened);
await expect(page.getByTestId("pdf-editor-filename")).toHaveText(other, {
timeout: 60_000,
});
await expect(page.getByTestId("pdf-editor-discard-confirm")).toBeHidden();
});
test("opening a file from disk over unsaved edits asks too", async ({
page,
}) => {
test.setTimeout(180_000);
await openEditorWithTwoFiles(page);
await makeAnEdit(page);
// The same input the drag-and-drop handler feeds.
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(PARAGRAPH_PDF);
await expect(
page.getByTestId("pdf-editor-discard-confirm"),
"a dropped file must not discard edits without asking",
).toBeVisible({ timeout: 10_000 });
});
});
@@ -61,19 +61,44 @@ test.describe("PDF text editor - workbench file selection", () => {
}) => {
test.setTimeout(180_000);
await openEditorWithTwoFiles(page);
const opened = (
await page.getByTestId("pdf-editor-filename").innerText()
).trim();
// The document's own text identifies it, and unlike the toolbar's filename
// chip it survives the editor's workbench view being replaced.
const textOf = () =>
page.evaluate(() =>
(
window as unknown as {
__editor_store: {
getState: () => {
pages: Array<{ runs: Array<{ text: string }> }>;
};
};
}
).__editor_store
.getState()
.pages.flatMap((p) => p.runs)
.map((r) => r.text)
.join(" ")
.slice(0, 120),
);
const opened = await textOf();
expect(
opened.length,
"the fixture must have readable text",
).toBeGreaterThan(0);
// Mounting Active Files trims the selection to its last entry to honour the
// tool's one-file limit; the editor must not follow that onto another file.
await activeFilesTab(page).click();
await page.waitForTimeout(2000);
// The canvas unmounting starts the store's dispose grace, after which the
// panel re-opens the same file - so poll rather than sample one frame.
await expect
.poll(async () => await textOf(), { timeout: 30_000 })
.not.toBe("");
await expect(
page.getByTestId("pdf-editor-filename"),
expect(
await textOf(),
"the editor swapped the open document out from under the user",
).toHaveText(opened);
).toBe(opened);
});
test("the editor lists the workbench files and opens the one picked", async ({
@@ -94,6 +119,8 @@ test.describe("PDF text editor - workbench file selection", () => {
const other =
opened === "sample.pdf" ? "paragraph-sample.pdf" : "sample.pdf";
// The switcher is the toolbar's file-title menu: open it, then pick.
await page.getByTestId("pdf-editor-file-switcher").click();
await page
.getByTestId("pdf-editor-file-switch")
.filter({ hasText: new RegExp(`^${other}$`) })
@@ -108,6 +135,7 @@ test.describe("PDF text editor - workbench file selection", () => {
timeout: 60_000,
});
// The picked entry is the one marked current.
await page.getByTestId("pdf-editor-file-switcher").click();
await expect(
page.locator(
'[data-testid="pdf-editor-file-switch"][data-current="true"]',
@@ -3738,11 +3738,11 @@ test.describe("PDF text editor - dirty state", () => {
await gotoEditor(page);
await loadSamplePdf(page);
// Save state lives beside the top-bar filename; the sidebar no longer
// repeats it. Clean on load.
// Save state is a dot beside the top-bar filename - a word would squeeze
// the name it sits next to. Clean on load.
const filename = page.getByTestId("pdf-editor-filename");
await expect(filename).toBeVisible();
await expect(filename).not.toContainText("unsaved");
await expect(page.getByTestId("pdf-editor-dirty-dot")).toHaveCount(0);
const firstRunTestId = await page
.locator('[data-testid^="pdf-editor-run-p0-"]')
@@ -3750,7 +3750,7 @@ test.describe("PDF text editor - dirty state", () => {
.getAttribute("data-testid");
await typeIntoRun(page, firstRunTestId!, "X");
await expect(filename).toContainText("unsaved");
await expect(page.getByTestId("pdf-editor-dirty-dot")).toBeVisible();
});
});
@@ -3,11 +3,13 @@ import { Alert, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
import { downloadFile } from "@app/services/downloadService";
import { useFileContext } from "@app/contexts/FileContext";
import { useFileContext, useFileSelection } from "@app/contexts/FileContext";
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
import type { FileId } from "@app/types/file";
import type { BaseToolProps } from "@app/types/tool";
import { useEditorStore } from "@app/tools/pdfTextEditor/hooks/useEditorStore";
import { setEditorSession } from "@app/tools/pdfTextEditor/store/EditorSession";
import { useIsMobile } from "@app/hooks/useIsMobile";
import {
useDocumentLoader,
ensureAllPagesRead,
@@ -19,11 +21,11 @@ import { useEditorTestGlobal } from "@app/tools/pdfTextEditor/hooks/useEditorTes
import { useSelectionActions } from "@app/tools/pdfTextEditor/hooks/useSelectionActions";
import { useEditorKeyboardShortcuts } from "@app/tools/pdfTextEditor/hooks/useEditorKeyboardShortcuts";
import { useEditorClipboard } from "@app/tools/pdfTextEditor/hooks/useEditorClipboard";
import { FindBar } from "@app/tools/pdfTextEditor/components/FindBar";
import { HelpOverlay } from "@app/tools/pdfTextEditor/components/HelpOverlay";
import { SaveRiskModal } from "@app/tools/pdfTextEditor/components/SaveRiskModal";
import { DiscardChangesModal } from "@app/tools/pdfTextEditor/components/DiscardChangesModal";
import { HelpOverlay } from "@app/tools/pdfTextEditor/components/HelpOverlay";
import { PasswordPromptModal } from "@app/tools/pdfTextEditor/components/PasswordPromptModal";
import { EditorSaveBar } from "@app/tools/pdfTextEditor/components/EditorSaveBar";
import { EditorPanelActions } from "@app/tools/pdfTextEditor/components/EditorPanelActions";
import { EditorSidebar } from "@app/tools/pdfTextEditor/components/EditorSidebar";
import { EditorFileInputs } from "@app/tools/pdfTextEditor/components/EditorFileInputs";
import { PageStage } from "@app/tools/pdfTextEditor/components/PageStage";
@@ -49,14 +51,13 @@ const INSERTED_IMAGE_RATIO = 0.4;
export default function PdfTextEditor(_props: BaseToolProps) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const { store, state } = useEditorStore();
const load = useDocumentLoader(store);
const [selection, setSelection] = useState<SelectionState>(
store.selection.value,
);
const [findOpen, setFindOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [openedFileName, setOpenedFileName] = useState<string | null>(null);
// Set only when the document came from the workbench; a drag-dropped
// file has no fileId and can only be downloaded. Mirrored into state so the
@@ -68,6 +69,7 @@ export default function PdfTextEditor(_props: BaseToolProps) {
setSourceFileId(id);
}, []);
const { addFiles, consumeFiles, selectors } = useFileContext();
const { setSelectedFiles } = useFileSelection();
// Saving replaces the workbench file, so for a moment the selection points at
// a file the editor has not adopted yet. Auto-load must sit that out.
const [applying, setApplying] = useState(false);
@@ -370,21 +372,24 @@ export default function PdfTextEditor(_props: BaseToolProps) {
[store],
);
const handleFindNext = useCallback((reverse: boolean) => {
setFindOpen(true);
const button = document.querySelector<HTMLButtonElement>(
reverse
? '[data-testid="pdf-editor-find-prev"]'
: '[data-testid="pdf-editor-find-next"]',
);
button?.click();
}, []);
const handleFindNext = useCallback(
(reverse: boolean) => {
store.setFindOpen(true);
const button = document.querySelector<HTMLButtonElement>(
reverse
? '[data-testid="pdf-editor-find-prev"]'
: '[data-testid="pdf-editor-find-next"]',
);
button?.click();
},
[store],
);
const handleEscape = useCallback(() => {
store.selection.clear();
store.setMode("select");
setHelpOpen(false);
setFindOpen(false);
store.setHelpOpen(false);
store.setFindOpen(false);
}, [store]);
const handleUngroupSelection = useCallback(() => {
@@ -455,8 +460,11 @@ export default function PdfTextEditor(_props: BaseToolProps) {
.pages.flatMap((p) => p.runs.map((r) => r.id));
if (ids.length > 0) store.selection.selectMany(ids);
}, [store]),
onToggleHelp: useCallback(() => setHelpOpen((v) => !v), []),
onOpenFind: useCallback(() => setFindOpen(true), []),
onToggleHelp: useCallback(
() => store.setHelpOpen(!store.getState().helpOpen),
[store],
),
onOpenFind: useCallback(() => store.setFindOpen(true), [store]),
onFindNext: handleFindNext,
onEscape: handleEscape,
onMergeSelection: handleMergeSelection,
@@ -477,8 +485,19 @@ export default function PdfTextEditor(_props: BaseToolProps) {
.find((r) => r.id === selection.runIds[0]);
return !!run && (run.paragraphLineCount ?? 0) > 1;
})();
const onPickPdf = useCallback(
(file: File) => {
// Opening any document disposes the one in memory - edits, undo history and
// all - so both routes in go through `requestOpen` first.
const openDocument = useCallback(
(file: File, fromDisk: boolean) => {
if (!fromDisk) {
// Deferred until the open is agreed: moving the workbench selection
// for an open the user then cancels leaves the rest of the app
// pointing at a file the editor is not editing.
const fileId = (file as File & { fileId?: FileId }).fileId;
if (fileId != null) setSelectedFiles([fileId]);
openWorkbenchFile(file);
return;
}
setOpenedFileName(file.name);
// Dropped/picked from disk: no workbench file to replace yet, but claim
// it so a later workbench arrival cannot auto-open over these edits.
@@ -486,9 +505,70 @@ export default function PdfTextEditor(_props: BaseToolProps) {
setSourceFile(null);
void load(file);
},
[adoptFile, load, setSourceFile],
[adoptFile, load, openWorkbenchFile, setSelectedFiles, setSourceFile],
);
// A document waiting on the user's answer to "discard your changes?".
const [pendingOpen, setPendingOpen] = useState<{
file: File;
fromDisk: boolean;
} | null>(null);
const requestOpen = useCallback(
(file: File, fromDisk: boolean) => {
// Read the store, not the render's copy: a keystroke that dirtied the
// document in the same tick must still be caught.
if (store.getState().dirty) {
setPendingOpen({ file, fromDisk });
return;
}
openDocument(file, fromDisk);
},
[openDocument, store],
);
const onPickPdf = useCallback(
(file: File) => requestOpen(file, true),
[requestOpen],
);
const onPickWorkbenchFile = useCallback(
(file: File) => requestOpen(file, false),
[requestOpen],
);
const confirmPendingOpen = useCallback(() => {
const pending = pendingOpen;
setPendingOpen(null);
if (pending) openDocument(pending.file, pending.fromDisk);
}, [openDocument, pendingOpen]);
const openImagePicker = useCallback(() => {
document
.querySelector<HTMLInputElement>('[data-testid="pdf-editor-image-input"]')
?.click();
}, []);
// Publish what the canvas top bar cannot reach on its own. Kept in an effect
// so the canvas always sees the CURRENT handlers, and retracted on unmount so
// a stale Save can never fire against a panel that is gone.
useEffect(() => {
setEditorSession({
fileName: openedFileName,
fileId: sourceFileId,
save: handleSave,
download: handleDownload,
pickFile: onPickWorkbenchFile,
pickImage: openImagePicker,
});
return () => setEditorSession(null);
}, [
openedFileName,
sourceFileId,
handleSave,
handleDownload,
onPickWorkbenchFile,
openImagePicker,
]);
const handleSubmitPassword = useCallback(
(password: string) => {
const file = store.pendingPasswordFile;
@@ -515,14 +595,13 @@ export default function PdfTextEditor(_props: BaseToolProps) {
</Alert>
)}
<EditorFileInputs onPickPdf={onPickPdf} onPickImage={handleInsertImage} />
{findOpen && state.hasDocument && (
<FindBar
store={store}
pages={state.pages}
onClose={() => setFindOpen(false)}
/>
)}
<HelpOverlay opened={helpOpen} onClose={() => setHelpOpen(false)} />
{/* Declared here, not on the canvas: the canvas unmounts whenever the
workbench shows something else, and a modal that only exists while
the page stack is on screen cannot be opened from the panel. */}
<HelpOverlay
opened={state.helpOpen}
onClose={() => store.setHelpOpen(false)}
/>
<SaveRiskModal
risks={saveRisks}
onConfirm={handleConfirmSaveRisk}
@@ -534,6 +613,11 @@ export default function PdfTextEditor(_props: BaseToolProps) {
onSubmit={handleSubmitPassword}
onCancel={handleCancelPassword}
/>
<DiscardChangesModal
incomingFileName={pendingOpen?.file.name ?? null}
onConfirm={confirmPendingOpen}
onCancel={() => setPendingOpen(null)}
/>
<EditorSidebar
store={store}
state={state}
@@ -545,30 +629,29 @@ export default function PdfTextEditor(_props: BaseToolProps) {
onSetGroupingMode={(mode) => store.setGroupingMode(mode)}
onSetWidthMode={(m) => store.setWidthMode(m)}
onSetShowRulers={(show) => store.setShowRulers(show)}
onOpenFind={() => setFindOpen(true)}
onShowHelp={() => setHelpOpen(true)}
addTextArmed={state.mode === "addText"}
onToggleAddText={() =>
store.setMode(
store.getState().mode === "addText" ? "select" : "addText",
)
}
onPickImage={() =>
document
.querySelector<HTMLInputElement>(
'[data-testid="pdf-editor-image-input"]',
)
?.click()
}
/>
{/* Every tool pins its primary action to the bottom of this panel, so
the editor's Save lives there too, at every width. */}
{state.hasDocument && (
<EditorSaveBar
<EditorPanelActions
compact={isMobile}
openedFileName={openedFileName}
dirty={state.dirty}
currentFileId={sourceFileId}
onPickFile={openWorkbenchFile}
onPickFile={onPickWorkbenchFile}
onSave={handleSave}
onDownload={handleDownload}
// The find bar lives beside the pages, so bring them back first -
// on a phone the user still has to leave the panel to see it.
onOpenFind={() => {
if (store.getState().findOpen) {
store.setFindOpen(false);
return;
}
pinWorkbench();
store.setFindOpen(true);
}}
onShowHelp={() => store.setHelpOpen(true)}
/>
)}
</Stack>
@@ -0,0 +1,174 @@
import { describe, expect, it } from "vitest";
import { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand";
import {
RolledBackError,
type Command,
} from "@app/tools/pdfTextEditor/commands/Command";
import {
HistoryStack,
HistoryStepError,
} from "@app/tools/pdfTextEditor/store/HistoryStack";
import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument";
const doc = {} as EditorDocument;
/** A command that appends to `log` so the exact call order is assertable. */
function tracked(
name: string,
log: string[],
fail?: { apply?: boolean; revert?: boolean },
): Command {
return {
type: name,
apply: () => {
if (fail?.apply) throw new Error(`${name} apply blew up`);
log.push(`+${name}`);
},
revert: () => {
if (fail?.revert) throw new Error(`${name} revert blew up`);
log.push(`-${name}`);
},
} as unknown as Command;
}
describe("CompositeCommand rollback", () => {
it("undoes the children that ran when a later apply throws", () => {
const log: string[] = [];
const composite = new CompositeCommand([
tracked("a", log),
tracked("b", log),
tracked("c", log, { apply: true }),
tracked("d", log),
]);
expect(() => composite.apply(doc)).toThrow(RolledBackError);
// b and a reverted in reverse order; d never ran.
expect(log).toEqual(["+a", "+b", "-b", "-a"]);
});
it("re-applies the children that ran when a revert throws", () => {
const log: string[] = [];
const composite = new CompositeCommand([
tracked("a", log),
tracked("b", log, { revert: true }),
tracked("c", log),
]);
// revert() runs c then b; b throws, so c is put back.
expect(() => composite.revert(doc)).toThrow(RolledBackError);
expect(log).toEqual(["-c", "+c"]);
});
it("reports an unrecoverable failure when the rollback itself throws", () => {
const log: string[] = [];
const composite = new CompositeCommand([
// Applies fine, but cannot be undone - so the group is genuinely stuck.
tracked("a", log, { revert: true }),
tracked("b", log, { apply: true }),
]);
let thrown: unknown;
try {
composite.apply(doc);
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(Error);
expect(thrown).not.toBeInstanceOf(RolledBackError);
});
it("leaves nothing applied when the FIRST child throws", () => {
const log: string[] = [];
const composite = new CompositeCommand([
tracked("a", log, { apply: true }),
tracked("b", log),
]);
expect(() => composite.apply(doc)).toThrow(RolledBackError);
expect(log).toEqual([]);
});
});
describe("HistoryStack forward-apply failures", () => {
it("surfaces a failed execute as a HistoryStepError", () => {
const h = new HistoryStack();
const log: string[] = [];
expect(() => h.execute(tracked("a", log, { apply: true }), doc)).toThrow(
HistoryStepError,
);
});
it("does not record a command whose apply threw", () => {
const h = new HistoryStack();
const log: string[] = [];
try {
h.execute(tracked("a", log, { apply: true }), doc);
} catch {
/* expected */
}
// Recording it would make the next undo revert changes never made.
expect(h.size()).toEqual({ undo: 0, redo: 0 });
expect(h.canUndo).toBe(false);
});
it("keeps the previous history intact after a failed execute", () => {
const h = new HistoryStack();
const log: string[] = [];
h.execute(tracked("a", log), doc);
try {
h.execute(tracked("b", log, { apply: true }), doc);
} catch {
/* expected */
}
expect(h.size()).toEqual({ undo: 1, redo: 0 });
h.undo(doc);
expect(log).toEqual(["+a", "-a"]);
});
it("marks a rolled-back composite as leaving the document intact", () => {
const h = new HistoryStack();
const log: string[] = [];
const composite = new CompositeCommand([
tracked("a", log),
tracked("b", log, { apply: true }),
]);
let thrown: unknown;
try {
h.execute(composite, doc);
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(HistoryStepError);
expect((thrown as HistoryStepError).documentIntact).toBe(true);
// The cause is unwrapped, so the UI shows what actually failed.
expect((thrown as HistoryStepError).cause).toBeInstanceOf(Error);
expect(String((thrown as HistoryStepError).cause)).toMatch(/b apply/);
});
it("marks a bare command's failure as leaving the document unknown", () => {
const h = new HistoryStack();
const log: string[] = [];
let thrown: unknown;
try {
h.execute(tracked("a", log, { apply: true }), doc);
} catch (err) {
thrown = err;
}
expect((thrown as HistoryStepError).documentIntact).toBe(false);
});
it("a failed execute does not coalesce the next edit into it", () => {
const h = new HistoryStack();
const log: string[] = [];
const keyed = (name: string, fail?: boolean): Command =>
({
...tracked(name, log, fail ? { apply: true } : undefined),
coalesceKey: () => "same",
}) as unknown as Command;
h.execute(keyed("a"), doc);
try {
h.execute(keyed("b", true), doc);
} catch {
/* expected */
}
h.execute(keyed("c"), doc);
// Two separate undo steps: the failure ended the burst.
expect(h.size()).toEqual({ undo: 2, redo: 0 });
});
});
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from "vitest";
import { PdfiumSave } from "@app/tools/pdfTextEditor/pdfium/PdfiumSave";
import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument";
const MIN_PDF = `%PDF-1.7\n${"x".repeat(400)}\n%%EOF\n`;
interface FakeOptions {
/** What PDFiumExt_SaveAsCopy returns (PDFium's FPDF_BOOL). */
saveResult?: number;
/** What FPDF_SaveAsCopy returns; omit the key to drop the entry point. */
flaggedResult?: boolean | null;
/** Bytes the writer hands back. */
output?: string;
}
/**
* A PDFium module stub that writes `output` into a fake wasm heap.
*
* Big enough to be honest about the save path - the malloc/heap dance is where
* a size of 0 would otherwise slip through as "saved fine".
*/
function fakeDoc(opts: FakeOptions = {}) {
const text = opts.output ?? MIN_PDF;
const bytes = new Uint8Array(text.length);
for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i);
const memory = { buffer: new ArrayBuffer(64 * 1024) };
const heap = new Uint8Array(memory.buffer);
const BASE = 1024;
const saveAsCopy = vi.fn(() => {
heap.set(bytes, BASE);
return opts.saveResult ?? 1;
});
const flagged = vi.fn(() => {
heap.set(bytes, BASE);
return opts.flaggedResult ?? true;
});
const module: Record<string, unknown> = {
PDFiumExt_OpenFileWriter: () => 7,
PDFiumExt_CloseFileWriter: vi.fn(),
PDFiumExt_SaveAsCopy: saveAsCopy,
PDFiumExt_GetFileWriterSize: () => bytes.length,
PDFiumExt_GetFileWriterData: (_w: number, out: number, size: number) => {
heap.copyWithin(out, BASE, BASE + size);
},
pdfium: {
wasmExports: { malloc: () => 8192, free: vi.fn(), memory },
},
};
if (opts.flaggedResult !== null) module.FPDF_SaveAsCopy = flagged;
const doc = {
module,
docPtr: 42,
loadedPages: () => [],
} as unknown as EditorDocument;
return { doc, saveAsCopy, flagged };
}
describe("PdfiumSave failure detection", () => {
it("returns the bytes when PDFium reports success", () => {
const { doc } = fakeDoc();
expect(PdfiumSave.serialize(doc).length).toBe(MIN_PDF.length);
});
it("throws when PDFium's save returns false", () => {
const { doc } = fakeDoc({ saveResult: 0 });
// Ignoring this return code is what let a failed save overwrite the user's
// file with whatever the writer happened to hold.
expect(() => PdfiumSave.serialize(doc)).toThrow(/could not serialize/i);
});
it("throws when the flagged incremental save returns false", () => {
const { doc } = fakeDoc({ flaggedResult: false });
expect(() => PdfiumSave.serialize(doc, { incremental: true })).toThrow(
/could not serialize/i,
);
});
it("throws when the writer hands back an empty buffer", () => {
const { doc } = fakeDoc({ output: "" });
expect(() => PdfiumSave.serialize(doc)).toThrow(/too short/i);
});
it("throws when the writer hands back something that is not a PDF", () => {
const { doc } = fakeDoc({ output: "N".repeat(2048) });
expect(() => PdfiumSave.serialize(doc)).toThrow(/PDF header/i);
});
it("uses the flagged entry point for an incremental save", () => {
const { doc, flagged, saveAsCopy } = fakeDoc();
PdfiumSave.serialize(doc, { incremental: true });
expect(flagged).toHaveBeenCalledWith(42, 7, 1);
expect(saveAsCopy).not.toHaveBeenCalled();
});
it("refuses to silently full-rewrite when the flagged entry point is gone", () => {
const { doc, saveAsCopy } = fakeDoc({ flaggedResult: null });
expect(() => PdfiumSave.serialize(doc, { incremental: true })).toThrow(
/FPDF_SaveAsCopy/,
);
// The whole point: no rewrite of the signed revision happened.
expect(saveAsCopy).not.toHaveBeenCalled();
});
it("still uses the plain entry point when incremental was not asked for", () => {
const { doc, saveAsCopy, flagged } = fakeDoc();
PdfiumSave.serialize(doc);
expect(saveAsCopy).toHaveBeenCalledWith(42, 7);
expect(flagged).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
assertIncrementalAppend,
assertSavedPdf,
} from "@app/tools/pdfTextEditor/util/savedBytes";
function ascii(text: string): Uint8Array {
const out = new Uint8Array(text.length);
for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i);
return out;
}
/** A byte string long enough to clear the minimum-length check. */
function pdf(body = "x".repeat(400), tail = "\n%%EOF\n"): Uint8Array {
return ascii(`%PDF-1.7\n${body}${tail}`);
}
describe("assertSavedPdf", () => {
it("accepts a plausible PDF", () => {
expect(() => assertSavedPdf(pdf())).not.toThrow();
});
it("rejects the empty buffer a failed writer hands back", () => {
expect(() => assertSavedPdf(new Uint8Array(0))).toThrow(/too short/i);
});
it("rejects a truncated write", () => {
expect(() => assertSavedPdf(ascii("%PDF-1.7\n%%EOF\n"))).toThrow(
/too short/i,
);
});
it("rejects data that is not a PDF at all", () => {
expect(() => assertSavedPdf(ascii("N".repeat(2048)))).toThrow(
/PDF header/i,
);
});
it("rejects a PDF with no end-of-file marker", () => {
expect(() => assertSavedPdf(pdf("x".repeat(400), "\n"))).toThrow(
/end-of-file/i,
);
});
it("rejects an EOF marker buried far from the end", () => {
// %%EOF followed by 8k of padding: the real terminator is missing.
expect(() => assertSavedPdf(pdf("%%EOF" + "\0".repeat(8192), ""))).toThrow(
/end-of-file/i,
);
});
});
describe("assertIncrementalAppend", () => {
const original = pdf();
it("accepts bytes that only appended a revision", () => {
const appended = new Uint8Array(original.length + 32);
appended.set(original);
appended.set(ascii("\n1 0 obj\n<<>>\nendobj\n%%EOF\n"), original.length);
expect(() => assertIncrementalAppend(appended, original)).not.toThrow();
});
it("accepts a byte-identical save", () => {
expect(() => assertIncrementalAppend(original, original)).not.toThrow();
});
it("rejects a save shorter than the revision it must preserve", () => {
expect(() =>
assertIncrementalAppend(original.slice(0, original.length - 1), original),
).toThrow(/shorter/i);
});
it("rejects a full rewrite that changed the signed bytes", () => {
const rewritten = new Uint8Array(original);
rewritten[100] = rewritten[100] ^ 0xff;
expect(() => assertIncrementalAppend(rewritten, original)).toThrow(
/byte 100/,
);
});
});
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from "vitest";
import { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore";
import { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand";
import type { Command } from "@app/tools/pdfTextEditor/commands/Command";
import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument";
function makeCmd(overrides: Partial<Command> = {}): Command {
return {
type: "test",
apply: vi.fn(),
revert: vi.fn(),
...overrides,
} as unknown as Command;
}
/** Minimal document stub: no pages, so repopulate is a no-op. */
function makeDoc(): EditorDocument {
return {
pageCount: 0,
loadedPages: () => [],
dispose: () => {},
} as unknown as EditorDocument;
}
async function makeStore(): Promise<EditorStore> {
const store = new EditorStore();
await store.setDocument(makeDoc());
return store;
}
describe("EditorStore recovery from a failed apply", () => {
it("does not throw out of dispatch when a command's apply fails", async () => {
const store = await makeStore();
expect(() =>
store.dispatch(
makeCmd({
apply: () => {
throw new Error("pdfium said no");
},
}),
),
).not.toThrow();
});
it("surfaces the underlying failure, not the history wrapper", async () => {
const store = await makeStore();
store.dispatch(
makeCmd({
apply: () => {
throw new Error("pdfium said no");
},
}),
);
expect(store.getState().error).toBe("pdfium said no");
});
it("throws the history away when the document may be half-changed", async () => {
const store = await makeStore();
store.dispatch(makeCmd());
expect(store.history.canUndo).toBe(true);
store.dispatch(
makeCmd({
apply: () => {
throw new Error("boom");
},
}),
);
// A bare command cannot say what it did, so nothing already on the stack
// can be trusted to describe the page any more.
expect(store.history.canUndo).toBe(false);
expect(store.getState().dirty).toBe(true);
});
it("keeps the history when a composite rolled itself back", async () => {
const store = await makeStore();
store.dispatch(makeCmd());
const before = store.history.peekUndo();
store.dispatch(
new CompositeCommand([
makeCmd(),
makeCmd({
apply: () => {
throw new Error("second child failed");
},
}),
]),
);
// The group put the document back, so the earlier edit is still undoable.
expect(store.history.peekUndo()).toBe(before);
expect(store.getState().error).toBe("second child failed");
});
it("a failed edit does not mark a clean document dirty when it rolled back", async () => {
const store = await makeStore();
store.dispatch(
new CompositeCommand([
makeCmd(),
makeCmd({
apply: () => {
throw new Error("nope");
},
}),
]),
);
expect(store.getState().dirty).toBe(false);
});
});
@@ -16,3 +16,21 @@ export interface Command {
// previous undo step however long ago that step ran.
coalesceIgnoresTimeWindow?(previous: Command | null): boolean;
}
/**
* A command failed to apply/revert but the document was put back as it was.
*
* Only `CompositeCommand` can promise this - it knows which children ran and
* can undo exactly those. The store reads it to decide whether the run model
* still describes the page (surface the error) or has to be rebuilt from
* PDFium (throw the history away).
*/
export class RolledBackError extends Error {
readonly cause: unknown;
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : String(cause));
this.name = "RolledBackError";
this.cause = cause;
}
}
@@ -1,4 +1,7 @@
import type { Command } from "@app/tools/pdfTextEditor/commands/Command";
import {
RolledBackError,
type Command,
} from "@app/tools/pdfTextEditor/commands/Command";
import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument";
/** Groups several already-applied commands into one undo/redo step. */
@@ -21,12 +24,47 @@ export class CompositeCommand implements Command {
}
apply(doc: EditorDocument): void {
for (const cmd of this.commands) cmd.apply(doc);
this.run(doc, this.commands, "apply");
}
revert(doc: EditorDocument): void {
for (let i = this.commands.length - 1; i >= 0; i--) {
this.commands[i].revert(doc);
const reversed = [...this.commands].reverse();
this.run(doc, reversed, "revert");
}
/**
* Run every child in order, undoing the ones that already ran if one throws.
*
* A group is ONE undo step, so half of it landing is worse than none of it:
* the model would describe a page that no longer matches and the user's next
* undo would revert children that never applied. Rolling back keeps the
* document exactly as it was found, which is what `RolledBackError` promises.
*/
private run(
doc: EditorDocument,
order: Command[],
phase: "apply" | "revert",
): void {
const done: Command[] = [];
for (const cmd of order) {
try {
if (phase === "apply") cmd.apply(doc);
else cmd.revert(doc);
} catch (err) {
for (let i = done.length - 1; i >= 0; i--) {
try {
if (phase === "apply") done[i].revert(doc);
else done[i].apply(doc);
} catch {
// The rollback itself failed, so the document is genuinely
// half-changed - report the original failure unwrapped so the
// caller rebuilds instead of trusting the model.
throw err;
}
}
throw new RolledBackError(err);
}
done.push(cmd);
}
}
@@ -482,6 +482,23 @@ export class EditTextCommand implements Command {
// emits land at the same baselines we just established.
run.paragraphLineHeight = lineHeight;
}
} else {
// Nothing was emitted, which means the run is now empty. Every object it
// owned was removed just above, so its pointers have to go too: left
// behind, the NEXT edit removes and reads freed objects, and whatever it
// emits never reaches the page.
run.pdfiumObjPtr = 0;
run.paragraphMemberPtrs = [];
run.paragraphMemberContainers = [];
run.paragraphMemberFs = [];
run.paragraphLeafPtrs = [];
run.paragraphLeafContainers = [];
// The box has no ink left, so its old width is not geometry any more -
// and the overlay treats run.bounds.width as a floor, so leaving it would
// make whatever is typed next sit in a box the size of what was deleted.
// The emit below only ever GROWS bounds, which rebuilds this from the
// fresh span.
run.bounds = { ...run.bounds, width: 0 };
}
run.mergedFromPtrs = [];
@@ -0,0 +1,69 @@
import { Group, Modal, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
interface Props {
/** Name of the document that would be opened, or null when closed. */
incomingFileName: string | null;
onConfirm: () => void;
onCancel: () => void;
}
/**
* Last stop before the editor throws away unsaved edits.
*
* Opening another document disposes the one in memory along with its whole
* undo history, so every route into that - the file switcher, a drop onto the
* canvas - has to pass through here first. Cancel is the default action: the
* user reaching for a file list has not asked to lose anything.
*/
export function DiscardChangesModal({
incomingFileName,
onConfirm,
onCancel,
}: Props) {
const { t } = useTranslation();
return (
<Modal
opened={incomingFileName !== null}
onClose={onCancel}
title={t("pdfTextEditor.discard.title", "Discard unsaved changes?")}
size="md"
data-testid="pdf-editor-discard-modal"
>
<Stack gap="md">
<Text size="sm">
{t(
"pdfTextEditor.discard.body",
"Opening {{name}} closes the document you are editing. Your unsaved changes will be lost.",
{ name: incomingFileName ?? "" },
)}
</Text>
<Text size="sm" c="dimmed">
{t(
"pdfTextEditor.discard.hint",
"Cancel, then Save PDF to keep them in your workspace first.",
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="secondary"
accent="neutral"
onClick={onCancel}
data-testid="pdf-editor-discard-cancel"
>
{t("pdfTextEditor.discard.cancel", "Cancel")}
</Button>
<Button
variant="primary"
accent="danger"
onClick={onConfirm}
data-testid="pdf-editor-discard-confirm"
>
{t("pdfTextEditor.discard.confirm", "Discard and open")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -1,66 +1,117 @@
import { Stack, Text } from "@mantine/core";
import { Menu, Text, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import CheckIcon from "@mui/icons-material/Check";
import { Button } from "@app/ui/Button";
import { useAllFiles, useFileSelection } from "@app/contexts/FileContext";
import { useAllFiles } from "@app/contexts/FileContext";
import type { FileId } from "@app/types/file";
interface Props {
/** Workbench file the editor currently holds, when it came from one. */
currentFileId: FileId | null;
/** Open the picked file; the editor never follows the selection on its own. */
/** Name shown on the chip - the open document, workbench file or not. */
currentFileName: string;
/** Unsaved-changes marker, shown as a dot beside the name. */
dirty: boolean;
// Ask the editor to open the picked file. It may refuse - a dirty document
// gets a confirmation first - so the workbench selection is NOT moved here;
// the editor syncs it once the open actually goes ahead.
onPick: (file: File) => void;
}
/**
* Switch which workbench file the editor is editing.
* Which document you are editing, and how to edit a different one.
*
* The editor owns the whole canvas, so the workbench's own Active Files grid is
* a view away; without this the user can open the tool with several files
* loaded and have no way to say which one to edit. Picking here sets the
* workbench selection rather than loading directly, so the rest of the app
* agrees about which file is being worked on.
* Sits at the top-left of the toolbar because that is where a document editor
* puts the document's name; picking from it is the same gesture as any other
* app's file title menu. With fewer than two PDFs in the workbench there is
* nothing to switch to, so it collapses to a plain name.
*/
export function EditorFileSwitcher({ currentFileId, onPick }: Props) {
export function EditorFileSwitcher({
currentFileId,
currentFileName,
dirty,
onPick,
}: Props) {
const { t } = useTranslation();
const { files } = useAllFiles();
const { setSelectedFiles } = useFileSelection();
const pdfs = files.filter((f) => /\.pdf$/i.test(f.name));
if (pdfs.length < 2) return null;
const label = (
<span className="pdf-editor-topbar__file">
<DescriptionIcon fontSize="small" style={{ flexShrink: 0 }} />
<span className="pdf-editor-topbar__filename">{currentFileName}</span>
{dirty && (
<span
className="pdf-editor-topbar__dirty"
data-testid="pdf-editor-dirty-dot"
aria-label={t("pdfTextEditor.unsaved", "(unsaved)")}
/>
)}
</span>
);
if (pdfs.length < 2) {
return (
<Tooltip label={currentFileName}>
<Text
size="xs"
c="dimmed"
px={6}
data-testid="pdf-editor-filename"
component="div"
>
{label}
</Text>
</Tooltip>
);
}
return (
<Stack gap={4} data-testid="pdf-editor-file-switcher">
<Text size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.4px" }}>
{t("pdfTextEditor.sidebar.document", "Document")}
</Text>
{pdfs.map((file) => {
const fileId = (file as File & { fileId?: FileId }).fileId;
const current = fileId != null && fileId === currentFileId;
return (
<Button
key={fileId ?? file.name}
size="sm"
fullWidth
justify="start"
overflow="hidden"
variant={current ? "primary" : "secondary"}
accent={current ? "default" : "neutral"}
leftSection={<DescriptionIcon fontSize="small" />}
title={file.name}
data-testid="pdf-editor-file-switch"
data-current={current ? "true" : "false"}
disabled={fileId == null}
onClick={() => {
if (fileId == null) return;
setSelectedFiles([fileId]);
onPick(file);
}}
>
{file.name}
</Button>
);
})}
</Stack>
<Menu shadow="md" position="bottom-start" withinPortal closeOnItemClick>
<Menu.Target>
<Button
size="sm"
variant="tertiary"
accent="neutral"
rightSection={<ExpandMoreIcon fontSize="small" />}
data-testid="pdf-editor-file-switcher"
title={currentFileName}
>
<span data-testid="pdf-editor-filename">{label}</span>
</Button>
</Menu.Target>
<Menu.Dropdown data-testid="pdf-editor-file-switcher-menu">
<Menu.Label>
{t("pdfTextEditor.sidebar.document", "Document")}
</Menu.Label>
{pdfs.map((file) => {
const fileId = (file as File & { fileId?: FileId }).fileId;
const current = fileId != null && fileId === currentFileId;
return (
<Menu.Item
key={fileId ?? file.name}
leftSection={
current ? (
<CheckIcon fontSize="small" />
) : (
<DescriptionIcon fontSize="small" />
)
}
disabled={fileId == null}
data-testid="pdf-editor-file-switch"
data-current={current ? "true" : "false"}
onClick={() => {
if (fileId == null) return;
onPick(file);
}}
>
{file.name}
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
);
}
@@ -0,0 +1,148 @@
import { Box, Group, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import DownloadIcon from "@mui/icons-material/FileDownloadOutlined";
import SearchIcon from "@mui/icons-material/SearchOutlined";
import HelpIcon from "@mui/icons-material/HelpOutlineOutlined";
import { EditorFileSwitcher } from "@app/tools/pdfTextEditor/components/EditorFileSwitcher";
import type { FileId } from "@app/types/file";
interface Props {
/** Phones also lose the toolbar behind this panel, so it carries its extras. */
compact: boolean;
openedFileName: string | null;
dirty: boolean;
/** Workbench file currently open, so the switcher can mark it. */
currentFileId: FileId | null;
/** Open a different workbench file. */
onPickFile: (file: File) => void;
onSave: () => void;
onDownload: () => void;
onOpenFind: () => void;
onShowHelp: () => void;
}
/**
* Where the editor finishes, in the place every other tool finishes.
*
* Every tool in the app pins its primary action to the bottom of this panel
* (see `createToolFlow`'s executeButton and its sticky `executeFooter`), so the
* editor's Save belongs here too - a user who has learnt where "the button that
* does the thing" lives should not have to learn a second answer for one tool.
*
* It is here at every width, and it is the ONLY Save: a primary action that
* appears in two places, or moves between them, is worse than one that is
* always in the same place. Find, help and the file switcher ride along only on
* phones, where the panel covers the canvas and the toolbar carrying them is
* off screen entirely.
*/
export function EditorPanelActions({
compact,
openedFileName,
dirty,
currentFileId,
onPickFile,
onSave,
onDownload,
onOpenFind,
onShowHelp,
}: Props) {
const { t } = useTranslation();
return (
<Box
px="md"
py="sm"
// Sticky, not flex-pinned: the tool renders inside ToolPanel's own
// ScrollArea, where height:100% resolves to content height, so a
// flex-column footer would just sit wherever the content ended.
style={{
position: "sticky",
bottom: 0,
zIndex: 2,
borderTop: "1px solid var(--mantine-color-default-border)",
background: "var(--c-bg-raised)",
}}
data-testid="pdf-editor-panel-actions"
>
{compact && openedFileName && (
<Group gap={6} wrap="nowrap" mb={8}>
<EditorFileSwitcher
currentFileId={currentFileId}
currentFileName={openedFileName}
dirty={dirty}
onPick={onPickFile}
/>
</Group>
)}
<Group gap="xs" wrap="nowrap">
<Tooltip
label={t(
"pdfTextEditor.saveTooltip",
"Apply changes to the file in your workspace (Ctrl+S)",
)}
>
<Button
size="sm"
onClick={onSave}
data-testid="pdf-editor-save"
style={{ flex: 1, minWidth: 0 }}
>
{t("pdfTextEditor.save", "Save PDF")}
</Button>
</Tooltip>
<Tooltip
label={t(
"pdfTextEditor.downloadTooltip",
"Save and download the edited PDF",
)}
>
<Button
size="sm"
variant="secondary"
accent="neutral"
onClick={onDownload}
data-testid="pdf-editor-download"
aria-label={t("pdfTextEditor.download", "Download")}
leftSection={<DownloadIcon fontSize="small" />}
/>
</Tooltip>
{compact && (
<>
<Tooltip
label={t("pdfTextEditor.settings.find", "Find in document")}
>
<Button
size="sm"
variant="secondary"
accent="neutral"
onClick={onOpenFind}
data-testid="pdf-editor-open-find-panel"
aria-label={t(
"pdfTextEditor.settings.find",
"Find in document",
)}
leftSection={<SearchIcon fontSize="small" />}
/>
</Tooltip>
<Tooltip
label={t("pdfTextEditor.help.ariaLabel", "Keyboard shortcuts")}
>
<Button
size="sm"
variant="secondary"
accent="neutral"
onClick={onShowHelp}
data-testid="pdf-editor-help-panel"
aria-label={t(
"pdfTextEditor.help.ariaLabel",
"Keyboard shortcuts",
)}
leftSection={<HelpIcon fontSize="small" />}
/>
</Tooltip>
</>
)}
</Group>
</Box>
);
}
@@ -1,113 +0,0 @@
import { Box, Group, Text, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import DownloadIcon from "@mui/icons-material/FileDownloadOutlined";
import { EditorFileSwitcher } from "@app/tools/pdfTextEditor/components/EditorFileSwitcher";
import type { FileId } from "@app/types/file";
interface Props {
openedFileName: string | null;
dirty: boolean;
/** Workbench file currently open, so the switcher can mark it. */
currentFileId: FileId | null;
/** Open a different workbench file. */
onPickFile: (file: File) => void;
onSave: () => void;
onDownload: () => void;
}
/**
* Pinned footer: what file you are editing, and the one action that finishes.
*
* Save is the primary verb - it lands the edit back in the workbench like
* every other tool. Download is the same save plus a file, so it rides along
* as a subordinate icon rather than a second full-width button competing for
* the same attention.
*/
export function EditorSaveBar({
openedFileName,
dirty,
currentFileId,
onPickFile,
onSave,
onDownload,
}: Props) {
const { t } = useTranslation();
return (
<Box
px="md"
py="sm"
// Sticky, not flex-pinned: the tool renders inside ToolPanel's own
// ScrollArea, where height:100% resolves to content height, so a
// flex-column footer would just sit wherever the content ended.
style={{
position: "sticky",
bottom: 0,
zIndex: 2,
borderTop: "1px solid var(--mantine-color-default-border)",
background: "var(--c-bg-raised)",
}}
>
{/* Choosing which file to edit is navigation, not a document fact, so it
stays reachable here rather than behind the Document tab. Renders
nothing until the workbench holds more than one PDF. */}
<EditorFileSwitcher currentFileId={currentFileId} onPick={onPickFile} />
{openedFileName && (
// The name truncates but the unsaved marker must not, so it sits in
// its own non-shrinking element rather than inside the ellipsis.
<Group gap={6} wrap="nowrap" mb={8} data-testid="pdf-editor-filename">
<Text
size="xs"
c="dimmed"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={openedFileName}
>
{openedFileName}
</Text>
{dirty && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{t("pdfTextEditor.unsaved", "(unsaved)")}
</Text>
)}
</Group>
)}
<Group gap="xs" wrap="nowrap">
<Tooltip
label={t(
"pdfTextEditor.saveTooltip",
"Apply changes to the file in your workspace (Ctrl+S)",
)}
>
<Button
size="sm"
onClick={onSave}
data-testid="pdf-editor-save"
style={{ flex: 1, minWidth: 0 }}
>
{t("pdfTextEditor.save", "Save PDF")}
</Button>
</Tooltip>
<Tooltip
label={t(
"pdfTextEditor.downloadTooltip",
"Save and download the edited PDF",
)}
>
<Button
size="sm"
variant="secondary"
accent="neutral"
onClick={onDownload}
data-testid="pdf-editor-download"
aria-label={t("pdfTextEditor.download", "Download")}
leftSection={<DownloadIcon fontSize="small" />}
/>
</Tooltip>
</Group>
</Box>
);
}
@@ -1,12 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Box, Center, Group, Stack, Tabs, Text, Tooltip } from "@mantine/core";
import { Box, Center, Group, Stack, Tabs, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import HighlightAltIcon from "@mui/icons-material/HighlightAltOutlined";
import TextFieldsIcon from "@mui/icons-material/TextFieldsOutlined";
import ImageIcon from "@mui/icons-material/ImageOutlined";
import SearchIcon from "@mui/icons-material/SearchOutlined";
import HelpIcon from "@mui/icons-material/HelpOutlineOutlined";
import { Button } from "@app/ui/Button";
import { useToolbarController } from "@app/tools/pdfTextEditor/hooks/useToolbarController";
import { useSelectionGeometry } from "@app/tools/pdfTextEditor/hooks/useSelectionGeometry";
import { DocumentInspector } from "@app/tools/pdfTextEditor/components/inspector/DocumentInspector";
@@ -34,23 +29,18 @@ interface SidebarProps {
onSetGroupingMode: (mode: GroupingMode) => void;
onSetWidthMode: (mode: WidthMode) => void;
onSetShowRulers: (show: boolean) => void;
onOpenFind: () => void;
onShowHelp: () => void;
/** True while the next page click drops a new text box. */
addTextArmed: boolean;
onToggleAddText: () => void;
onPickImage: () => void;
}
type TabId = "selected" | "document";
/**
* The editor's right-hand panel: a properties inspector for the selection.
* The editor's side panel: a properties inspector, and nothing else.
*
* Two tabs and one overflow menu. "Selected" only ever shows controls that can
* act on what is picked right now; "Document" holds the facts about the file;
* everything set-and-forget lives behind the menu. Nothing that never changes
* competes for space with the thing the user is actually editing.
* Two tabs. "Selected" only ever shows what can act on what is picked right
* now; "Document" holds the facts about the file and its reading settings.
* The verbs - insert, find, save, help - moved to the canvas top bar, where a
* document editor's toolbar has always been; a panel that carried them made the
* user cross the screen for the two things they do most.
*/
export function EditorSidebar({
store,
@@ -63,11 +53,6 @@ export function EditorSidebar({
onSetGroupingMode,
onSetWidthMode,
onSetShowRulers,
onOpenFind,
onShowHelp,
addTextArmed,
onToggleAddText,
onPickImage,
}: SidebarProps) {
const { t } = useTranslation();
const controller = useToolbarController(store, state, selection);
@@ -121,70 +106,6 @@ export function EditorSidebar({
{t("pdfTextEditor.inspector.tabDocument", "Document")}
</Tabs.Tab>
</Tabs.List>
<Tooltip label={t("pdfTextEditor.settings.find", "Find (Ctrl+F)")}>
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={onOpenFind}
aria-label={t("pdfTextEditor.settings.find", "Find in document")}
data-testid="pdf-editor-open-find"
leftSection={<SearchIcon fontSize="small" />}
/>
</Tooltip>
<Tooltip
label={t("pdfTextEditor.help.tooltip", "Keyboard shortcuts (?)")}
>
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={onShowHelp}
aria-label={t("pdfTextEditor.help.ariaLabel", "Keyboard shortcuts")}
data-testid="pdf-editor-help"
leftSection={<HelpIcon fontSize="small" />}
/>
</Tooltip>
</Group>
{/* Insert applies in either tab and to no selection in particular, so it
rides with the sticky header rather than inside a panel. */}
<Group
gap="xs"
grow
wrap="nowrap"
px="md"
py="xs"
style={{
position: "sticky",
top: 37,
zIndex: 2,
background: "var(--c-bg-raised)",
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Button
size="sm"
variant={addTextArmed ? "primary" : "secondary"}
accent={addTextArmed ? "default" : "neutral"}
leftSection={<TextFieldsIcon fontSize="small" />}
onClick={onToggleAddText}
data-testid="pdf-editor-add-text"
>
{addTextArmed
? t("pdfTextEditor.sidebar.clickPageToAddText", "Click page")
: t("pdfTextEditor.sidebar.addText", "Add text")}
</Button>
<Button
size="sm"
variant="secondary"
accent="neutral"
leftSection={<ImageIcon fontSize="small" />}
onClick={onPickImage}
data-testid="pdf-editor-add-image"
>
{t("pdfTextEditor.sidebar.addImage", "Add image")}
</Button>
</Group>
<Box>
@@ -0,0 +1,87 @@
/* The editor's top bar: a fixed identity block on the left, a scrolling band of
controls in the middle, and the finishing actions pinned right. */
.pdf-editor-topbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-bottom: 0.0625rem solid var(--c-border-subtle);
background: var(--c-bg-raised);
/* The bar itself never scrolls: the Save cluster has to stay reachable no
matter how many contextual controls the selection adds. */
overflow: hidden;
flex: 0 0 auto;
/* Queried by width below. The canvas can be narrow while the WINDOW is wide
(the tool panel takes its share), so a viewport media query would keep
labels the bar has no room for. */
container-type: inline-size;
container-name: pdfeditortoolbar;
}
.pdf-editor-topbar__lead,
.pdf-editor-topbar__trail {
display: flex;
align-items: center;
gap: 0.25rem;
flex: 0 0 auto;
}
/* Only the middle band scrolls, and only when the contextual groups overflow. */
.pdf-editor-topbar__band {
display: flex;
align-items: center;
gap: 0.25rem;
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
/* Room for the thin scrollbar so it never sits on top of the buttons. */
padding-bottom: 0.125rem;
}
.pdf-editor-topbar__file {
display: flex;
align-items: center;
gap: 0.375rem;
min-width: 0;
max-width: 15rem;
}
.pdf-editor-topbar__filename {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8125rem;
font-weight: 500;
}
/* Unsaved marker: a dot, not the word, so it never squeezes the name. */
.pdf-editor-topbar__dirty {
flex: 0 0 auto;
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: var(--c-warning);
}
/* Squeezed: drop the words the icons already say, in order of expendability -
the file name (the tooltip still has it), then the Save label. */
@container pdfeditortoolbar (max-width: 62rem) {
.pdf-editor-topbar__filename {
display: none;
}
}
@container pdfeditortoolbar (max-width: 52rem) {
.pdf-editor-topbar__save-label {
display: none;
}
}
@media (max-width: 64rem) {
.pdf-editor-topbar__file {
max-width: 8rem;
}
}
@@ -0,0 +1,321 @@
import { useRef } from "react";
import { Group, Menu, Popover, Text, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import TextFieldsIcon from "@mui/icons-material/TextFieldsOutlined";
import ImageIcon from "@mui/icons-material/ImageOutlined";
import SearchIcon from "@mui/icons-material/SearchOutlined";
import HelpIcon from "@mui/icons-material/HelpOutlineOutlined";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import TextFormatIcon from "@mui/icons-material/TextFormat";
import { Button } from "@app/ui/Button";
import { EditorFileSwitcher } from "@app/tools/pdfTextEditor/components/EditorFileSwitcher";
import { FormatGroup } from "@app/tools/pdfTextEditor/components/toolbar/FormatGroup";
import { ObjectGroup } from "@app/tools/pdfTextEditor/components/toolbar/ObjectGroup";
import {
NO_SHRINK,
ToolbarSeparator,
type Controller,
} from "@app/tools/pdfTextEditor/components/toolbar/toolbarShared";
import { useEditorSession } from "@app/tools/pdfTextEditor/store/EditorSession";
import { useElementWidth } from "@app/tools/pdfTextEditor/hooks/useElementWidth";
import "@app/tools/pdfTextEditor/components/EditorTopBar.css";
// Below this the insert/find/help controls stop being buttons and become one
// menu. Chosen so the contextual formatting group - the reason a selection is
// made at all - still gets room beside them rather than scrolling out of view.
const COMPACT_BELOW_PX = 900;
interface EditorTopBarProps {
controller: Controller;
/** True while the next page click drops a new text box. */
addTextArmed: boolean;
onToggleAddText: () => void;
/** True while the find bar is showing, so the button can read as pressed. */
findOpen: boolean;
onToggleFind: () => void;
onShowHelp: () => void;
/** False before a document is open: only the identity block makes sense. */
hasDocument: boolean;
/** Unsaved-changes marker for the file chip. */
dirty: boolean;
}
/**
* Everything you do TO the document, in the place editors have always put it.
*
* The side panel used to carry the file you are editing, Insert, Find, help and
* Save, which meant the two most common verbs in the tool lived at the bottom
* of a scrolling panel on the far side of the screen from the page. They read
* left to right the way a document editor's toolbar does: what file this is,
* undo/redo, what you can add, what you can find, then the selection's own
* formatting.
*
* Saving is deliberately NOT here. Every other tool pins its primary action to
* the bottom of the side panel, and one tool answering "where is the button"
* differently from the rest is worse than the extra travel - see
* EditorPanelActions.
*
* Narrow bars fold insert, find and help into one overflow menu rather than
* scrolling them out of sight - see COMPACT_BELOW_PX.
*/
export function EditorTopBar({
controller,
addTextArmed,
onToggleAddText,
findOpen,
onToggleFind,
onShowHelp,
hasDocument,
dirty,
}: EditorTopBarProps) {
const { t } = useTranslation();
const session = useEditorSession();
const hasSelection = controller.selectionCount > 0;
const barRef = useRef<HTMLDivElement | null>(null);
const barWidth = useElementWidth(barRef);
// null until the first measurement: start roomy so the bar does not flash
// through its compact form on mount.
const compact = barWidth !== null && barWidth < COMPACT_BELOW_PX;
const addTextLabel = addTextArmed
? t("pdfTextEditor.sidebar.clickPageToAddText", "Click page to add text")
: t("pdfTextEditor.sidebar.addText", "Add text");
const addImageLabel = t("pdfTextEditor.sidebar.addImage", "Add image");
const findLabel = t("pdfTextEditor.settings.find", "Find in document");
const helpLabel = t("pdfTextEditor.help.ariaLabel", "Keyboard shortcuts");
return (
<div
className="pdf-editor-topbar"
data-testid="pdf-editor-toolbar"
data-compact={compact ? "true" : "false"}
ref={barRef}
>
<div className="pdf-editor-topbar__lead">
{session?.fileName ? (
<EditorFileSwitcher
currentFileId={session.fileId}
currentFileName={session.fileName}
dirty={dirty}
onPick={session.pickFile}
/>
) : (
<Text size="xs" c="dimmed" px={6}>
{t("pdfTextEditor.sidebar.noFile", "No file loaded")}
</Text>
)}
</div>
{hasDocument && (
<div className="pdf-editor-topbar__band">
<Tooltip
label={t("pdfTextEditor.toolbar.undoTooltip", "Undo (Ctrl+Z)")}
>
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={controller.onUndo}
disabled={!controller.canUndo}
aria-label={t("pdfTextEditor.toolbar.undo", "Undo")}
data-testid="pdf-editor-undo"
style={NO_SHRINK}
leftSection={<UndoIcon fontSize="small" />}
/>
</Tooltip>
<Tooltip
label={t("pdfTextEditor.toolbar.redoTooltip", "Redo (Ctrl+Y)")}
>
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={controller.onRedo}
disabled={!controller.canRedo}
aria-label={t("pdfTextEditor.toolbar.redo", "Redo")}
data-testid="pdf-editor-redo"
style={NO_SHRINK}
leftSection={<RedoIcon fontSize="small" />}
/>
</Tooltip>
<ToolbarSeparator />
{compact ? (
/* One menu instead of three buttons. The formatting group that
appears with a selection is what actually needs the room. */
<Menu shadow="md" position="bottom-start" withinPortal>
<Menu.Target>
<Button
size="sm"
variant="tertiary"
accent="neutral"
aria-label={t("pdfTextEditor.toolbar.more", "More actions")}
data-testid="pdf-editor-overflow-menu"
style={NO_SHRINK}
leftSection={<MoreVertIcon fontSize="small" />}
/>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<TextFieldsIcon fontSize="small" />}
onClick={onToggleAddText}
data-testid="pdf-editor-add-text"
>
{addTextLabel}
</Menu.Item>
<Menu.Item
leftSection={<ImageIcon fontSize="small" />}
onClick={() => session?.pickImage()}
disabled={!session}
data-testid="pdf-editor-add-image"
>
{addImageLabel}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<SearchIcon fontSize="small" />}
onClick={onToggleFind}
data-testid="pdf-editor-open-find"
>
{findLabel}
</Menu.Item>
<Menu.Item
leftSection={<HelpIcon fontSize="small" />}
onClick={onShowHelp}
data-testid="pdf-editor-help"
>
{helpLabel}
</Menu.Item>
</Menu.Dropdown>
</Menu>
) : (
<>
{/* Add text keeps its label: it is the tool's whole point, and an
unlabelled "T" is a guess. Add image rides beside it as an icon. */}
<Tooltip
label={t(
"pdfTextEditor.toolbar.addTextTooltip",
"Add a text box - then click the page",
)}
>
<Button
size="sm"
variant={addTextArmed ? "primary" : "tertiary"}
accent={addTextArmed ? "default" : "neutral"}
leftSection={<TextFieldsIcon fontSize="small" />}
onClick={onToggleAddText}
data-testid="pdf-editor-add-text"
style={NO_SHRINK}
>
{addTextArmed
? t(
"pdfTextEditor.sidebar.clickPageToAddText",
"Click page to add text",
)
: t("pdfTextEditor.sidebar.addText", "Add text")}
</Button>
</Tooltip>
<Tooltip label={t("pdfTextEditor.sidebar.addImage", "Add image")}>
<Button
size="sm"
variant="tertiary"
accent="neutral"
leftSection={<ImageIcon fontSize="small" />}
onClick={() => session?.pickImage()}
disabled={!session}
aria-label={t("pdfTextEditor.sidebar.addImage", "Add image")}
data-testid="pdf-editor-add-image"
style={NO_SHRINK}
/>
</Tooltip>
<Tooltip
label={t("pdfTextEditor.settings.findTooltip", "Find (Ctrl+F)")}
>
<Button
// Pressed while the bar is showing, so it reads as the toggle it
// is - clicking it again puts the find bar away.
variant={findOpen ? "primary" : "tertiary"}
accent={findOpen ? "default" : "neutral"}
size="sm"
aria-pressed={findOpen}
onClick={onToggleFind}
aria-label={t(
"pdfTextEditor.settings.find",
"Find in document",
)}
data-testid="pdf-editor-open-find"
style={NO_SHRINK}
leftSection={<SearchIcon fontSize="small" />}
/>
</Tooltip>
</>
)}
{hasSelection && (
<>
<ToolbarSeparator />
{compact ? (
/* Font family, size, colour, outline, italic and case are the
widest thing on the bar by far, and they only appear once
something is selected - which is exactly when a narrow bar
ran out of room and started dropping controls off the end.
Behind one button they cost 40px instead of 500. */
<Popover position="bottom-start" withinPortal shadow="md">
<Popover.Target>
<Button
size="sm"
variant="tertiary"
accent="neutral"
aria-label={t(
"pdfTextEditor.toolbar.formatting",
"Text formatting",
)}
data-testid="pdf-editor-format-menu"
style={NO_SHRINK}
leftSection={<TextFormatIcon fontSize="small" />}
/>
</Popover.Target>
<Popover.Dropdown>
<Group gap="xs" wrap="wrap" maw={320}>
<FormatGroup controller={controller} />
</Group>
</Popover.Dropdown>
</Popover>
) : (
<FormatGroup controller={controller} />
)}
<ToolbarSeparator />
{/* ObjectGroup stays inline either way: two icons and a menu. */}
<ObjectGroup controller={controller} />
</>
)}
</div>
)}
<div className="pdf-editor-topbar__trail">
{hasDocument && !compact && (
<Tooltip
label={t("pdfTextEditor.help.tooltip", "Keyboard shortcuts (?)")}
>
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={onShowHelp}
aria-label={t(
"pdfTextEditor.help.ariaLabel",
"Keyboard shortcuts",
)}
data-testid="pdf-editor-help"
style={NO_SHRINK}
leftSection={<HelpIcon fontSize="small" />}
/>
</Tooltip>
)}
</div>
</div>
);
}
@@ -0,0 +1,100 @@
/* Find & replace, laid out the way a code editor's is: a docked strip under
the toolbar, two rows, and hairlines that fence it off from the page. */
.pdf-editor-findbar {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.4375rem 0.625rem;
background: var(--c-bg-raised);
/* Both edges drawn: without the bottom rule the bar reads as part of the
page it is searching. The rule spans the full width; the controls do not. */
border-bottom: 0.0625rem solid var(--c-border-subtle);
flex: 0 0 auto;
}
/* One grid for BOTH rows, so the find and replace fields end at the same x
whatever the buttons beside them are called. Two flex rows could only agree
by hardcoding a width, which the first translation longer than "Replace all"
would break. */
.pdf-editor-findbar__grid {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
column-gap: 0.25rem;
row-gap: 0.3125rem;
/* Capped, not full-bleed: a search box stretched across a 1600px canvas
stops reading as a control. */
max-width: 44rem;
}
/* The toggles + match count that live inside the find field. */
.pdf-editor-findbar__inline {
display: flex;
align-items: center;
gap: 0.125rem;
padding-right: 0.25rem;
}
.pdf-editor-findbar__count {
font-size: 0.6875rem;
color: var(--c-text-muted);
white-space: nowrap;
padding-right: 0.25rem;
font-variant-numeric: tabular-nums;
}
/* Square, quiet toggles - "Aa", "ab", "a" - pressed state filled. */
.pdf-editor-findbar__toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
padding: 0;
border: 0;
border-radius: 0.25rem;
background: transparent;
color: var(--c-text-muted);
font-size: 0.6875rem;
font-weight: 600;
line-height: 1;
cursor: pointer;
}
.pdf-editor-findbar__toggle--underlined {
text-decoration: underline;
text-underline-offset: 0.125rem;
}
.pdf-editor-findbar__toggle:hover {
background: var(--c-hover);
color: var(--c-text);
}
.pdf-editor-findbar__toggle[aria-pressed="true"] {
background: var(--c-primary);
color: var(--c-text-on-primary);
}
/* Vertical hairline between each field and its buttons. */
.pdf-editor-findbar__divider {
width: 0.0625rem;
align-self: stretch;
margin: 0.125rem 0.1875rem;
background: var(--c-border-strong);
}
.pdf-editor-findbar__actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Pinned to the bar's own right edge, clear of the capped control grid. */
.pdf-editor-findbar__close {
flex: 0 0 auto;
margin-left: auto;
}
@@ -1,8 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Group, Stack, Text, TextInput, Tooltip } from "@mantine/core";
import { TextInput, Tooltip } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import CloseIcon from "@mui/icons-material/Close";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import "@app/tools/pdfTextEditor/components/FindBar.css";
import { EditTextCommand } from "@app/tools/pdfTextEditor/commands/EditTextCommand";
import { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand";
import {
@@ -194,158 +197,212 @@ export function FindBar({ store, pages, onClose }: FindBarProps) {
setReplaceCount(n);
}, [query, replace, matches, store]);
const countLabel =
matches.length === 0
? query
? t("pdfTextEditor.find.noMatches", "No matches")
: ""
: t("pdfTextEditor.find.count", "{{current}} of {{total}}", {
current: activeIndex + 1,
total: matches.length,
});
// "Aa" and "ab" would read as the same two letters at 11px, so whole-word
// borrows the underline every code editor draws under its own version.
const toggles: Array<{
on: boolean;
set: () => void;
label: string;
glyph: string;
underline?: boolean;
testId: string;
}> = [
{
on: matchCase,
set: () => setMatchCase((v) => !v),
label: t("pdfTextEditor.find.matchCase", "Match case"),
glyph: "Aa",
testId: "pdf-editor-find-match-case",
},
{
on: wholeWord,
set: () => setWholeWord((v) => !v),
label: t("pdfTextEditor.find.wholeWord", "Whole word"),
glyph: "ab",
underline: true,
testId: "pdf-editor-find-whole-word",
},
{
on: ignoreAccents,
set: () => setIgnoreAccents((v) => !v),
label: t("pdfTextEditor.find.ignoreAccents", "Ignore accents"),
glyph: "\u00e1",
testId: "pdf-editor-find-ignore-accents",
},
];
return (
<Stack gap="xs" p="sm" data-testid="pdf-editor-find-bar">
<Group justify="space-between">
<Text size="sm" fw={500}>
{t("pdfTextEditor.find.title", "Find & replace")}
</Text>
<div className="pdf-editor-findbar" data-testid="pdf-editor-find-bar">
<div className="pdf-editor-findbar__grid">
<TextInput
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
placeholder={t("pdfTextEditor.find.findPlaceholder", "Find")}
aria-label={t("pdfTextEditor.find.findPlaceholder", "Find")}
data-testid="pdf-editor-find-input"
size="xs"
// The count and the toggles live INSIDE the field, the way every code
// editor puts them - the row stays one line however many are added.
rightSectionWidth={132}
// Mantine makes a section inert by default so clicks reach the input.
// These are real buttons, so they have to take their own clicks.
rightSectionPointerEvents="all"
rightSection={
<span className="pdf-editor-findbar__inline">
<span
className="pdf-editor-findbar__count"
data-testid="pdf-editor-find-count"
role="status"
aria-live="polite"
aria-atomic="true"
>
{countLabel}
</span>
{toggles.map((toggle) => (
<Tooltip key={toggle.testId} label={toggle.label}>
<button
type="button"
className={
toggle.underline
? "pdf-editor-findbar__toggle pdf-editor-findbar__toggle--underlined"
: "pdf-editor-findbar__toggle"
}
aria-pressed={toggle.on}
aria-label={toggle.label}
data-testid={toggle.testId}
onClick={toggle.set}
>
{toggle.glyph}
</button>
</Tooltip>
))}
</span>
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) prev();
else next();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}}
/>
<span className="pdf-editor-findbar__divider" aria-hidden />
<div className="pdf-editor-findbar__actions">
<Tooltip label={t("pdfTextEditor.find.previous", "Previous match")}>
<Button
size="sm"
variant="tertiary"
accent="neutral"
onClick={prev}
disabled={matches.length === 0}
aria-label={t("pdfTextEditor.find.previous", "Previous match")}
data-testid="pdf-editor-find-prev"
leftSection={<KeyboardArrowUpIcon fontSize="small" />}
/>
</Tooltip>
<Tooltip label={t("pdfTextEditor.find.next", "Next match")}>
<Button
size="sm"
variant="tertiary"
accent="neutral"
onClick={next}
disabled={matches.length === 0}
aria-label={t("pdfTextEditor.find.next", "Next match")}
data-testid="pdf-editor-find-next"
leftSection={<KeyboardArrowDownIcon fontSize="small" />}
/>
</Tooltip>
</div>
<TextInput
value={replace}
onChange={(e) => setReplace(e.currentTarget.value)}
placeholder={t(
"pdfTextEditor.find.replacePlaceholder",
"Replace with",
)}
aria-label={t(
"pdfTextEditor.find.replacePlaceholder",
"Replace with",
)}
data-testid="pdf-editor-replace-input"
size="xs"
rightSectionWidth={132}
rightSection={
replaceCount !== null ? (
<span className="pdf-editor-findbar__inline">
<span className="pdf-editor-findbar__count">
{t("pdfTextEditor.find.replaced", "{{count}} replaced", {
count: replaceCount,
})}
</span>
</span>
) : null
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) doReplaceAll();
else doReplaceOne();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}}
/>
<span className="pdf-editor-findbar__divider" aria-hidden />
<div className="pdf-editor-findbar__actions">
<Button
size="sm"
variant="tertiary"
accent="neutral"
onClick={doReplaceOne}
disabled={matches.length === 0 || !query}
data-testid="pdf-editor-replace-one"
>
{t("pdfTextEditor.find.replace", "Replace")}
</Button>
<Button
size="sm"
variant="tertiary"
accent="neutral"
onClick={doReplaceAll}
disabled={matches.length === 0 || !query}
data-testid="pdf-editor-replace-all"
>
{t("pdfTextEditor.find.replaceAll", "Replace all")}
</Button>
</div>
</div>
{/* Dismiss sits at the far corner of the bar, not beside the match
arrows: next to them it read as a third way to navigate. */}
<Tooltip label={t("pdfTextEditor.find.close", "Close find bar")}>
<Button
variant="tertiary"
accent="neutral"
size="sm"
className="pdf-editor-findbar__close"
onClick={onClose}
aria-label={t("pdfTextEditor.find.close", "Close find bar")}
data-testid="pdf-editor-find-close"
leftSection={<CloseIcon fontSize="small" />}
/>
</Group>
<TextInput
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
placeholder={t("pdfTextEditor.find.findPlaceholder", "Find")}
data-testid="pdf-editor-find-input"
size="xs"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) prev();
else next();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}}
/>
<TextInput
value={replace}
onChange={(e) => setReplace(e.currentTarget.value)}
placeholder={t("pdfTextEditor.find.replacePlaceholder", "Replace with")}
data-testid="pdf-editor-replace-input"
size="xs"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) doReplaceAll();
else doReplaceOne();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}}
/>
<Group gap="xs" align="center">
<Tooltip label={t("pdfTextEditor.find.matchCase", "Match case")}>
<Button
size="sm"
variant={matchCase ? "primary" : "tertiary"}
onClick={() => setMatchCase((v) => !v)}
aria-pressed={matchCase}
aria-label={t("pdfTextEditor.find.matchCase", "Match case")}
data-testid="pdf-editor-find-match-case"
>
Aa
</Button>
</Tooltip>
<Tooltip label={t("pdfTextEditor.find.wholeWord", "Whole word")}>
<Button
size="sm"
variant={wholeWord ? "primary" : "tertiary"}
onClick={() => setWholeWord((v) => !v)}
aria-pressed={wholeWord}
aria-label={t("pdfTextEditor.find.wholeWord", "Whole word")}
data-testid="pdf-editor-find-whole-word"
>
[ab]
</Button>
</Tooltip>
<Tooltip
label={t("pdfTextEditor.find.ignoreAccents", "Ignore accents")}
>
<Button
size="sm"
variant={ignoreAccents ? "primary" : "tertiary"}
onClick={() => setIgnoreAccents((v) => !v)}
aria-pressed={ignoreAccents}
aria-label={t("pdfTextEditor.find.ignoreAccents", "Ignore accents")}
data-testid="pdf-editor-find-ignore-accents"
>
á=a
</Button>
</Tooltip>
</Group>
<Group gap="xs" align="center">
<Text
size="xs"
c="dimmed"
data-testid="pdf-editor-find-count"
role="status"
aria-live="polite"
aria-atomic="true"
>
{matches.length === 0
? query
? t("pdfTextEditor.find.noMatches", "No matches")
: t("pdfTextEditor.find.typeToSearch", "Type to search")
: t("pdfTextEditor.find.count", "{{current}} of {{total}}", {
current: activeIndex + 1,
total: matches.length,
})}
{replaceCount !== null
? t("pdfTextEditor.find.replaced", " · {{count}} replaced", {
count: replaceCount,
})
: ""}
</Text>
<Button
size="sm"
variant="tertiary"
onClick={prev}
disabled={matches.length === 0}
aria-label={t("pdfTextEditor.find.previous", "Previous match")}
data-testid="pdf-editor-find-prev"
>
</Button>
<Button
size="sm"
variant="tertiary"
onClick={next}
disabled={matches.length === 0}
aria-label={t("pdfTextEditor.find.next", "Next match")}
data-testid="pdf-editor-find-next"
>
</Button>
<Button
size="sm"
variant="tertiary"
onClick={doReplaceOne}
disabled={matches.length === 0 || !query}
data-testid="pdf-editor-replace-one"
>
{t("pdfTextEditor.find.replace", "Replace")}
</Button>
<Button
size="sm"
variant="tertiary"
onClick={doReplaceAll}
disabled={matches.length === 0 || !query}
data-testid="pdf-editor-replace-all"
>
{t("pdfTextEditor.find.replaceAll", "Replace all")}
</Button>
</Group>
</Stack>
</Tooltip>
</div>
);
}
@@ -0,0 +1,44 @@
/* Shortcut reference: one card per section, one row per shortcut, hairlines
between rows so a long list stays scannable. */
.pdf-editor-help__section-title {
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.pdf-editor-help__section-hint {
font-size: 0.8125rem;
color: var(--c-text-muted);
margin: -0.25rem 0 0.5rem;
}
.pdf-editor-help__card {
border: 0.0625rem solid var(--c-border-subtle);
border-radius: 0.5rem;
background: var(--c-surface-raised);
overflow: hidden;
}
.pdf-editor-help__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.625rem 0.875rem;
}
.pdf-editor-help__row + .pdf-editor-help__row {
border-top: 0.0625rem solid var(--c-border-subtle);
}
.pdf-editor-help__label {
font-size: 0.875rem;
min-width: 0;
}
/* The keys column never shrinks - a wrapped description is fine, a wrapped
shortcut is not. */
.pdf-editor-help__keys {
flex: 0 0 auto;
}
@@ -1,287 +1,216 @@
import { Modal, Stack, Text, Group, Kbd, Divider } from "@mantine/core";
import { Modal, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { KeyCombo } from "@app/ui/KeyCombo";
import "@app/tools/pdfTextEditor/components/HelpOverlay.css";
interface HelpOverlayProps {
opened: boolean;
onClose: () => void;
}
/** Keyboard + click reference for the PDF text/image editor. */
interface Shortcut {
/** Keys exactly as pressed - see the spacing rule below. */
keys: string;
description: string;
}
interface Section {
heading: string;
items: Shortcut[];
}
/**
* Keyboard shortcuts for the PDF text/image editor - and only those.
*
* Nothing here describes a toolbar control, a mouse gesture, or what a key does
* inside a field it is already obvious in. A shortcut list people trust is one
* they can scan for a key they did not know about; padding it with "Font family
* - swap to another font" or "Drag - move image" turns it into a feature tour
* and buries the handful of things it exists to tell you.
*
* Every `keys` string is written as separate keys joined by " + ", never as a
* run-on like "Ctrl+Click": KeyCombo splits on that separator to give each key
* its own tile, so the spacing is what keeps the whole list consistent. Where a
* shortcut has alternatives, separate them with " / ".
*/
export function HelpOverlay({ opened, onClose }: HelpOverlayProps) {
const { t } = useTranslation();
const SHORTCUTS: Array<{
heading: string;
items: Array<[string, string]>;
}> = [
const SHORTCUTS: Section[] = [
{
heading: t("pdfTextEditor.help.editing.heading", "Editing"),
items: [
[
t("pdfTextEditor.help.editing.clickKey", "Click"),
t("pdfTextEditor.help.editing.clickDesc", "Edit text"),
],
[
t(
{
keys: t(
"pdfTextEditor.help.editing.shiftClickKey",
"Ctrl+Click / Shift+Click",
"Ctrl + Click / Shift + Click",
),
t(
description: t(
"pdfTextEditor.help.editing.shiftClickDesc",
"Add / remove a run from selection",
),
],
[
t("pdfTextEditor.help.editing.marqueeKey", "Ctrl+Shift+drag"),
t("pdfTextEditor.help.editing.marqueeDesc", "Marquee multi-select"),
],
[
t("pdfTextEditor.help.editing.groupKey", "Ctrl+M"),
t(
},
{
keys: t(
"pdfTextEditor.help.editing.marqueeKey",
"Ctrl + Shift + Drag",
),
description: t(
"pdfTextEditor.help.editing.marqueeDesc",
"Marquee multi-select",
),
},
{
keys: t("pdfTextEditor.help.editing.moveKey", "Ctrl + Click + Drag"),
description: t(
"pdfTextEditor.help.editing.moveDesc",
"Move text run",
),
},
{
keys: t("pdfTextEditor.help.editing.selectAllKey", "Ctrl + A"),
description: t(
"pdfTextEditor.help.editing.selectAllDesc",
"Select all",
),
},
{
keys: t("pdfTextEditor.help.editing.groupKey", "Ctrl + M"),
description: t(
"pdfTextEditor.help.editing.groupDesc",
"Group selected runs (Group button)",
"Group selected runs into one paragraph",
),
],
[
t("pdfTextEditor.help.editing.ungroupKey", "-"),
t(
"pdfTextEditor.help.editing.ungroupDesc",
"Ungroup paragraph: select it, click Ungroup",
},
{
keys: t("pdfTextEditor.help.editing.duplicateKey", "Ctrl + D"),
description: t(
"pdfTextEditor.help.editing.duplicateDesc",
"Duplicate selected",
),
],
[
t("pdfTextEditor.help.editing.moveKey", "Ctrl+Click + drag"),
t("pdfTextEditor.help.editing.moveDesc", "Move text run"),
],
[
t("pdfTextEditor.help.editing.deleteKey", "Delete"),
t("pdfTextEditor.help.editing.deleteDesc", "Remove selected"),
],
[
t("pdfTextEditor.help.editing.duplicateKey", "Ctrl+D"),
t("pdfTextEditor.help.editing.duplicateDesc", "Duplicate selected"),
],
[
t("pdfTextEditor.help.editing.selectAllKey", "Ctrl+A"),
t("pdfTextEditor.help.editing.selectAllDesc", "Select all"),
],
[
t("pdfTextEditor.help.editing.undoRedoKey", "Ctrl+Z / Ctrl+Y"),
t("pdfTextEditor.help.editing.undoRedoDesc", "Undo / Redo"),
],
},
{
keys: t("pdfTextEditor.help.editing.deleteKey", "Delete"),
description: t(
"pdfTextEditor.help.editing.deleteDesc",
"Remove selected",
),
},
{
keys: t(
"pdfTextEditor.help.editing.undoRedoKey",
"Ctrl + Z / Ctrl + Y",
),
description: t(
"pdfTextEditor.help.editing.undoRedoDesc",
"Undo / Redo",
),
},
],
},
{
heading: t("pdfTextEditor.help.clipboard.heading", "Clipboard"),
items: [
[
t("pdfTextEditor.help.clipboard.copyKey", "Ctrl+C"),
t("pdfTextEditor.help.clipboard.copyDesc", "Copy selected text"),
],
[
t("pdfTextEditor.help.clipboard.cutKey", "Ctrl+X"),
t(
{
keys: t("pdfTextEditor.help.clipboard.copyKey", "Ctrl + C"),
description: t(
"pdfTextEditor.help.clipboard.copyDesc",
"Copy selected text",
),
},
{
keys: t("pdfTextEditor.help.clipboard.cutKey", "Ctrl + X"),
description: t(
"pdfTextEditor.help.clipboard.cutDesc",
"Cut selected (copy + delete)",
),
],
[
t("pdfTextEditor.help.clipboard.pasteKey", "Ctrl+V"),
t(
},
{
keys: t("pdfTextEditor.help.clipboard.pasteKey", "Ctrl + V"),
description: t(
"pdfTextEditor.help.clipboard.pasteDesc",
"Paste clipboard text as new run",
),
],
[
t("pdfTextEditor.help.clipboard.pastePlainKey", "Ctrl+Shift+V"),
t(
},
{
keys: t(
"pdfTextEditor.help.clipboard.pastePlainKey",
"Ctrl + Shift + V",
),
description: t(
"pdfTextEditor.help.clipboard.pastePlainDesc",
"Paste as plain text",
),
],
},
],
},
{
heading: t("pdfTextEditor.help.find.heading", "Find & Replace"),
heading: t("pdfTextEditor.help.find.heading", "Find & replace"),
items: [
[
t("pdfTextEditor.help.find.openKey", "Ctrl+F"),
t("pdfTextEditor.help.find.openDesc", "Open find bar (and replace)"),
],
[
t("pdfTextEditor.help.find.nextKey", "F3 / Ctrl+G"),
t(
{
keys: t("pdfTextEditor.help.find.openKey", "Ctrl + F"),
description: t(
"pdfTextEditor.help.find.openDesc",
"Open find bar (and replace)",
),
},
{
keys: t("pdfTextEditor.help.find.nextKey", "F3 / Ctrl + G"),
description: t(
"pdfTextEditor.help.find.nextDesc",
"Next match (Shift = previous)",
"Next match (add Shift for previous)",
),
],
[
t("pdfTextEditor.help.find.enterFindKey", "Enter (in find)"),
t("pdfTextEditor.help.find.enterFindDesc", "Next match"),
],
[
t("pdfTextEditor.help.find.enterReplaceKey", "Enter (in replace)"),
t(
"pdfTextEditor.help.find.enterReplaceDesc",
"Replace one (Shift = Replace All)",
),
],
],
},
{
heading: t(
"pdfTextEditor.help.arrangement.heading",
"Object arrangement",
),
items: [
[
t("pdfTextEditor.help.arrangement.orderKey", "Toolbar ↑ ↓"),
t(
"pdfTextEditor.help.arrangement.orderDesc",
"Bring forward / send backward (one step)",
),
],
[
t(
"pdfTextEditor.help.arrangement.frontBackKey",
"Toolbar front/back",
),
t(
"pdfTextEditor.help.arrangement.frontBackDesc",
"Bring to front / send to back",
),
],
[
t("pdfTextEditor.help.arrangement.alignKey", "Toolbar align"),
t(
"pdfTextEditor.help.arrangement.alignDesc",
"Align edges L / centre / R / T / mid / B",
),
],
[
t(
"pdfTextEditor.help.arrangement.distributeKey",
"Toolbar distribute",
),
t(
"pdfTextEditor.help.arrangement.distributeDesc",
"Equal horizontal / vertical spacing (3+)",
),
],
[
t("pdfTextEditor.help.arrangement.lockKey", "Lock button"),
t(
"pdfTextEditor.help.arrangement.lockDesc",
"Lock / unlock selection (session-only)",
),
],
],
},
{
heading: t("pdfTextEditor.help.image.heading", "Image"),
items: [
[
t("pdfTextEditor.help.image.rotateKey", "Toolbar rotate"),
t(
"pdfTextEditor.help.image.rotateDesc",
"Rotate 90° clockwise or counter-clockwise",
),
],
[
t("pdfTextEditor.help.image.flipKey", "Toolbar flip"),
t(
"pdfTextEditor.help.image.flipDesc",
"Flip horizontally or vertically",
),
],
[
t("pdfTextEditor.help.image.resizeKey", "Corner drag"),
t("pdfTextEditor.help.image.resizeDesc", "Resize image"),
],
[
t("pdfTextEditor.help.image.moveKey", "Drag"),
t("pdfTextEditor.help.image.moveDesc", "Move image"),
],
],
},
{
heading: t("pdfTextEditor.help.formatting.heading", "Text formatting"),
items: [
[
t("pdfTextEditor.help.formatting.italicKey", "Toolbar I"),
t("pdfTextEditor.help.formatting.italicDesc", "Italic"),
],
[
t("pdfTextEditor.help.formatting.fontSizeKey", "Toolbar font size"),
t("pdfTextEditor.help.formatting.fontSizeDesc", "Change font size"),
],
[
t("pdfTextEditor.help.formatting.colourKey", "Toolbar colour"),
t("pdfTextEditor.help.formatting.colourDesc", "Change fill colour"),
],
[
t(
"pdfTextEditor.help.formatting.fontFamilyKey",
"Toolbar font family",
),
t(
"pdfTextEditor.help.formatting.fontFamilyDesc",
"Swap to base-14 font",
),
],
[
t("pdfTextEditor.help.formatting.caseKey", "Toolbar case (Aa)"),
t(
"pdfTextEditor.help.formatting.caseDesc",
"Change case (upper/lower/title/sentence)",
),
],
},
],
},
{
heading: t("pdfTextEditor.help.navigation.heading", "Navigation"),
items: [
[
t("pdfTextEditor.help.navigation.pageKey", "PageDown / PageUp"),
t("pdfTextEditor.help.navigation.pageDesc", "Next / previous page"),
],
[
t(
{
keys: t("pdfTextEditor.help.navigation.pageKey", "PageUp / PageDown"),
description: t(
"pdfTextEditor.help.navigation.pageDesc",
"Previous / next page",
),
},
{
keys: t(
"pdfTextEditor.help.navigation.firstLastKey",
"Ctrl+Home / Ctrl+End",
"Ctrl + Home / Ctrl + End",
),
t("pdfTextEditor.help.navigation.firstLastDesc", "First / last page"),
],
[
t("pdfTextEditor.help.navigation.zoomKey", "Ctrl+Wheel"),
t("pdfTextEditor.help.navigation.zoomDesc", "Zoom in / out"),
],
[
t("pdfTextEditor.help.navigation.toolbarZoomKey", "Toolbar zoom"),
t(
"pdfTextEditor.help.navigation.toolbarZoomDesc",
"Manual zoom + Fit to width",
description: t(
"pdfTextEditor.help.navigation.firstLastDesc",
"First / last page",
),
],
},
{
keys: t("pdfTextEditor.help.navigation.zoomKey", "Ctrl + Wheel"),
description: t(
"pdfTextEditor.help.navigation.zoomDesc",
"Zoom in / out",
),
},
],
},
{
heading: t("pdfTextEditor.help.document.heading", "Document"),
items: [
[
t("pdfTextEditor.help.document.saveKey", "Ctrl+S"),
t("pdfTextEditor.help.document.saveDesc", "Save to your workspace"),
],
[
t("pdfTextEditor.help.document.helpKey", "? / F1"),
t("pdfTextEditor.help.document.helpDesc", "This help"),
],
[
t("pdfTextEditor.help.document.escKey", "Esc"),
t(
{
keys: t("pdfTextEditor.help.document.saveKey", "Ctrl + S"),
description: t(
"pdfTextEditor.help.document.saveDesc",
"Save to your workspace",
),
},
{
keys: t("pdfTextEditor.help.document.helpKey", "? / F1"),
description: t("pdfTextEditor.help.document.helpDesc", "This help"),
},
{
keys: t("pdfTextEditor.help.document.escKey", "Esc"),
description: t(
"pdfTextEditor.help.document.escDesc",
"Clear selection / close find / close help",
),
],
},
],
},
];
@@ -291,29 +220,31 @@ export function HelpOverlay({ opened, onClose }: HelpOverlayProps) {
opened={opened}
onClose={onClose}
title={t("pdfTextEditor.help.title", "Keyboard shortcuts")}
size="md"
size="lg"
data-testid="pdf-editor-help-overlay"
>
<Stack gap="md">
{SHORTCUTS.map((section, sectionIdx) => (
<Stack key={section.heading} gap="xs">
{sectionIdx > 0 && <Divider />}
<Text fw={600} size="sm">
<Stack gap="lg">
{SHORTCUTS.map((section) => (
<section key={section.heading}>
<h3 className="pdf-editor-help__section-title">
{section.heading}
</Text>
{section.items.map(([keys, desc]) => (
<Group
key={`${section.heading}-${keys}`}
justify="space-between"
wrap="nowrap"
>
<Text size="sm" c="dimmed">
{desc}
</Text>
<Kbd>{keys}</Kbd>
</Group>
))}
</Stack>
</h3>
<div className="pdf-editor-help__card">
{section.items.map((item) => (
<div
className="pdf-editor-help__row"
key={`${section.heading}-${item.description}`}
>
<Text className="pdf-editor-help__label">
{item.description}
</Text>
<span className="pdf-editor-help__keys">
<KeyCombo combo={item.keys} />
</span>
</div>
))}
</div>
</section>
))}
</Stack>
</Modal>
@@ -11,7 +11,8 @@ import {
import { useTranslation } from "react-i18next";
import { useEditorStore } from "@app/tools/pdfTextEditor/hooks/useEditorStore";
import { ensurePageRead } from "@app/tools/pdfTextEditor/hooks/useDocumentLoader";
import { Toolbar } from "@app/tools/pdfTextEditor/components/Toolbar";
import { EditorTopBar } from "@app/tools/pdfTextEditor/components/EditorTopBar";
import { FindBar } from "@app/tools/pdfTextEditor/components/FindBar";
import { useToolbarController } from "@app/tools/pdfTextEditor/hooks/useToolbarController";
import { ZoomPill } from "@app/tools/pdfTextEditor/components/ZoomPill";
import { MarqueeSelector } from "@app/tools/pdfTextEditor/components/MarqueeSelector";
@@ -69,12 +70,30 @@ export function PageStage() {
// inspector derives from the same controller, so both surfaces read one
// source of truth.
const controller = useToolbarController(store, state, selection);
const toolbar = { controller };
// One element, rendered in both the empty and loaded branches. Find/help
// open from here, so their panels are rendered here too rather than in the
// side panel the user is not looking at.
const topBar = (
<EditorTopBar
controller={controller}
hasDocument={state.hasDocument}
dirty={state.dirty}
addTextArmed={state.mode === "addText"}
onToggleAddText={() =>
store.setMode(
store.getState().mode === "addText" ? "select" : "addText",
)
}
findOpen={state.findOpen}
onToggleFind={() => store.setFindOpen(!store.getState().findOpen)}
onShowHelp={() => store.setHelpOpen(true)}
/>
);
if (!state.hasDocument && !state.loading) {
return (
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
<Toolbar {...toolbar} />
{topBar}
<Center
style={{ flex: 1, minHeight: 0 }}
data-testid="pdf-editor-stage-empty"
@@ -110,7 +129,14 @@ export function PageStage() {
return (
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
<Toolbar {...toolbar} />
{topBar}
{state.findOpen && state.hasDocument && (
<FindBar
store={store}
pages={state.pages}
onClose={() => store.setFindOpen(false)}
/>
)}
<Box
pos="relative"
ref={stageRootRef}
@@ -142,19 +168,9 @@ export function PageStage() {
(f) => f.type === "application/pdf" || /\.pdf$/i.test(f.name),
);
if (!pdf) return;
// Replacing the open document discards in-progress edits - confirm
// first when dirty, so an accidental drop can't silently lose work.
if (
store.getState().dirty &&
!window.confirm(
t(
"pdfTextEditor.confirmReplaceDirty",
"You have unsaved changes. Replace the open document and discard them?",
),
)
) {
return;
}
// Replacing the open document discards in-progress edits. The panel
// owns that confirmation (DiscardChangesModal) for every route in,
// so the drop just hands the file over.
const input = document.querySelector<HTMLInputElement>(
'[data-testid="pdf-editor-file-input"]',
);
@@ -1,578 +0,0 @@
import { useState } from "react";
import {
ColorInput,
Group,
Menu,
NumberInput,
Popover,
Text,
Tooltip,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import FormatItalicIcon from "@mui/icons-material/FormatItalic";
import TuneIcon from "@mui/icons-material/TuneOutlined";
import LockIcon from "@mui/icons-material/LockOutlined";
import LockOpenIcon from "@mui/icons-material/LockOpenOutlined";
import TextFieldsIcon from "@mui/icons-material/TextFields";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import LayersIcon from "@mui/icons-material/LayersOutlined";
import FlipToFrontIcon from "@mui/icons-material/FlipToFrontOutlined";
import FlipToBackIcon from "@mui/icons-material/FlipToBackOutlined";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import VerticalAlignTopIcon from "@mui/icons-material/VerticalAlignTop";
import VerticalAlignBottomIcon from "@mui/icons-material/VerticalAlignBottom";
import VerticalAlignCenterIcon from "@mui/icons-material/VerticalAlignCenter";
import AlignHorizontalLeftIcon from "@mui/icons-material/AlignHorizontalLeftOutlined";
import AlignHorizontalCenterIcon from "@mui/icons-material/AlignHorizontalCenterOutlined";
import AlignHorizontalRightIcon from "@mui/icons-material/AlignHorizontalRightOutlined";
import LinearScaleIcon from "@mui/icons-material/LinearScaleOutlined";
import { useTranslation } from "react-i18next";
import { parseCssColor, toCssHex } from "@app/tools/pdfTextEditor/model/Color";
import { familyOf } from "@app/tools/pdfTextEditor/util/fontFamily";
import { FontFamilySelect } from "@app/tools/pdfTextEditor/components/FontFamilySelect";
import type { useToolbarController } from "@app/tools/pdfTextEditor/hooks/useToolbarController";
type Controller = ReturnType<typeof useToolbarController>;
/**
* The canvas toolbar: undo/redo, plus formatting for the current selection.
*
* Character formatting sits here rather than in the side panel because that is
* where every document editor puts it. The group is *contextual* - it appears
* with a selection instead of standing permanently greyed - which is what
* keeps the strip to a single row.
*/
interface ToolbarProps {
controller: Controller;
}
function ToolbarSeparator() {
return (
<Text size="sm" c="dimmed" aria-hidden style={NO_SHRINK}>
|
</Text>
);
}
/** Toolbar children keep their natural width; the strip scrolls if pressed. */
const NO_SHRINK = { flexShrink: 0 } as const;
export function Toolbar({ controller }: ToolbarProps) {
const { t } = useTranslation();
const hasSelection = controller.selectionCount > 0;
return (
<Group
gap="xs"
px="md"
py="xs"
wrap="nowrap"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
background: "var(--mantine-color-body)",
// Scrolls rather than wraps: a second row was the old strip's failure
// mode, and a wrapped toolbar hides controls without saying so.
overflowX: "auto",
scrollbarWidth: "thin",
}}
data-testid="pdf-editor-toolbar"
>
<Tooltip label={t("pdfTextEditor.toolbar.undoTooltip", "Undo (Ctrl+Z)")}>
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={controller.onUndo}
disabled={!controller.canUndo}
aria-label={t("pdfTextEditor.toolbar.undo", "Undo")}
data-testid="pdf-editor-undo"
style={NO_SHRINK}
leftSection={<UndoIcon fontSize="small" />}
/>
</Tooltip>
<Tooltip label={t("pdfTextEditor.toolbar.redoTooltip", "Redo (Ctrl+Y)")}>
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={controller.onRedo}
disabled={!controller.canRedo}
aria-label={t("pdfTextEditor.toolbar.redo", "Redo")}
data-testid="pdf-editor-redo"
style={NO_SHRINK}
leftSection={<RedoIcon fontSize="small" />}
/>
</Tooltip>
{hasSelection && (
<>
<ToolbarSeparator />
<FormatGroup controller={controller} />
<ToolbarSeparator />
<ObjectGroup controller={controller} />
</>
)}
</Group>
);
}
/** Character formatting. Text runs only - absent for a pure image selection. */
function FormatGroup({ controller }: { controller: Controller }) {
const { t } = useTranslation();
const {
state,
hasRunSelection,
onChangeFontFamily,
onChangeFontSize,
onChangeFill,
onChangeOutline,
onToggleItalic,
onChangeCase,
} = controller;
// Mantine only closes the fill picker's dropdown on blur, and it is
// portalled over the page - so we drive it and close it on a commit.
const [fillPickerOpen, setFillPickerOpen] = useState(false);
if (!hasRunSelection) return null;
const fillHex = state.fill ? toCssHex(state.fill) : "#000000";
const outlineHex = state.stroke ? toCssHex(state.stroke) : "#000000";
const outlineWidth = state.strokeWidth ?? 0;
const fontFamily = state.fontFamily ? familyOf(state.fontFamily) : null;
return (
<>
<FontFamilySelect
value={fontFamily}
onChange={onChangeFontFamily}
mixed={state.mixed.fontFamily}
/>
<NumberInput
size="xs"
w={76}
min={4}
max={144}
// A PDF font size is a float, so 11pt arrives as 11.000000002 and
// rendered in full. One decimal is all a type size ever needs.
decimalScale={1}
// Blank rather than a made-up number when the runs disagree, but still
// editable: typing a size is how you make a mixed selection uniform.
value={
state.mixed.fontSize
? ""
: Math.round((state.fontSize ?? 12) * 10) / 10
}
placeholder={
state.mixed.fontSize
? t("pdfTextEditor.fontPicker.mixed", "Mixed")
: undefined
}
onChange={(value) => {
const next = typeof value === "number" ? value : Number(value);
if (Number.isFinite(next) && next > 0) onChangeFontSize(next);
}}
aria-label={t("pdfTextEditor.toolbar.fontSize", "Font size")}
data-testid="pdf-editor-font-size"
style={NO_SHRINK}
/>
<ColorInput
size="xs"
w={fillPickerOpen ? 116 : 74}
withEyeDropper={false}
styles={{
// The swatch is decoration, not a target: left interactive it covers
// the whole of a narrow input and swallows the click that opens the
// picker. Clicks fall through to the input, which owns the gesture.
section: { pointerEvents: "none" },
input: {
color: fillPickerOpen ? undefined : "transparent",
transition: "width 120ms ease-out",
},
}}
value={fillHex}
// Blur re-emits the last valid colour, which re-applied the fill AFTER
// an undo. The input is controlled, so there is nothing to fix up.
fixOnBlur={false}
onChange={(next) => {
if (!next) return;
const rgb = parseCssColor(next);
if (!rgb) return;
// Re-emitting the applied colour must not cost a second undo step.
// Mixed fills (state.fill null) still apply - that unifies them.
if (
state.fill &&
rgb.r === state.fill.r &&
rgb.g === state.fill.g &&
rgb.b === state.fill.b
) {
return;
}
onChangeFill(next);
}}
// Drag end, swatch click or a committed hex - the deliberate pick.
// Deferred a frame: a saturation-square pick delivers its value on the
// NEXT animation frame (use-move), so closing now unmounts the picker
// first and Firefox loses the pick against a detached 0x0 node.
onChangeEnd={() =>
window.requestAnimationFrame(() => setFillPickerOpen(false))
}
onFocus={() => setFillPickerOpen(true)}
onClick={() => setFillPickerOpen(true)}
onBlur={() => setFillPickerOpen(false)}
onKeyDown={(event) => {
// The dropdown's own Escape handler never sees the key - focus stays
// in the input - so dismiss it here.
if (event.key === "Escape" || event.key === "Enter") {
setFillPickerOpen(false);
}
}}
popoverProps={{
opened: fillPickerOpen,
onChange: setFillPickerOpen,
transitionProps: { transition: "fade", duration: 0 },
}}
aria-label={t("pdfTextEditor.toolbar.fontColour", "Font colour")}
data-testid="pdf-editor-colour"
style={NO_SHRINK}
/>
<Popover position="bottom-start" withinPortal shadow="md">
<Popover.Target>
<Tooltip
label={t(
"pdfTextEditor.toolbar.advancedColourTooltip",
"Advanced colour (glyph outline)",
)}
>
<Button
variant={outlineWidth > 0 ? "primary" : "tertiary"}
accent={outlineWidth > 0 ? "default" : "neutral"}
size="sm"
aria-label={t(
"pdfTextEditor.toolbar.advancedColour",
"Advanced colour",
)}
data-testid="pdf-editor-colour-advanced"
style={NO_SHRINK}
leftSection={<TuneIcon fontSize="small" />}
/>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Group gap="xs" align="center" wrap="nowrap">
<ColorInput
size="xs"
w={132}
value={outlineHex}
onChange={(next) => {
if (!next || !parseCssColor(next)) return;
// Picking a colour with no width yet is meant as "outline it",
// so give it a visible default rather than a silent no-op.
onChangeOutline(next, outlineWidth > 0 ? outlineWidth : 0.5);
}}
aria-label={t(
"pdfTextEditor.toolbar.outlineColour",
"Outline colour",
)}
data-testid="pdf-editor-outline-colour"
/>
<NumberInput
size="xs"
w={76}
min={0}
max={12}
step={0.25}
decimalScale={2}
value={outlineWidth}
onChange={(value) => {
const next = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(next) || next < 0) return;
// With a mixed colour there is no single hex to apply, so only
// the "remove the outline" direction is unambiguous.
if (next > 0 && state.mixed.stroke) return;
onChangeOutline(next > 0 ? outlineHex : null, next);
}}
disabled={state.strokeWidth === null}
aria-label={t(
"pdfTextEditor.toolbar.outlineWidth",
"Outline width (0 = none)",
)}
data-testid="pdf-editor-outline-width"
/>
</Group>
</Popover.Dropdown>
</Popover>
<Tooltip
label={
!state.canItalic
? t(
"pdfTextEditor.toolbar.italicUnavailable",
"This font has no italic version. Load your device fonts or pick another font family.",
)
: t("pdfTextEditor.toolbar.italic", "Italic")
}
>
<Button
variant={state.italic ? "primary" : "tertiary"}
accent={state.italic ? "default" : "neutral"}
size="sm"
onClick={onToggleItalic}
disabled={!state.canItalic}
aria-label={t("pdfTextEditor.toolbar.italic", "Italic")}
data-testid="pdf-editor-italic"
style={NO_SHRINK}
leftSection={<FormatItalicIcon fontSize="small" />}
/>
</Tooltip>
<Menu shadow="md" position="bottom-start" withinPortal>
<Menu.Target>
<Tooltip
label={t(
"pdfTextEditor.toolbar.changeCaseTooltip",
"Change case (text runs only)",
)}
>
<Button
variant="tertiary"
accent="neutral"
size="sm"
aria-label={t("pdfTextEditor.toolbar.changeCase", "Change case")}
data-testid="pdf-editor-change-case"
style={NO_SHRINK}
leftSection={<TextFieldsIcon fontSize="small" />}
/>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
data-testid="pdf-editor-change-case-upper"
onClick={() => onChangeCase("upper")}
>
{t("pdfTextEditor.toolbar.caseUpper", "UPPERCASE")}
</Menu.Item>
<Menu.Item
data-testid="pdf-editor-change-case-lower"
onClick={() => onChangeCase("lower")}
>
{t("pdfTextEditor.toolbar.caseLower", "lowercase")}
</Menu.Item>
<Menu.Item
data-testid="pdf-editor-change-case-title"
onClick={() => onChangeCase("title")}
>
{t("pdfTextEditor.toolbar.caseTitle", "Title Case")}
</Menu.Item>
<Menu.Item
data-testid="pdf-editor-change-case-sentence"
onClick={() => onChangeCase("sentence")}
>
{t("pdfTextEditor.toolbar.caseSentence", "Sentence case")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
}
/** Verbs that apply to any object: lock, delete, and Arrange. */
function ObjectGroup({ controller }: { controller: Controller }) {
const { t } = useTranslation();
const {
selectionAllLocked,
onToggleLock,
onDelete,
onChangeZOrder,
onAlign,
onDistribute,
selectionCount,
canAlignLines,
} = controller;
// Vertical aligns + distribute need 2+ objects. Horizontal aligns also
// accept a single multi-line paragraph (aligns its lines to each other).
const alignDisabled = selectionCount < 2;
const hAlignDisabled = selectionCount < 2 && !canAlignLines;
const distributeDisabled = selectionCount < 3;
return (
<>
<Tooltip
label={
selectionAllLocked
? t(
"pdfTextEditor.toolbar.unlockTooltip",
"Unlock selection - makes it editable again",
)
: t(
"pdfTextEditor.toolbar.lockTooltip",
"Lock selection - prevents accidental edits",
)
}
>
<Button
variant={selectionAllLocked ? "primary" : "tertiary"}
accent={selectionAllLocked ? "default" : "neutral"}
size="sm"
onClick={onToggleLock}
aria-label={
selectionAllLocked
? t("pdfTextEditor.toolbar.unlock", "Unlock selection")
: t("pdfTextEditor.toolbar.lock", "Lock selection")
}
data-testid="pdf-editor-toggle-lock"
style={NO_SHRINK}
leftSection={
selectionAllLocked ? (
<LockIcon fontSize="small" />
) : (
<LockOpenIcon fontSize="small" />
)
}
/>
</Tooltip>
<Tooltip label={t("pdfTextEditor.toolbar.deleteTooltip", "Delete (Del)")}>
<Button
variant="tertiary"
accent="danger"
size="sm"
onClick={onDelete}
aria-label={t("pdfTextEditor.toolbar.delete", "Delete selected")}
data-testid="pdf-editor-delete"
style={NO_SHRINK}
leftSection={<DeleteIcon fontSize="small" />}
/>
</Tooltip>
<Menu shadow="md" position="bottom-start" withinPortal closeOnItemClick>
<Menu.Target>
<Button
size="sm"
variant="secondary"
accent="neutral"
leftSection={<LayersIcon fontSize="small" />}
rightSection={<ExpandMoreIcon fontSize="small" />}
data-testid="pdf-editor-arrange-menu"
style={NO_SHRINK}
>
{t("pdfTextEditor.toolbar.arrange", "Arrange")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("pdfTextEditor.toolbar.order", "Order")}</Menu.Label>
<Menu.Item
leftSection={<FlipToFrontIcon fontSize="small" />}
onClick={() => onChangeZOrder("to-front")}
data-testid="pdf-editor-z-to-front"
>
{t("pdfTextEditor.toolbar.bringToFront", "Bring to front")}
</Menu.Item>
<Menu.Item
leftSection={<ArrowUpwardIcon fontSize="small" />}
onClick={() => onChangeZOrder("forward")}
data-testid="pdf-editor-z-forward"
>
{t("pdfTextEditor.toolbar.bringForward", "Bring forward")}
</Menu.Item>
<Menu.Item
leftSection={<ArrowDownwardIcon fontSize="small" />}
onClick={() => onChangeZOrder("backward")}
data-testid="pdf-editor-z-backward"
>
{t("pdfTextEditor.toolbar.sendBackward", "Send backward")}
</Menu.Item>
<Menu.Item
leftSection={<FlipToBackIcon fontSize="small" />}
onClick={() => onChangeZOrder("to-back")}
data-testid="pdf-editor-z-to-back"
>
{t("pdfTextEditor.toolbar.sendToBack", "Send to back")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>
{t("pdfTextEditor.toolbar.alignLabel", "Align · needs 2+ objects")}
</Menu.Label>
<Menu.Item
leftSection={<AlignHorizontalLeftIcon fontSize="small" />}
disabled={hAlignDisabled}
onClick={() => onAlign("left")}
data-testid="pdf-editor-align-left"
>
{t("pdfTextEditor.toolbar.alignLeft", "Align left")}
</Menu.Item>
<Menu.Item
leftSection={<AlignHorizontalCenterIcon fontSize="small" />}
disabled={hAlignDisabled}
onClick={() => onAlign("center-h")}
data-testid="pdf-editor-align-center-h"
>
{t("pdfTextEditor.toolbar.alignCentre", "Align centre")}
</Menu.Item>
<Menu.Item
leftSection={<AlignHorizontalRightIcon fontSize="small" />}
disabled={hAlignDisabled}
onClick={() => onAlign("right")}
data-testid="pdf-editor-align-right"
>
{t("pdfTextEditor.toolbar.alignRight", "Align right")}
</Menu.Item>
<Menu.Item
leftSection={<VerticalAlignTopIcon fontSize="small" />}
disabled={alignDisabled}
onClick={() => onAlign("top")}
data-testid="pdf-editor-align-top"
>
{t("pdfTextEditor.toolbar.alignTop", "Align top")}
</Menu.Item>
<Menu.Item
leftSection={<VerticalAlignCenterIcon fontSize="small" />}
disabled={alignDisabled}
onClick={() => onAlign("middle-v")}
data-testid="pdf-editor-align-middle-v"
>
{t("pdfTextEditor.toolbar.alignMiddle", "Align middle")}
</Menu.Item>
<Menu.Item
leftSection={<VerticalAlignBottomIcon fontSize="small" />}
disabled={alignDisabled}
onClick={() => onAlign("bottom")}
data-testid="pdf-editor-align-bottom"
>
{t("pdfTextEditor.toolbar.alignBottom", "Align bottom")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>
{t(
"pdfTextEditor.toolbar.distributeLabel",
"Distribute · needs 3+ objects",
)}
</Menu.Label>
<Menu.Item
leftSection={<LinearScaleIcon fontSize="small" />}
disabled={distributeDisabled}
onClick={() => onDistribute("horizontal")}
data-testid="pdf-editor-distribute-h"
>
{t(
"pdfTextEditor.toolbar.distributeHorizontally",
"Distribute horizontally",
)}
</Menu.Item>
<Menu.Item
leftSection={
<LinearScaleIcon
fontSize="small"
style={{ transform: "rotate(90deg)" }}
/>
}
disabled={distributeDisabled}
onClick={() => onDistribute("vertical")}
data-testid="pdf-editor-distribute-v"
>
{t(
"pdfTextEditor.toolbar.distributeVertically",
"Distribute vertically",
)}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
}
@@ -0,0 +1,277 @@
import { useState } from "react";
import {
ColorInput,
Group,
Menu,
NumberInput,
Popover,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import FormatItalicIcon from "@mui/icons-material/FormatItalic";
import TuneIcon from "@mui/icons-material/TuneOutlined";
import TextFieldsIcon from "@mui/icons-material/TextFields";
import { parseCssColor, toCssHex } from "@app/tools/pdfTextEditor/model/Color";
import { familyOf } from "@app/tools/pdfTextEditor/util/fontFamily";
import { FontFamilySelect } from "@app/tools/pdfTextEditor/components/FontFamilySelect";
import {
NO_SHRINK,
type Controller,
} from "@app/tools/pdfTextEditor/components/toolbar/toolbarShared";
/** Character formatting. Text runs only - absent for a pure image selection. */
export function FormatGroup({ controller }: { controller: Controller }) {
const { t } = useTranslation();
const {
state,
hasRunSelection,
onChangeFontFamily,
onChangeFontSize,
onChangeFill,
onChangeOutline,
onToggleItalic,
onChangeCase,
} = controller;
// Mantine only closes the fill picker's dropdown on blur, and it is
// portalled over the page - so we drive it and close it on a commit.
const [fillPickerOpen, setFillPickerOpen] = useState(false);
if (!hasRunSelection) return null;
const fillHex = state.fill ? toCssHex(state.fill) : "#000000";
const outlineHex = state.stroke ? toCssHex(state.stroke) : "#000000";
const outlineWidth = state.strokeWidth ?? 0;
const fontFamily = state.fontFamily ? familyOf(state.fontFamily) : null;
return (
<>
<FontFamilySelect
value={fontFamily}
onChange={onChangeFontFamily}
mixed={state.mixed.fontFamily}
/>
<NumberInput
size="xs"
w={76}
min={4}
max={144}
// A PDF font size is a float, so 11pt arrives as 11.000000002 and
// rendered in full. One decimal is all a type size ever needs.
decimalScale={1}
// Blank rather than a made-up number when the runs disagree, but still
// editable: typing a size is how you make a mixed selection uniform.
value={
state.mixed.fontSize
? ""
: Math.round((state.fontSize ?? 12) * 10) / 10
}
placeholder={
state.mixed.fontSize
? t("pdfTextEditor.fontPicker.mixed", "Mixed")
: undefined
}
onChange={(value) => {
const next = typeof value === "number" ? value : Number(value);
if (Number.isFinite(next) && next > 0) onChangeFontSize(next);
}}
aria-label={t("pdfTextEditor.toolbar.fontSize", "Font size")}
data-testid="pdf-editor-font-size"
style={NO_SHRINK}
/>
<ColorInput
size="xs"
w={fillPickerOpen ? 116 : 74}
withEyeDropper={false}
styles={{
// The swatch is decoration, not a target: left interactive it covers
// the whole of a narrow input and swallows the click that opens the
// picker. Clicks fall through to the input, which owns the gesture.
section: { pointerEvents: "none" },
input: {
color: fillPickerOpen ? undefined : "transparent",
transition: "width 120ms ease-out",
},
}}
value={fillHex}
// Blur re-emits the last valid colour, which re-applied the fill AFTER
// an undo. The input is controlled, so there is nothing to fix up.
fixOnBlur={false}
onChange={(next) => {
if (!next) return;
const rgb = parseCssColor(next);
if (!rgb) return;
// Re-emitting the applied colour must not cost a second undo step.
// Mixed fills (state.fill null) still apply - that unifies them.
if (
state.fill &&
rgb.r === state.fill.r &&
rgb.g === state.fill.g &&
rgb.b === state.fill.b
) {
return;
}
onChangeFill(next);
}}
// Drag end, swatch click or a committed hex - the deliberate pick.
// Deferred a frame: a saturation-square pick delivers its value on the
// NEXT animation frame (use-move), so closing now unmounts the picker
// first and Firefox loses the pick against a detached 0x0 node.
onChangeEnd={() =>
window.requestAnimationFrame(() => setFillPickerOpen(false))
}
onFocus={() => setFillPickerOpen(true)}
onClick={() => setFillPickerOpen(true)}
onBlur={() => setFillPickerOpen(false)}
onKeyDown={(event) => {
// The dropdown's own Escape handler never sees the key - focus stays
// in the input - so dismiss it here.
if (event.key === "Escape" || event.key === "Enter") {
setFillPickerOpen(false);
}
}}
popoverProps={{
opened: fillPickerOpen,
onChange: setFillPickerOpen,
transitionProps: { transition: "fade", duration: 0 },
}}
aria-label={t("pdfTextEditor.toolbar.fontColour", "Font colour")}
data-testid="pdf-editor-colour"
style={NO_SHRINK}
/>
<Popover position="bottom-start" withinPortal shadow="md">
<Popover.Target>
<Tooltip
label={t(
"pdfTextEditor.toolbar.advancedColourTooltip",
"Advanced colour (glyph outline)",
)}
>
<Button
variant={outlineWidth > 0 ? "primary" : "tertiary"}
accent={outlineWidth > 0 ? "default" : "neutral"}
size="sm"
aria-label={t(
"pdfTextEditor.toolbar.advancedColour",
"Advanced colour",
)}
data-testid="pdf-editor-colour-advanced"
style={NO_SHRINK}
leftSection={<TuneIcon fontSize="small" />}
/>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Group gap="xs" align="center" wrap="nowrap">
<ColorInput
size="xs"
w={132}
value={outlineHex}
onChange={(next) => {
if (!next || !parseCssColor(next)) return;
// Picking a colour with no width yet is meant as "outline it",
// so give it a visible default rather than a silent no-op.
onChangeOutline(next, outlineWidth > 0 ? outlineWidth : 0.5);
}}
aria-label={t(
"pdfTextEditor.toolbar.outlineColour",
"Outline colour",
)}
data-testid="pdf-editor-outline-colour"
/>
<NumberInput
size="xs"
w={76}
min={0}
max={12}
step={0.25}
decimalScale={2}
value={outlineWidth}
onChange={(value) => {
const next = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(next) || next < 0) return;
// With a mixed colour there is no single hex to apply, so only
// the "remove the outline" direction is unambiguous.
if (next > 0 && state.mixed.stroke) return;
onChangeOutline(next > 0 ? outlineHex : null, next);
}}
disabled={state.strokeWidth === null}
aria-label={t(
"pdfTextEditor.toolbar.outlineWidth",
"Outline width (0 = none)",
)}
data-testid="pdf-editor-outline-width"
/>
</Group>
</Popover.Dropdown>
</Popover>
<Tooltip
label={
!state.canItalic
? t(
"pdfTextEditor.toolbar.italicUnavailable",
"This font has no italic version. Load your device fonts or pick another font family.",
)
: t("pdfTextEditor.toolbar.italic", "Italic")
}
>
<Button
variant={state.italic ? "primary" : "tertiary"}
accent={state.italic ? "default" : "neutral"}
size="sm"
onClick={onToggleItalic}
disabled={!state.canItalic}
aria-label={t("pdfTextEditor.toolbar.italic", "Italic")}
data-testid="pdf-editor-italic"
style={NO_SHRINK}
leftSection={<FormatItalicIcon fontSize="small" />}
/>
</Tooltip>
<Menu shadow="md" position="bottom-start" withinPortal>
<Menu.Target>
<Tooltip
label={t(
"pdfTextEditor.toolbar.changeCaseTooltip",
"Change case (text runs only)",
)}
>
<Button
variant="tertiary"
accent="neutral"
size="sm"
aria-label={t("pdfTextEditor.toolbar.changeCase", "Change case")}
data-testid="pdf-editor-change-case"
style={NO_SHRINK}
leftSection={<TextFieldsIcon fontSize="small" />}
/>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
data-testid="pdf-editor-change-case-upper"
onClick={() => onChangeCase("upper")}
>
{t("pdfTextEditor.toolbar.caseUpper", "UPPERCASE")}
</Menu.Item>
<Menu.Item
data-testid="pdf-editor-change-case-lower"
onClick={() => onChangeCase("lower")}
>
{t("pdfTextEditor.toolbar.caseLower", "lowercase")}
</Menu.Item>
<Menu.Item
data-testid="pdf-editor-change-case-title"
onClick={() => onChangeCase("title")}
>
{t("pdfTextEditor.toolbar.caseTitle", "Title Case")}
</Menu.Item>
<Menu.Item
data-testid="pdf-editor-change-case-sentence"
onClick={() => onChangeCase("sentence")}
>
{t("pdfTextEditor.toolbar.caseSentence", "Sentence case")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
}
@@ -0,0 +1,226 @@
import { Menu, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import LockIcon from "@mui/icons-material/LockOutlined";
import LockOpenIcon from "@mui/icons-material/LockOpenOutlined";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import LayersIcon from "@mui/icons-material/LayersOutlined";
import FlipToFrontIcon from "@mui/icons-material/FlipToFrontOutlined";
import FlipToBackIcon from "@mui/icons-material/FlipToBackOutlined";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import VerticalAlignTopIcon from "@mui/icons-material/VerticalAlignTop";
import VerticalAlignBottomIcon from "@mui/icons-material/VerticalAlignBottom";
import VerticalAlignCenterIcon from "@mui/icons-material/VerticalAlignCenter";
import AlignHorizontalLeftIcon from "@mui/icons-material/AlignHorizontalLeftOutlined";
import AlignHorizontalCenterIcon from "@mui/icons-material/AlignHorizontalCenterOutlined";
import AlignHorizontalRightIcon from "@mui/icons-material/AlignHorizontalRightOutlined";
import LinearScaleIcon from "@mui/icons-material/LinearScaleOutlined";
import {
NO_SHRINK,
type Controller,
} from "@app/tools/pdfTextEditor/components/toolbar/toolbarShared";
/** Verbs that apply to any object: lock, delete, and Arrange. */
export function ObjectGroup({ controller }: { controller: Controller }) {
const { t } = useTranslation();
const {
selectionAllLocked,
onToggleLock,
onDelete,
onChangeZOrder,
onAlign,
onDistribute,
selectionCount,
canAlignLines,
} = controller;
// Vertical aligns + distribute need 2+ objects. Horizontal aligns also
// accept a single multi-line paragraph (aligns its lines to each other).
const alignDisabled = selectionCount < 2;
const hAlignDisabled = selectionCount < 2 && !canAlignLines;
const distributeDisabled = selectionCount < 3;
return (
<>
<Tooltip
label={
selectionAllLocked
? t(
"pdfTextEditor.toolbar.unlockTooltip",
"Unlock selection - makes it editable again",
)
: t(
"pdfTextEditor.toolbar.lockTooltip",
"Lock selection - prevents accidental edits",
)
}
>
<Button
variant={selectionAllLocked ? "primary" : "tertiary"}
accent={selectionAllLocked ? "default" : "neutral"}
size="sm"
onClick={onToggleLock}
aria-label={
selectionAllLocked
? t("pdfTextEditor.toolbar.unlock", "Unlock selection")
: t("pdfTextEditor.toolbar.lock", "Lock selection")
}
data-testid="pdf-editor-toggle-lock"
style={NO_SHRINK}
leftSection={
selectionAllLocked ? (
<LockIcon fontSize="small" />
) : (
<LockOpenIcon fontSize="small" />
)
}
/>
</Tooltip>
<Tooltip label={t("pdfTextEditor.toolbar.deleteTooltip", "Delete (Del)")}>
<Button
variant="tertiary"
accent="danger"
size="sm"
onClick={onDelete}
aria-label={t("pdfTextEditor.toolbar.delete", "Delete selected")}
data-testid="pdf-editor-delete"
style={NO_SHRINK}
leftSection={<DeleteIcon fontSize="small" />}
/>
</Tooltip>
<Menu shadow="md" position="bottom-start" withinPortal closeOnItemClick>
<Menu.Target>
<Button
size="sm"
variant="secondary"
accent="neutral"
leftSection={<LayersIcon fontSize="small" />}
rightSection={<ExpandMoreIcon fontSize="small" />}
data-testid="pdf-editor-arrange-menu"
style={NO_SHRINK}
>
{t("pdfTextEditor.toolbar.arrange", "Arrange")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("pdfTextEditor.toolbar.order", "Order")}</Menu.Label>
<Menu.Item
leftSection={<FlipToFrontIcon fontSize="small" />}
onClick={() => onChangeZOrder("to-front")}
data-testid="pdf-editor-z-to-front"
>
{t("pdfTextEditor.toolbar.bringToFront", "Bring to front")}
</Menu.Item>
<Menu.Item
leftSection={<ArrowUpwardIcon fontSize="small" />}
onClick={() => onChangeZOrder("forward")}
data-testid="pdf-editor-z-forward"
>
{t("pdfTextEditor.toolbar.bringForward", "Bring forward")}
</Menu.Item>
<Menu.Item
leftSection={<ArrowDownwardIcon fontSize="small" />}
onClick={() => onChangeZOrder("backward")}
data-testid="pdf-editor-z-backward"
>
{t("pdfTextEditor.toolbar.sendBackward", "Send backward")}
</Menu.Item>
<Menu.Item
leftSection={<FlipToBackIcon fontSize="small" />}
onClick={() => onChangeZOrder("to-back")}
data-testid="pdf-editor-z-to-back"
>
{t("pdfTextEditor.toolbar.sendToBack", "Send to back")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>
{t("pdfTextEditor.toolbar.alignLabel", "Align · needs 2+ objects")}
</Menu.Label>
<Menu.Item
leftSection={<AlignHorizontalLeftIcon fontSize="small" />}
disabled={hAlignDisabled}
onClick={() => onAlign("left")}
data-testid="pdf-editor-align-left"
>
{t("pdfTextEditor.toolbar.alignLeft", "Align left")}
</Menu.Item>
<Menu.Item
leftSection={<AlignHorizontalCenterIcon fontSize="small" />}
disabled={hAlignDisabled}
onClick={() => onAlign("center-h")}
data-testid="pdf-editor-align-center-h"
>
{t("pdfTextEditor.toolbar.alignCentre", "Align centre")}
</Menu.Item>
<Menu.Item
leftSection={<AlignHorizontalRightIcon fontSize="small" />}
disabled={hAlignDisabled}
onClick={() => onAlign("right")}
data-testid="pdf-editor-align-right"
>
{t("pdfTextEditor.toolbar.alignRight", "Align right")}
</Menu.Item>
<Menu.Item
leftSection={<VerticalAlignTopIcon fontSize="small" />}
disabled={alignDisabled}
onClick={() => onAlign("top")}
data-testid="pdf-editor-align-top"
>
{t("pdfTextEditor.toolbar.alignTop", "Align top")}
</Menu.Item>
<Menu.Item
leftSection={<VerticalAlignCenterIcon fontSize="small" />}
disabled={alignDisabled}
onClick={() => onAlign("middle-v")}
data-testid="pdf-editor-align-middle-v"
>
{t("pdfTextEditor.toolbar.alignMiddle", "Align middle")}
</Menu.Item>
<Menu.Item
leftSection={<VerticalAlignBottomIcon fontSize="small" />}
disabled={alignDisabled}
onClick={() => onAlign("bottom")}
data-testid="pdf-editor-align-bottom"
>
{t("pdfTextEditor.toolbar.alignBottom", "Align bottom")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>
{t(
"pdfTextEditor.toolbar.distributeLabel",
"Distribute · needs 3+ objects",
)}
</Menu.Label>
<Menu.Item
leftSection={<LinearScaleIcon fontSize="small" />}
disabled={distributeDisabled}
onClick={() => onDistribute("horizontal")}
data-testid="pdf-editor-distribute-h"
>
{t(
"pdfTextEditor.toolbar.distributeHorizontally",
"Distribute horizontally",
)}
</Menu.Item>
<Menu.Item
leftSection={
<LinearScaleIcon
fontSize="small"
style={{ transform: "rotate(90deg)" }}
/>
}
disabled={distributeDisabled}
onClick={() => onDistribute("vertical")}
data-testid="pdf-editor-distribute-v"
>
{t(
"pdfTextEditor.toolbar.distributeVertically",
"Distribute vertically",
)}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
}
@@ -0,0 +1,22 @@
import type { useToolbarController } from "@app/tools/pdfTextEditor/hooks/useToolbarController";
export type Controller = ReturnType<typeof useToolbarController>;
/** Toolbar children keep their natural width; the strip scrolls if pressed. */
export const NO_SHRINK = { flexShrink: 0 } as const;
/** Hairline between two groups of toolbar controls. */
export function ToolbarSeparator() {
return (
<span
aria-hidden
style={{
...NO_SHRINK,
width: "0.0625rem",
alignSelf: "stretch",
margin: "0.25rem 0.125rem",
background: "var(--c-border-subtle)",
}}
/>
);
}
@@ -0,0 +1,35 @@
import { useEffect, useState, type RefObject } from "react";
/**
* The element's own width in pixels, tracked as it resizes.
*
* A CSS container query can hide a control that does not fit, but it cannot
* move one into a menu - that is a change of markup, not of style. So the one
* place the toolbar needs to make a structural decision has to measure.
*
* Returns null until the first observation, so a caller can render its roomy
* layout rather than flashing the compact one on mount.
*/
export function useElementWidth(
ref: RefObject<HTMLElement | null>,
): number | null {
const [width, setWidth] = useState<number | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (typeof ResizeObserver === "undefined") {
// No observer (jsdom, very old engines): measure once and stay there.
setWidth(el.getBoundingClientRect().width);
return;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) setWidth(entry.contentRect.width);
});
observer.observe(el);
return () => observer.disconnect();
}, [ref]);
return width;
}
@@ -1,4 +1,5 @@
import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument";
import { assertSavedPdf } from "@app/tools/pdfTextEditor/util/savedBytes";
/** `FPDF_SaveAsCopy` flags. */
const FPDF_INCREMENTAL = 1;
@@ -40,15 +41,34 @@ export class PdfiumSave {
);
}
// The writer the shim hands back is the FPDF_FILEWRITE the flagged
// entry point expects, so incremental mode needs no extra plumbing.
const withFlags = (m as unknown as SaveFlagsModule).FPDF_SaveAsCopy;
// Falling back to the unflagged entry point would REWRITE a document the
// caller asked to append to - the one thing incremental mode exists to
// prevent - so refuse rather than quietly break its signatures.
if (options.incremental && typeof withFlags !== "function") {
throw new Error(
"This PDF needs a signature-preserving incremental save, but the " +
"PDFium build in use has no FPDF_SaveAsCopy entry point; save " +
"aborted rather than rewriting the signed revision.",
);
}
const writerPtr = m.PDFiumExt_OpenFileWriter();
try {
// The writer the shim hands back is the FPDF_FILEWRITE the flagged
// entry point expects, so incremental mode needs no extra plumbing.
const withFlags = (m as unknown as SaveFlagsModule).FPDF_SaveAsCopy;
if (options.incremental && typeof withFlags === "function") {
withFlags(doc.docPtr, writerPtr, FPDF_INCREMENTAL);
} else {
m.PDFiumExt_SaveAsCopy(doc.docPtr, writerPtr);
// PDFium reports a failed save by return code, not by throwing. Ignoring
// it meant a failure produced an empty/partial buffer that the editor
// then wrote over the user's file.
const ok =
options.incremental && typeof withFlags === "function"
? withFlags(doc.docPtr, writerPtr, FPDF_INCREMENTAL)
: m.PDFiumExt_SaveAsCopy(doc.docPtr, writerPtr);
if (!ok) {
throw new Error(
"PDFium could not serialize the edited document; save aborted so " +
"the existing file is left untouched.",
);
}
const size = m.PDFiumExt_GetFileWriterSize(writerPtr);
const outBuf = m.pdfium.wasmExports.malloc(size);
@@ -62,6 +82,7 @@ export class PdfiumSave {
size,
);
view.set(heap);
assertSavedPdf(view);
return view;
} finally {
m.pdfium.wasmExports.free(outBuf);
@@ -23,10 +23,20 @@ export class PdfiumTextWriter {
*/
static commitRunText(doc: EditorDocument, page: Page, run: TextRun): boolean {
if (!run.pdfiumObjPtr) return false;
// A text object with no characters is not a thing PDFium will build:
// FPDFText_SetText traps ("unreachable") on an empty string rather than
// returning false, which used to escape as a raw WASM error the moment a
// user selected all the text in a box and pressed Delete. Emptying a run
// is the overlay path's job - it paints the cover rect and emits nothing.
if (run.text.length === 0) return false;
const m = doc.module;
const ptr = writeUtf16(m, run.text);
try {
m.FPDFText_SetText(run.pdfiumObjPtr, ptr);
} catch {
// Any other trap means the fast path is unusable, which is exactly what
// false already means; the caller restores and re-emits.
return false;
} finally {
m.pdfium.wasmExports.free(ptr);
}
@@ -0,0 +1,59 @@
import { useSyncExternalStore } from "react";
import type { FileId } from "@app/types/file";
/**
* What the editor's canvas top bar needs from the tool panel.
*
* The two surfaces are siblings, not ancestor and descendant: the panel is the
* tool, and the canvas is a workbench view registered by id and mounted
* somewhere else entirely, so no React context reaches from one to the other.
* They already share the EditorStore singleton for document state; this is the
* same seam for the things only the panel can do - anything touching the
* workbench's file list has to run where the FileContext is.
*/
export interface EditorSession {
/** Name of the open document, or null before one is opened. */
fileName: string | null;
/** Workbench file the document came from; null for a file opened from disk. */
fileId: FileId | null;
/** Apply the edit back to the workbench file. */
save: () => void;
/** Apply, then hand the user a copy. */
download: () => void;
/** Open a different workbench PDF (asks first when there are unsaved edits). */
pickFile: (file: File) => void;
/** Open the OS file picker for a replacement image. */
pickImage: () => void;
}
let current: EditorSession | null = null;
const listeners = new Set<() => void>();
function emit(): void {
for (const listener of Array.from(listeners)) listener();
}
/** Publish (or, with null, retract) the panel's session. */
export function setEditorSession(session: EditorSession | null): void {
current = session;
emit();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): EditorSession | null {
return current;
}
/**
* The live session, or null while the panel is not mounted.
*
* A null session means the canvas is on its own - it must degrade to view-only
* controls rather than render a Save button that cannot save.
*/
export function useEditorSession(): EditorSession | null {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
@@ -1,5 +1,8 @@
import { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument";
import { HistoryStack } from "@app/tools/pdfTextEditor/store/HistoryStack";
import {
HistoryStack,
HistoryStepError,
} from "@app/tools/pdfTextEditor/store/HistoryStack";
import { Selection } from "@app/tools/pdfTextEditor/store/Selection";
import { pageGuides } from "@app/tools/pdfTextEditor/util/guides";
import { PdfiumTextReader } from "@app/tools/pdfTextEditor/pdfium/PdfiumTextReader";
@@ -78,6 +81,11 @@ export interface EditorViewState {
widthMode: WidthMode;
/** Show per-page rulers and alignment guides. */
showRulers: boolean;
// Find and shortcuts are opened from the canvas top bar but rendered beside
// the pages, and the panel's keyboard shortcuts drive both - so the flags
// live on the store both surfaces already share.
findOpen: boolean;
helpOpen: boolean;
}
const POSITION_REFRESH_MS = 600;
@@ -101,6 +109,8 @@ const INITIAL: EditorViewState = {
groupingMode: "auto",
widthMode: "grow",
showRulers: false,
findOpen: false,
helpOpen: false,
};
// Single observable store for the editor's React layer. Components never reach
@@ -203,6 +213,16 @@ export class EditorStore {
this.patch({ showRulers });
}
setFindOpen(findOpen: boolean): void {
if (this.state.findOpen === findOpen) return;
this.patch({ findOpen });
}
setHelpOpen(helpOpen: boolean): void {
if (this.state.helpOpen === helpOpen) return;
this.patch({ helpOpen });
}
get groupingMode(): GroupingMode {
return this.state.groupingMode;
}
@@ -301,7 +321,14 @@ export class EditorStore {
/** Apply a command via the history stack, re-snapshot, and notify. */
dispatch(cmd: Command): void {
if (!this.doc) return;
this.history.execute(cmd, this.doc);
try {
this.history.execute(cmd, this.doc);
} catch (err) {
// A forward edit can break the document exactly like an undo can, so it
// gets the same recovery rather than throwing out of the click handler.
this.recoverFromBrokenStep(err);
return;
}
this.resnapshot();
this.patch({ dirty: this.isDirty() });
this.schedulePositionRefresh();
@@ -391,8 +418,8 @@ export class EditorStore {
if (!this.doc) return;
try {
this.history.undo(this.doc);
} catch {
this.recoverFromBrokenStep();
} catch (err) {
this.recoverFromBrokenStep(err);
return;
}
this.resnapshot();
@@ -403,8 +430,8 @@ export class EditorStore {
if (!this.doc) return;
try {
this.history.redo(this.doc);
} catch {
this.recoverFromBrokenStep();
} catch (err) {
this.recoverFromBrokenStep(err);
return;
}
this.resnapshot();
@@ -412,10 +439,23 @@ export class EditorStore {
}
// A half-applied command leaves the run model describing objects that no
// longer match the page, so rebuild it from PDFium rather than guess.
private recoverFromBrokenStep(): void {
// longer match the page, so rebuild it from PDFium rather than guess. A
// command that rolled itself back changed nothing, so it keeps its history.
private recoverFromBrokenStep(err?: unknown): void {
const doc = this.doc;
if (!doc) return;
// The wrapper says which phase broke; the cause says what actually failed,
// and that is the half worth putting in front of the user.
const reason = err instanceof HistoryStepError ? err.cause : err;
const message = reason instanceof Error ? reason.message : null;
if (err instanceof HistoryStepError && err.documentIntact) {
this.resnapshot();
this.patch({
dirty: this.isDirty(),
error: message ?? this.state.error,
});
return;
}
this.repopulateAllPages(doc, this.state.groupingMode);
// Rebuilt runs get fresh ids, so no existing history entry can apply.
this.history.clear();
@@ -423,7 +463,7 @@ export class EditorStore {
this.bakedDirty = true;
this.selection.clear();
this.resnapshot();
this.patch({ dirty: true });
this.patch({ dirty: true, error: message ?? this.state.error });
}
/** Drop every page's run model and read it back from the document. */
@@ -1,4 +1,7 @@
import type { Command } from "@app/tools/pdfTextEditor/commands/Command";
import {
RolledBackError,
type Command,
} from "@app/tools/pdfTextEditor/commands/Command";
import { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand";
import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument";
@@ -12,12 +15,16 @@ const COALESCE_WINDOW_MS = 600;
export class HistoryStepError extends Error {
readonly phase: "apply" | "revert";
readonly cause: unknown;
// True when the failing command put the document back as it was, so the run
// model still describes the page and only THIS step is lost.
readonly documentIntact: boolean;
constructor(phase: "apply" | "revert", cause: unknown) {
super(`Command failed to ${phase}`);
this.name = "HistoryStepError";
this.phase = phase;
this.cause = cause;
this.documentIntact = cause instanceof RolledBackError;
this.cause = cause instanceof RolledBackError ? cause.cause : cause;
}
}
@@ -59,7 +66,16 @@ export class HistoryStack {
// Read the clock BEFORE apply: the window is meant to measure the user's
// idle time between edits.
const startedAt = Date.now();
cmd.apply(doc);
try {
cmd.apply(doc);
} catch (err) {
// Same contract undo/redo already honoured: a command that threw is NOT
// recorded, because a history entry whose apply half-ran cannot be
// reverted. Pushing first would have made the next undo run a revert
// against changes that were never made.
this.lastCoalesceKey = null;
throw new HistoryStepError("apply", err);
}
const key = cmd.coalesceKey?.() ?? null;
const top = this.undoStack[this.undoStack.length - 1];
// The command a merge would join. Unwrap a group to its most recent
@@ -1,6 +1,7 @@
import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument";
import { preserveShadings } from "@app/tools/pdfTextEditor/pdfdoc/passes/preserveShadings";
import { PdfiumSave } from "@app/tools/pdfTextEditor/pdfium/PdfiumSave";
import { assertIncrementalAppend } from "@app/tools/pdfTextEditor/util/savedBytes";
/** Serialize the editor document to a Blob plus the download filename. */
export async function exportToBlob(
@@ -32,12 +33,21 @@ export async function exportToBlob(
const repaired = await preserveShadings(bytes, doc.openedBytes, {
pages: regenerated,
});
if (repaired) bytes = repaired;
// The repair appends its own revision, so it may only ever grow the file
// - a shorter result would mean it rewrote what it was handed.
if (repaired && (!incremental || repaired.length >= bytes.length)) {
bytes = repaired;
}
} catch {
/* the unrepaired save is still a correct save */
}
}
// Checked LAST, on the bytes that will actually be written: the incremental
// path is the tool's signature-preserving promise, and a promise nothing
// verifies is just a comment.
if (incremental) assertIncrementalAppend(bytes, doc.openedBytes);
return { blob: pdfBlob(bytes), filename: exportName(sourceName) };
}
@@ -0,0 +1,97 @@
/**
* Checks the bytes a save produced before anything writes them anywhere.
*
* PDFium's save path reports failure by return code, and a writer that gave
* back nothing still hands over a zero-length buffer. Without these the editor
* would replace the user's workbench file with that buffer and clear the dirty
* flag - a total loss with no error anywhere.
*/
const PDF_HEADER = "%PDF-";
// A PDF cannot be meaningfully smaller than this; the smallest valid file is
// a few hundred bytes. Anything under it is a truncated or empty write.
const MIN_PDF_BYTES = 256;
// The spec puts %%EOF in the last 1024 bytes; allow slack for trailing padding.
const EOF_SCAN_BYTES = 4096;
function startsWith(bytes: Uint8Array, ascii: string): boolean {
if (bytes.length < ascii.length) return false;
for (let i = 0; i < ascii.length; i++) {
if (bytes[i] !== ascii.charCodeAt(i)) return false;
}
return true;
}
/** True when `%%EOF` appears within the last `EOF_SCAN_BYTES` bytes. */
function hasTrailingEof(bytes: Uint8Array): boolean {
const marker = "%%EOF";
const from = Math.max(0, bytes.length - EOF_SCAN_BYTES);
for (let i = bytes.length - marker.length; i >= from; i--) {
let match = true;
for (let j = 0; j < marker.length; j++) {
if (bytes[i + j] !== marker.charCodeAt(j)) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}
/**
* Throw unless `bytes` look like a whole PDF.
*
* Cheap structural checks only - header, plausible length, terminating marker.
* They are not a validator; they are the difference between "the save failed
* quietly" and "the save failed loudly".
*/
export function assertSavedPdf(bytes: Uint8Array): void {
if (bytes.length < MIN_PDF_BYTES) {
throw new Error(
`Save produced ${bytes.length} bytes, which is too short to be a PDF; ` +
"the file was left untouched.",
);
}
if (!startsWith(bytes, PDF_HEADER)) {
throw new Error(
"Save produced data that does not start with a PDF header; " +
"the file was left untouched.",
);
}
if (!hasTrailingEof(bytes)) {
throw new Error(
"Save produced a PDF with no end-of-file marker, so it is truncated; " +
"the file was left untouched.",
);
}
}
/**
* Throw unless `saved` is `original` plus an appended revision.
*
* This is what makes the signature-preserving claim checkable rather than
* hopeful: an incremental save may only ADD bytes, so every byte a signature
* covers has to still be there, unchanged, at the same offset. A full rewrite
* that slipped through the incremental path fails here instead of silently
* shipping a document whose signatures no longer verify.
*/
export function assertIncrementalAppend(
saved: Uint8Array,
original: Uint8Array,
): void {
if (saved.length < original.length) {
throw new Error(
"Incremental save is shorter than the file it appends to, so the signed " +
"revision was rewritten; the file was left untouched.",
);
}
for (let i = 0; i < original.length; i++) {
if (saved[i] !== original[i]) {
throw new Error(
`Incremental save changed byte ${i} of the original revision, so its ` +
"signatures would no longer verify; the file was left untouched.",
);
}
}
}
+53
View File
@@ -0,0 +1,53 @@
/* Keyboard shortcut rendered as one tile per key, the way chat and design apps
show their shortcut lists. */
.sui-keycombo {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.sui-keycap {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.625rem;
height: 1.625rem;
padding: 0 0.4375rem;
border-radius: 0.3125rem;
border: 0.0625rem solid var(--c-border-subtle);
/* Raised against the row it sits on, so the tiles read as physical keys
rather than as inline code. */
background: var(--c-bg-raised);
box-shadow: 0 0.0625rem 0 var(--c-border-subtle);
color: var(--c-text);
font-family: inherit;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
/* A word key ("Click", "Drag") needs room to read as a word. */
.sui-keycap--word {
font-weight: 500;
letter-spacing: 0.01em;
}
/* Separator between alternative combos ("Ctrl + Z / Ctrl + Y"). */
.sui-keycombo__or {
padding: 0 0.125rem;
color: var(--c-text-muted);
font-size: 0.75rem;
line-height: 1;
}
/* The "+" between two keys of one combo. Muted, because the tiles carry the
meaning and a bright plus competes with them. */
.sui-keycombo__plus {
color: var(--c-text-muted);
font-size: 0.6875rem;
line-height: 1;
}
@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { KeyCombo } from "@app/ui/KeyCombo";
const meta: Meta<typeof KeyCombo> = {
title: "Primitives/KeyCombo",
component: KeyCombo,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: { combo: "Ctrl + Shift + V" },
};
export default meta;
type Story = StoryObj<typeof KeyCombo>;
export const Default: Story = {};
export const WithPlus: Story = {
args: { combo: "Ctrl + S", withPlus: true },
};
/** Alternatives are separated by "/", never crammed into one cap. */
export const Alternatives: Story = {
args: { combo: "F3 / Ctrl + G" },
};
/** Mouse gestures are keys too - they just happen to be words. */
export const Gestures: Story = {
args: { combo: "Ctrl + Click + Drag" },
};
export const InContext_ShortcutList: Story = {
render: () => (
<div
style={{
width: "26rem",
border: "1px solid var(--c-border-subtle)",
borderRadius: "0.5rem",
overflow: "hidden",
background: "var(--c-surface-raised)",
}}
>
{[
["Edit text", "Click"],
["Add / remove a run from selection", "Ctrl + Click / Shift + Click"],
["Marquee multi-select", "Ctrl + Shift + Drag"],
["Undo / Redo", "Ctrl + Z / Ctrl + Y"],
["Save to your workspace", "Ctrl + S"],
].map(([label, combo], index) => (
<div
key={combo}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
padding: "0.625rem 0.875rem",
borderTop:
index === 0 ? "none" : "1px solid var(--c-border-subtle)",
color: "var(--c-text)",
fontSize: "0.875rem",
}}
>
<span>{label}</span>
<KeyCombo combo={combo} />
</div>
))}
</div>
),
};
@@ -0,0 +1,70 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { KeyCombo } from "@app/ui/KeyCombo";
/** Every <kbd> the combo rendered, in order. */
function keycaps(container: HTMLElement): string[] {
return Array.from(container.querySelectorAll("kbd")).map(
(el) => el.textContent ?? "",
);
}
describe("KeyCombo", () => {
it("gives each key of a combo its own cap", () => {
const { container } = render(<KeyCombo combo="Ctrl + Shift + V" />);
expect(keycaps(container)).toEqual(["Ctrl", "Shift", "V"]);
});
it("splits an unspaced combo the same way", () => {
// Both spellings have to land identically, or a list written by two people
// renders as a mix of tiles and run-on strings.
const { container } = render(<KeyCombo combo="Ctrl+Shift+V" />);
expect(keycaps(container)).toEqual(["Ctrl", "Shift", "V"]);
});
it("keeps alternatives apart with a separator, not a cap", () => {
const { container } = render(<KeyCombo combo="F3 / Ctrl + G" />);
expect(keycaps(container)).toEqual(["F3", "Ctrl", "G"]);
expect(container.textContent).toContain("/");
});
it("treats a word key as one cap", () => {
const { container } = render(<KeyCombo combo="Ctrl + Click + Drag" />);
expect(keycaps(container)).toEqual(["Ctrl", "Click", "Drag"]);
});
it("renders a lone key with no separators", () => {
const { container } = render(<KeyCombo combo="Esc" />);
expect(keycaps(container)).toEqual(["Esc"]);
});
it("survives a bare '+' being the key itself", () => {
const { container } = render(<KeyCombo combo="+" />);
expect(keycaps(container)).toEqual(["+"]);
});
it("survives a bare '/' being the key itself", () => {
const { container } = render(<KeyCombo combo="/" />);
expect(keycaps(container)).toEqual(["/"]);
});
it("adds a plus between keys only when asked", () => {
const { container } = render(<KeyCombo combo="Ctrl + S" withPlus />);
expect(keycaps(container)).toEqual(["Ctrl", "S"]);
expect(container.textContent).toContain("+");
});
it("marks a long key so it can be styled as a word", () => {
const { container } = render(<KeyCombo combo="PageDown" />);
expect(container.querySelector("kbd")?.className).toContain(
"sui-keycap--word",
);
});
it("does not mark a short key as a word", () => {
const { container } = render(<KeyCombo combo="Ctrl" />);
expect(container.querySelector("kbd")?.className).not.toContain(
"sui-keycap--word",
);
});
});
+85
View File
@@ -0,0 +1,85 @@
import { Fragment } from "react";
import "@app/ui/KeyCombo.css";
export interface KeyComboProps {
// The shortcut as written by a human: "Ctrl + Z", "Ctrl+Shift+V",
// "F3 / Ctrl + G", "Ctrl + Click + drag". Both spaced and unspaced "+"
// parse the same, so no caller has to remember which style this expects.
combo: string;
/** Show a "+" between the keys of one combo. Off by default. */
withPlus?: boolean;
className?: string;
}
/** Alternative separator: " / " between whole combos, e.g. "F3 / Ctrl+G". */
const ALTERNATIVE = /\s*\/\s*/;
/** Key separator inside one combo: "+" with or without spaces around it. */
const KEY = /\s*\+\s*/;
// A lone "+" IS a key (Discord's "add reaction"), so a combo that is nothing
// but separators has to survive the split.
function splitKeys(combo: string): string[] {
const trimmed = combo.trim();
if (trimmed === "+" || trimmed === "") return [trimmed];
return trimmed
.split(KEY)
.map((k) => k.trim())
.filter((k) => k.length > 0);
}
/** True for keys that read as words rather than as a single glyph or name. */
function isWord(key: string): boolean {
return /\s/.test(key) || key.length > 5;
}
/**
* A keyboard shortcut drawn as one tile per key.
*
* Every key gets its own cap - "Ctrl" and "Z" are two things you press, so
* they are two tiles, which is what every app that shows a shortcut list does.
* Rendering the whole string in one cap ("Ctrl+Shift+V") reads as a single
* mystery key and makes lists of shortcuts impossible to scan.
*/
export function KeyCombo({
combo,
withPlus = false,
className,
}: KeyComboProps) {
const alternatives = combo.split(ALTERNATIVE).filter((c) => c.trim() !== "");
// "/" is also a key on its own ("Cmd + /"), so a string that splits to
// nothing was a bare separator and is shown verbatim.
const groups = alternatives.length > 0 ? alternatives : [combo];
return (
<span
className={["sui-keycombo", className ?? ""].filter(Boolean).join(" ")}
data-testid="sui-key-combo"
>
{groups.map((group, groupIndex) => (
<Fragment key={`${group}-${groupIndex}`}>
{groupIndex > 0 && (
<span className="sui-keycombo__or" aria-hidden>
/
</span>
)}
{splitKeys(group).map((key, keyIndex) => (
<Fragment key={`${key}-${keyIndex}`}>
{withPlus && keyIndex > 0 && (
<span className="sui-keycombo__plus" aria-hidden>
+
</span>
)}
<kbd
className={["sui-keycap", isWord(key) ? "sui-keycap--word" : ""]
.filter(Boolean)
.join(" ")}
>
{key}
</kbd>
</Fragment>
))}
</Fragment>
))}
</span>
);
}
+1
View File
@@ -15,6 +15,7 @@ export * from "@app/ui/NavSurface";
export * from "@app/ui/Surface";
export * from "@app/ui/PanelHeader";
export * from "@app/ui/CodeBlock";
export * from "@app/ui/KeyCombo";
export * from "@app/ui/SectionDivider";
export * from "@app/ui/CarouselDots";
export * from "@app/ui/Card";