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)." SIDE_STITCH_BOOKLET_SORT = "Arrange pages for sidestitch booklet printing (optimized for binding on the side)."
[pdfTextEditor] [pdfTextEditor]
confirmReplaceDirty = "You have unsaved changes. Replace the open document and discard them?"
download = "Download" download = "Download"
downloadTooltip = "Save and download the edited PDF" downloadTooltip = "Save and download the edited PDF"
save = "Save 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" 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" 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] [pdfTextEditor.drop]
hint = "Releases on the editor stage replace any open document." hint = "Releases on the editor stage replace any open document."
title = "Drop a PDF to open" title = "Drop a PDF to open"
@@ -6375,10 +6381,8 @@ noMatches = "No matches"
previous = "Previous match" previous = "Previous match"
replace = "Replace" replace = "Replace"
replaceAll = "Replace all" replaceAll = "Replace all"
replaced = " · {{count}} replaced" replaced = "{{count}} replaced"
replacePlaceholder = "Replace with" replacePlaceholder = "Replace with"
title = "Find & replace"
typeToSearch = "Type to search"
wholeWord = "Whole word" wholeWord = "Whole word"
[pdfTextEditor.fontPicker] [pdfTextEditor.fontPicker]
@@ -6422,29 +6426,16 @@ ariaLabel = "Keyboard shortcuts"
title = "Keyboard shortcuts" title = "Keyboard shortcuts"
tooltip = "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] [pdfTextEditor.help.clipboard]
copyDesc = "Copy selected text" copyDesc = "Copy selected text"
copyKey = "Ctrl+C" copyKey = "Ctrl + C"
cutDesc = "Cut selected (copy + delete)" cutDesc = "Cut selected (copy + delete)"
cutKey = "Ctrl+X" cutKey = "Ctrl + X"
heading = "Clipboard" heading = "Clipboard"
pasteDesc = "Paste clipboard text as new run" pasteDesc = "Paste clipboard text as new run"
pasteKey = "Ctrl+V" pasteKey = "Ctrl + V"
pastePlainDesc = "Paste as plain text" pastePlainDesc = "Paste as plain text"
pastePlainKey = "Ctrl+Shift+V" pastePlainKey = "Ctrl + Shift + V"
[pdfTextEditor.help.document] [pdfTextEditor.help.document]
escDesc = "Clear selection / close find / close help" escDesc = "Clear selection / close find / close help"
@@ -6453,76 +6444,42 @@ heading = "Document"
helpDesc = "This help" helpDesc = "This help"
helpKey = "? / F1" helpKey = "? / F1"
saveDesc = "Save to your workspace" saveDesc = "Save to your workspace"
saveKey = "Ctrl+S" saveKey = "Ctrl + S"
[pdfTextEditor.help.editing] [pdfTextEditor.help.editing]
clickDesc = "Edit text"
clickKey = "Click"
deleteDesc = "Remove selected" deleteDesc = "Remove selected"
deleteKey = "Delete" deleteKey = "Delete"
duplicateDesc = "Duplicate selected" duplicateDesc = "Duplicate selected"
duplicateKey = "Ctrl+D" duplicateKey = "Ctrl + D"
groupDesc = "Group selected runs (Group button)" groupDesc = "Group selected runs into one paragraph"
groupKey = "Ctrl+M" groupKey = "Ctrl + M"
heading = "Editing" heading = "Editing"
marqueeDesc = "Marquee multi-select" marqueeDesc = "Marquee multi-select"
marqueeKey = "Ctrl+Shift+drag" marqueeKey = "Ctrl + Shift + Drag"
moveDesc = "Move text run" moveDesc = "Move text run"
moveKey = "Ctrl+Click + drag" moveKey = "Ctrl + Click + Drag"
selectAllDesc = "Select all" selectAllDesc = "Select all"
selectAllKey = "Ctrl+A" selectAllKey = "Ctrl + A"
shiftClickDesc = "Add / remove a run from selection" shiftClickDesc = "Add / remove a run from selection"
shiftClickKey = "Ctrl+Click / Shift+Click" shiftClickKey = "Ctrl + Click / Shift + Click"
undoRedoDesc = "Undo / Redo" undoRedoDesc = "Undo / Redo"
undoRedoKey = "Ctrl+Z / Ctrl+Y" undoRedoKey = "Ctrl + Z / Ctrl + Y"
ungroupDesc = "Ungroup paragraph: select it, click Ungroup"
ungroupKey = "-"
[pdfTextEditor.help.find] [pdfTextEditor.help.find]
enterFindDesc = "Next match" heading = "Find & replace"
enterFindKey = "Enter (in find)" nextDesc = "Next match (add Shift for previous)"
enterReplaceDesc = "Replace one (Shift = Replace All)" nextKey = "F3 / Ctrl + G"
enterReplaceKey = "Enter (in replace)"
heading = "Find & Replace"
nextDesc = "Next match (Shift = previous)"
nextKey = "F3 / Ctrl+G"
openDesc = "Open find bar (and replace)" openDesc = "Open find bar (and replace)"
openKey = "Ctrl+F" 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"
[pdfTextEditor.help.navigation] [pdfTextEditor.help.navigation]
firstLastDesc = "First / last page" firstLastDesc = "First / last page"
firstLastKey = "Ctrl+Home / Ctrl+End" firstLastKey = "Ctrl + Home / Ctrl + End"
heading = "Navigation" heading = "Navigation"
pageDesc = "Next / previous page" pageDesc = "Previous / next page"
pageKey = "PageDown / PageUp" pageKey = "PageUp / PageDown"
toolbarZoomDesc = "Manual zoom + Fit to width"
toolbarZoomKey = "Toolbar zoom"
zoomDesc = "Zoom in / out" zoomDesc = "Zoom in / out"
zoomKey = "Ctrl+Wheel" zoomKey = "Ctrl + Wheel"
[pdfTextEditor.inspector] [pdfTextEditor.inspector]
document = "Document" document = "Document"
@@ -6579,6 +6536,7 @@ title = "Saving will change this PDF"
[pdfTextEditor.settings] [pdfTextEditor.settings]
advanced = "Advanced" advanced = "Advanced"
find = "Find in document" find = "Find in document"
findTooltip = "Find (Ctrl+F)"
view = "View" view = "View"
[pdfTextEditor.sidebar] [pdfTextEditor.sidebar]
@@ -6619,6 +6577,7 @@ pickPrompt = "Pick a PDF from the Files panel on the left to begin editing."
renderingPreview = "Rendering preview" renderingPreview = "Rendering preview"
[pdfTextEditor.toolbar] [pdfTextEditor.toolbar]
addTextTooltip = "Add a text box - then click the page"
advancedColour = "Advanced colour" advancedColour = "Advanced colour"
advancedColourTooltip = "Advanced colour (glyph outline)" advancedColourTooltip = "Advanced colour (glyph outline)"
alignBottom = "Align bottom" alignBottom = "Align bottom"
@@ -6647,10 +6606,12 @@ flipHorizontal = "Flip horizontal"
flipVertical = "Flip vertical" flipVertical = "Flip vertical"
fontColour = "Font colour" fontColour = "Font colour"
fontSize = "Font size" fontSize = "Font size"
formatting = "Text formatting"
italic = "Italic" italic = "Italic"
italicUnavailable = "This font has no italic version. Load your device fonts or pick another font family." italicUnavailable = "This font has no italic version. Load your device fonts or pick another font family."
lock = "Lock selection" lock = "Lock selection"
lockTooltip = "Lock selection - prevents accidental edits" lockTooltip = "Lock selection - prevents accidental edits"
more = "More actions"
order = "Order" order = "Order"
outlineColour = "Outline colour" outlineColour = "Outline colour"
outlineWidth = "Outline width (0 = none)" outlineWidth = "Outline width (0 = none)"
@@ -155,15 +155,19 @@ test.describe("PDF text editor - edge gestures", () => {
expect(await textOf("Open Source")).toBe(before); 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, page,
}) => { }) => {
await open(page); await open(page);
const panel = page.locator('[data-sidebar="tool-panel"]'); // Insert is a verb aimed at the page, so it sits above the page - the
await expect(panel.getByTestId("pdf-editor-add-text")).toBeVisible(); // panel is for the properties of whatever is already selected.
await expect(panel.getByTestId("pdf-editor-add-image")).toBeVisible(); 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( 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); ).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, page,
}) => { }) => {
await open(page, 0); await open(page, 0);
await expect(page.getByTestId("pdf-editor-save")).toBeVisible(); // Save finishes the job, so it sits at the foot of the side panel - the
await expect(page.getByTestId("pdf-editor-download")).toBeVisible(); // 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. // Zoom sits over the pages it scales, not in the far rail.
const zoom = page.getByTestId("pdf-editor-zoom-controls"); const zoom = page.getByTestId("pdf-editor-zoom-controls");
await expect(zoom).toBeVisible(); await expect(zoom).toBeVisible();
@@ -224,7 +227,7 @@ test.describe("PDF text editor - inspector layout", () => {
}) => { }) => {
await open(page); await open(page);
// Find and the shortcuts sheet are everyday controls, not settings: they // 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-open-find")).toBeVisible();
await expect(page.getByTestId("pdf-editor-help")).toBeVisible(); await expect(page.getByTestId("pdf-editor-help")).toBeVisible();
@@ -250,7 +253,7 @@ test.describe("PDF text editor - inspector layout", () => {
).toBeVisible(); ).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, page,
}) => { }) => {
await open(page, 0); 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); test.setTimeout(180_000);
await openEditorWithTwoFiles(page); await openEditorWithTwoFiles(page);
const opened = ( // The document's own text identifies it, and unlike the toolbar's filename
await page.getByTestId("pdf-editor-filename").innerText() // chip it survives the editor's workbench view being replaced.
).trim(); 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 // 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. // tool's one-file limit; the editor must not follow that onto another file.
await activeFilesTab(page).click(); 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( expect(
page.getByTestId("pdf-editor-filename"), await textOf(),
"the editor swapped the open document out from under the user", "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 ({ 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 = const other =
opened === "sample.pdf" ? "paragraph-sample.pdf" : "sample.pdf"; 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 await page
.getByTestId("pdf-editor-file-switch") .getByTestId("pdf-editor-file-switch")
.filter({ hasText: new RegExp(`^${other}$`) }) .filter({ hasText: new RegExp(`^${other}$`) })
@@ -108,6 +135,7 @@ test.describe("PDF text editor - workbench file selection", () => {
timeout: 60_000, timeout: 60_000,
}); });
// The picked entry is the one marked current. // The picked entry is the one marked current.
await page.getByTestId("pdf-editor-file-switcher").click();
await expect( await expect(
page.locator( page.locator(
'[data-testid="pdf-editor-file-switch"][data-current="true"]', '[data-testid="pdf-editor-file-switch"][data-current="true"]',
@@ -3738,11 +3738,11 @@ test.describe("PDF text editor - dirty state", () => {
await gotoEditor(page); await gotoEditor(page);
await loadSamplePdf(page); await loadSamplePdf(page);
// Save state lives beside the top-bar filename; the sidebar no longer // Save state is a dot beside the top-bar filename - a word would squeeze
// repeats it. Clean on load. // the name it sits next to. Clean on load.
const filename = page.getByTestId("pdf-editor-filename"); const filename = page.getByTestId("pdf-editor-filename");
await expect(filename).toBeVisible(); await expect(filename).toBeVisible();
await expect(filename).not.toContainText("unsaved"); await expect(page.getByTestId("pdf-editor-dirty-dot")).toHaveCount(0);
const firstRunTestId = await page const firstRunTestId = await page
.locator('[data-testid^="pdf-editor-run-p0-"]') .locator('[data-testid^="pdf-editor-run-p0-"]')
@@ -3750,7 +3750,7 @@ test.describe("PDF text editor - dirty state", () => {
.getAttribute("data-testid"); .getAttribute("data-testid");
await typeIntoRun(page, firstRunTestId!, "X"); 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 { useTranslation } from "react-i18next";
import DescriptionIcon from "@mui/icons-material/DescriptionOutlined"; import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
import { downloadFile } from "@app/services/downloadService"; 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 { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
import type { FileId } from "@app/types/file"; import type { FileId } from "@app/types/file";
import type { BaseToolProps } from "@app/types/tool"; import type { BaseToolProps } from "@app/types/tool";
import { useEditorStore } from "@app/tools/pdfTextEditor/hooks/useEditorStore"; import { useEditorStore } from "@app/tools/pdfTextEditor/hooks/useEditorStore";
import { setEditorSession } from "@app/tools/pdfTextEditor/store/EditorSession";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { import {
useDocumentLoader, useDocumentLoader,
ensureAllPagesRead, ensureAllPagesRead,
@@ -19,11 +21,11 @@ import { useEditorTestGlobal } from "@app/tools/pdfTextEditor/hooks/useEditorTes
import { useSelectionActions } from "@app/tools/pdfTextEditor/hooks/useSelectionActions"; import { useSelectionActions } from "@app/tools/pdfTextEditor/hooks/useSelectionActions";
import { useEditorKeyboardShortcuts } from "@app/tools/pdfTextEditor/hooks/useEditorKeyboardShortcuts"; import { useEditorKeyboardShortcuts } from "@app/tools/pdfTextEditor/hooks/useEditorKeyboardShortcuts";
import { useEditorClipboard } from "@app/tools/pdfTextEditor/hooks/useEditorClipboard"; 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 { 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 { 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 { EditorSidebar } from "@app/tools/pdfTextEditor/components/EditorSidebar";
import { EditorFileInputs } from "@app/tools/pdfTextEditor/components/EditorFileInputs"; import { EditorFileInputs } from "@app/tools/pdfTextEditor/components/EditorFileInputs";
import { PageStage } from "@app/tools/pdfTextEditor/components/PageStage"; import { PageStage } from "@app/tools/pdfTextEditor/components/PageStage";
@@ -49,14 +51,13 @@ const INSERTED_IMAGE_RATIO = 0.4;
export default function PdfTextEditor(_props: BaseToolProps) { export default function PdfTextEditor(_props: BaseToolProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isMobile = useIsMobile();
const { store, state } = useEditorStore(); const { store, state } = useEditorStore();
const load = useDocumentLoader(store); const load = useDocumentLoader(store);
const [selection, setSelection] = useState<SelectionState>( const [selection, setSelection] = useState<SelectionState>(
store.selection.value, store.selection.value,
); );
const [findOpen, setFindOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [openedFileName, setOpenedFileName] = useState<string | null>(null); const [openedFileName, setOpenedFileName] = useState<string | null>(null);
// Set only when the document came from the workbench; a drag-dropped // 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 // 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); setSourceFileId(id);
}, []); }, []);
const { addFiles, consumeFiles, selectors } = useFileContext(); const { addFiles, consumeFiles, selectors } = useFileContext();
const { setSelectedFiles } = useFileSelection();
// Saving replaces the workbench file, so for a moment the selection points at // 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. // a file the editor has not adopted yet. Auto-load must sit that out.
const [applying, setApplying] = useState(false); const [applying, setApplying] = useState(false);
@@ -370,21 +372,24 @@ export default function PdfTextEditor(_props: BaseToolProps) {
[store], [store],
); );
const handleFindNext = useCallback((reverse: boolean) => { const handleFindNext = useCallback(
setFindOpen(true); (reverse: boolean) => {
const button = document.querySelector<HTMLButtonElement>( store.setFindOpen(true);
reverse const button = document.querySelector<HTMLButtonElement>(
? '[data-testid="pdf-editor-find-prev"]' reverse
: '[data-testid="pdf-editor-find-next"]', ? '[data-testid="pdf-editor-find-prev"]'
); : '[data-testid="pdf-editor-find-next"]',
button?.click(); );
}, []); button?.click();
},
[store],
);
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
store.selection.clear(); store.selection.clear();
store.setMode("select"); store.setMode("select");
setHelpOpen(false); store.setHelpOpen(false);
setFindOpen(false); store.setFindOpen(false);
}, [store]); }, [store]);
const handleUngroupSelection = useCallback(() => { const handleUngroupSelection = useCallback(() => {
@@ -455,8 +460,11 @@ export default function PdfTextEditor(_props: BaseToolProps) {
.pages.flatMap((p) => p.runs.map((r) => r.id)); .pages.flatMap((p) => p.runs.map((r) => r.id));
if (ids.length > 0) store.selection.selectMany(ids); if (ids.length > 0) store.selection.selectMany(ids);
}, [store]), }, [store]),
onToggleHelp: useCallback(() => setHelpOpen((v) => !v), []), onToggleHelp: useCallback(
onOpenFind: useCallback(() => setFindOpen(true), []), () => store.setHelpOpen(!store.getState().helpOpen),
[store],
),
onOpenFind: useCallback(() => store.setFindOpen(true), [store]),
onFindNext: handleFindNext, onFindNext: handleFindNext,
onEscape: handleEscape, onEscape: handleEscape,
onMergeSelection: handleMergeSelection, onMergeSelection: handleMergeSelection,
@@ -477,8 +485,19 @@ export default function PdfTextEditor(_props: BaseToolProps) {
.find((r) => r.id === selection.runIds[0]); .find((r) => r.id === selection.runIds[0]);
return !!run && (run.paragraphLineCount ?? 0) > 1; return !!run && (run.paragraphLineCount ?? 0) > 1;
})(); })();
const onPickPdf = useCallback( // Opening any document disposes the one in memory - edits, undo history and
(file: File) => { // 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); setOpenedFileName(file.name);
// Dropped/picked from disk: no workbench file to replace yet, but claim // Dropped/picked from disk: no workbench file to replace yet, but claim
// it so a later workbench arrival cannot auto-open over these edits. // it so a later workbench arrival cannot auto-open over these edits.
@@ -486,9 +505,70 @@ export default function PdfTextEditor(_props: BaseToolProps) {
setSourceFile(null); setSourceFile(null);
void load(file); 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( const handleSubmitPassword = useCallback(
(password: string) => { (password: string) => {
const file = store.pendingPasswordFile; const file = store.pendingPasswordFile;
@@ -515,14 +595,13 @@ export default function PdfTextEditor(_props: BaseToolProps) {
</Alert> </Alert>
)} )}
<EditorFileInputs onPickPdf={onPickPdf} onPickImage={handleInsertImage} /> <EditorFileInputs onPickPdf={onPickPdf} onPickImage={handleInsertImage} />
{findOpen && state.hasDocument && ( {/* Declared here, not on the canvas: the canvas unmounts whenever the
<FindBar workbench shows something else, and a modal that only exists while
store={store} the page stack is on screen cannot be opened from the panel. */}
pages={state.pages} <HelpOverlay
onClose={() => setFindOpen(false)} opened={state.helpOpen}
/> onClose={() => store.setHelpOpen(false)}
)} />
<HelpOverlay opened={helpOpen} onClose={() => setHelpOpen(false)} />
<SaveRiskModal <SaveRiskModal
risks={saveRisks} risks={saveRisks}
onConfirm={handleConfirmSaveRisk} onConfirm={handleConfirmSaveRisk}
@@ -534,6 +613,11 @@ export default function PdfTextEditor(_props: BaseToolProps) {
onSubmit={handleSubmitPassword} onSubmit={handleSubmitPassword}
onCancel={handleCancelPassword} onCancel={handleCancelPassword}
/> />
<DiscardChangesModal
incomingFileName={pendingOpen?.file.name ?? null}
onConfirm={confirmPendingOpen}
onCancel={() => setPendingOpen(null)}
/>
<EditorSidebar <EditorSidebar
store={store} store={store}
state={state} state={state}
@@ -545,30 +629,29 @@ export default function PdfTextEditor(_props: BaseToolProps) {
onSetGroupingMode={(mode) => store.setGroupingMode(mode)} onSetGroupingMode={(mode) => store.setGroupingMode(mode)}
onSetWidthMode={(m) => store.setWidthMode(m)} onSetWidthMode={(m) => store.setWidthMode(m)}
onSetShowRulers={(show) => store.setShowRulers(show)} 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 && ( {state.hasDocument && (
<EditorSaveBar <EditorPanelActions
compact={isMobile}
openedFileName={openedFileName} openedFileName={openedFileName}
dirty={state.dirty} dirty={state.dirty}
currentFileId={sourceFileId} currentFileId={sourceFileId}
onPickFile={openWorkbenchFile} onPickFile={onPickWorkbenchFile}
onSave={handleSave} onSave={handleSave}
onDownload={handleDownload} 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> </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. // previous undo step however long ago that step ran.
coalesceIgnoresTimeWindow?(previous: Command | null): boolean; 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"; import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument";
/** Groups several already-applied commands into one undo/redo step. */ /** Groups several already-applied commands into one undo/redo step. */
@@ -21,12 +24,47 @@ export class CompositeCommand implements Command {
} }
apply(doc: EditorDocument): void { apply(doc: EditorDocument): void {
for (const cmd of this.commands) cmd.apply(doc); this.run(doc, this.commands, "apply");
} }
revert(doc: EditorDocument): void { revert(doc: EditorDocument): void {
for (let i = this.commands.length - 1; i >= 0; i--) { const reversed = [...this.commands].reverse();
this.commands[i].revert(doc); 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. // emits land at the same baselines we just established.
run.paragraphLineHeight = lineHeight; 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 = []; 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 { useTranslation } from "react-i18next";
import DescriptionIcon from "@mui/icons-material/DescriptionOutlined"; 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 { 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"; import type { FileId } from "@app/types/file";
interface Props { interface Props {
/** Workbench file the editor currently holds, when it came from one. */ /** Workbench file the editor currently holds, when it came from one. */
currentFileId: FileId | null; 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; 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 * Sits at the top-left of the toolbar because that is where a document editor
* a view away; without this the user can open the tool with several files * puts the document's name; picking from it is the same gesture as any other
* loaded and have no way to say which one to edit. Picking here sets the * app's file title menu. With fewer than two PDFs in the workbench there is
* workbench selection rather than loading directly, so the rest of the app * nothing to switch to, so it collapses to a plain name.
* agrees about which file is being worked on.
*/ */
export function EditorFileSwitcher({ currentFileId, onPick }: Props) { export function EditorFileSwitcher({
currentFileId,
currentFileName,
dirty,
onPick,
}: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { files } = useAllFiles(); const { files } = useAllFiles();
const { setSelectedFiles } = useFileSelection();
const pdfs = files.filter((f) => /\.pdf$/i.test(f.name)); 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 ( return (
<Stack gap={4} data-testid="pdf-editor-file-switcher"> <Menu shadow="md" position="bottom-start" withinPortal closeOnItemClick>
<Text size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.4px" }}> <Menu.Target>
{t("pdfTextEditor.sidebar.document", "Document")} <Button
</Text> size="sm"
{pdfs.map((file) => { variant="tertiary"
const fileId = (file as File & { fileId?: FileId }).fileId; accent="neutral"
const current = fileId != null && fileId === currentFileId; rightSection={<ExpandMoreIcon fontSize="small" />}
return ( data-testid="pdf-editor-file-switcher"
<Button title={currentFileName}
key={fileId ?? file.name} >
size="sm" <span data-testid="pdf-editor-filename">{label}</span>
fullWidth </Button>
justify="start" </Menu.Target>
overflow="hidden" <Menu.Dropdown data-testid="pdf-editor-file-switcher-menu">
variant={current ? "primary" : "secondary"} <Menu.Label>
accent={current ? "default" : "neutral"} {t("pdfTextEditor.sidebar.document", "Document")}
leftSection={<DescriptionIcon fontSize="small" />} </Menu.Label>
title={file.name} {pdfs.map((file) => {
data-testid="pdf-editor-file-switch" const fileId = (file as File & { fileId?: FileId }).fileId;
data-current={current ? "true" : "false"} const current = fileId != null && fileId === currentFileId;
disabled={fileId == null} return (
onClick={() => { <Menu.Item
if (fileId == null) return; key={fileId ?? file.name}
setSelectedFiles([fileId]); leftSection={
onPick(file); current ? (
}} <CheckIcon fontSize="small" />
> ) : (
{file.name} <DescriptionIcon fontSize="small" />
</Button> )
); }
})} disabled={fileId == null}
</Stack> 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 { 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 { useTranslation } from "react-i18next";
import HighlightAltIcon from "@mui/icons-material/HighlightAltOutlined"; 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 { useToolbarController } from "@app/tools/pdfTextEditor/hooks/useToolbarController";
import { useSelectionGeometry } from "@app/tools/pdfTextEditor/hooks/useSelectionGeometry"; import { useSelectionGeometry } from "@app/tools/pdfTextEditor/hooks/useSelectionGeometry";
import { DocumentInspector } from "@app/tools/pdfTextEditor/components/inspector/DocumentInspector"; import { DocumentInspector } from "@app/tools/pdfTextEditor/components/inspector/DocumentInspector";
@@ -34,23 +29,18 @@ interface SidebarProps {
onSetGroupingMode: (mode: GroupingMode) => void; onSetGroupingMode: (mode: GroupingMode) => void;
onSetWidthMode: (mode: WidthMode) => void; onSetWidthMode: (mode: WidthMode) => void;
onSetShowRulers: (show: boolean) => 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"; 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 * Two tabs. "Selected" only ever shows what can act on what is picked right
* act on what is picked right now; "Document" holds the facts about the file; * now; "Document" holds the facts about the file and its reading settings.
* everything set-and-forget lives behind the menu. Nothing that never changes * The verbs - insert, find, save, help - moved to the canvas top bar, where a
* competes for space with the thing the user is actually editing. * 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({ export function EditorSidebar({
store, store,
@@ -63,11 +53,6 @@ export function EditorSidebar({
onSetGroupingMode, onSetGroupingMode,
onSetWidthMode, onSetWidthMode,
onSetShowRulers, onSetShowRulers,
onOpenFind,
onShowHelp,
addTextArmed,
onToggleAddText,
onPickImage,
}: SidebarProps) { }: SidebarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const controller = useToolbarController(store, state, selection); const controller = useToolbarController(store, state, selection);
@@ -121,70 +106,6 @@ export function EditorSidebar({
{t("pdfTextEditor.inspector.tabDocument", "Document")} {t("pdfTextEditor.inspector.tabDocument", "Document")}
</Tabs.Tab> </Tabs.Tab>
</Tabs.List> </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> </Group>
<Box> <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 { 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 { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import CloseIcon from "@mui/icons-material/Close"; 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 { EditTextCommand } from "@app/tools/pdfTextEditor/commands/EditTextCommand";
import { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand"; import { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand";
import { import {
@@ -194,158 +197,212 @@ export function FindBar({ store, pages, onClose }: FindBarProps) {
setReplaceCount(n); setReplaceCount(n);
}, [query, replace, matches, store]); }, [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 ( return (
<Stack gap="xs" p="sm" data-testid="pdf-editor-find-bar"> <div className="pdf-editor-findbar" data-testid="pdf-editor-find-bar">
<Group justify="space-between"> <div className="pdf-editor-findbar__grid">
<Text size="sm" fw={500}> <TextInput
{t("pdfTextEditor.find.title", "Find & replace")} ref={inputRef}
</Text> 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 <Button
variant="tertiary" variant="tertiary"
accent="neutral"
size="sm" size="sm"
className="pdf-editor-findbar__close"
onClick={onClose} onClick={onClose}
aria-label={t("pdfTextEditor.find.close", "Close find bar")} aria-label={t("pdfTextEditor.find.close", "Close find bar")}
data-testid="pdf-editor-find-close" data-testid="pdf-editor-find-close"
leftSection={<CloseIcon fontSize="small" />} leftSection={<CloseIcon fontSize="small" />}
/> />
</Group> </Tooltip>
<TextInput </div>
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>
); );
} }
@@ -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 { useTranslation } from "react-i18next";
import { KeyCombo } from "@app/ui/KeyCombo";
import "@app/tools/pdfTextEditor/components/HelpOverlay.css";
interface HelpOverlayProps { interface HelpOverlayProps {
opened: boolean; opened: boolean;
onClose: () => void; 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) { export function HelpOverlay({ opened, onClose }: HelpOverlayProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const SHORTCUTS: Array<{ const SHORTCUTS: Section[] = [
heading: string;
items: Array<[string, string]>;
}> = [
{ {
heading: t("pdfTextEditor.help.editing.heading", "Editing"), heading: t("pdfTextEditor.help.editing.heading", "Editing"),
items: [ items: [
[ {
t("pdfTextEditor.help.editing.clickKey", "Click"), keys: t(
t("pdfTextEditor.help.editing.clickDesc", "Edit text"),
],
[
t(
"pdfTextEditor.help.editing.shiftClickKey", "pdfTextEditor.help.editing.shiftClickKey",
"Ctrl+Click / Shift+Click", "Ctrl + Click / Shift + Click",
), ),
t( description: t(
"pdfTextEditor.help.editing.shiftClickDesc", "pdfTextEditor.help.editing.shiftClickDesc",
"Add / remove a run from selection", "Add / remove a run from selection",
), ),
], },
[ {
t("pdfTextEditor.help.editing.marqueeKey", "Ctrl+Shift+drag"), keys: t(
t("pdfTextEditor.help.editing.marqueeDesc", "Marquee multi-select"), "pdfTextEditor.help.editing.marqueeKey",
], "Ctrl + Shift + Drag",
[ ),
t("pdfTextEditor.help.editing.groupKey", "Ctrl+M"), description: t(
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", "pdfTextEditor.help.editing.groupDesc",
"Group selected runs (Group button)", "Group selected runs into one paragraph",
), ),
], },
[ {
t("pdfTextEditor.help.editing.ungroupKey", "-"), keys: t("pdfTextEditor.help.editing.duplicateKey", "Ctrl + D"),
t( description: t(
"pdfTextEditor.help.editing.ungroupDesc", "pdfTextEditor.help.editing.duplicateDesc",
"Ungroup paragraph: select it, click Ungroup", "Duplicate selected",
), ),
], },
[ {
t("pdfTextEditor.help.editing.moveKey", "Ctrl+Click + drag"), keys: t("pdfTextEditor.help.editing.deleteKey", "Delete"),
t("pdfTextEditor.help.editing.moveDesc", "Move text run"), description: t(
], "pdfTextEditor.help.editing.deleteDesc",
[ "Remove selected",
t("pdfTextEditor.help.editing.deleteKey", "Delete"), ),
t("pdfTextEditor.help.editing.deleteDesc", "Remove selected"), },
], {
[ keys: t(
t("pdfTextEditor.help.editing.duplicateKey", "Ctrl+D"), "pdfTextEditor.help.editing.undoRedoKey",
t("pdfTextEditor.help.editing.duplicateDesc", "Duplicate selected"), "Ctrl + Z / Ctrl + Y",
], ),
[ description: t(
t("pdfTextEditor.help.editing.selectAllKey", "Ctrl+A"), "pdfTextEditor.help.editing.undoRedoDesc",
t("pdfTextEditor.help.editing.selectAllDesc", "Select all"), "Undo / Redo",
], ),
[ },
t("pdfTextEditor.help.editing.undoRedoKey", "Ctrl+Z / Ctrl+Y"),
t("pdfTextEditor.help.editing.undoRedoDesc", "Undo / Redo"),
],
], ],
}, },
{ {
heading: t("pdfTextEditor.help.clipboard.heading", "Clipboard"), heading: t("pdfTextEditor.help.clipboard.heading", "Clipboard"),
items: [ items: [
[ {
t("pdfTextEditor.help.clipboard.copyKey", "Ctrl+C"), keys: t("pdfTextEditor.help.clipboard.copyKey", "Ctrl + C"),
t("pdfTextEditor.help.clipboard.copyDesc", "Copy selected text"), description: t(
], "pdfTextEditor.help.clipboard.copyDesc",
[ "Copy selected text",
t("pdfTextEditor.help.clipboard.cutKey", "Ctrl+X"), ),
t( },
{
keys: t("pdfTextEditor.help.clipboard.cutKey", "Ctrl + X"),
description: t(
"pdfTextEditor.help.clipboard.cutDesc", "pdfTextEditor.help.clipboard.cutDesc",
"Cut selected (copy + delete)", "Cut selected (copy + delete)",
), ),
], },
[ {
t("pdfTextEditor.help.clipboard.pasteKey", "Ctrl+V"), keys: t("pdfTextEditor.help.clipboard.pasteKey", "Ctrl + V"),
t( description: t(
"pdfTextEditor.help.clipboard.pasteDesc", "pdfTextEditor.help.clipboard.pasteDesc",
"Paste clipboard text as new run", "Paste clipboard text as new run",
), ),
], },
[ {
t("pdfTextEditor.help.clipboard.pastePlainKey", "Ctrl+Shift+V"), keys: t(
t( "pdfTextEditor.help.clipboard.pastePlainKey",
"Ctrl + Shift + V",
),
description: t(
"pdfTextEditor.help.clipboard.pastePlainDesc", "pdfTextEditor.help.clipboard.pastePlainDesc",
"Paste as plain text", "Paste as plain text",
), ),
], },
], ],
}, },
{ {
heading: t("pdfTextEditor.help.find.heading", "Find & Replace"), heading: t("pdfTextEditor.help.find.heading", "Find & replace"),
items: [ items: [
[ {
t("pdfTextEditor.help.find.openKey", "Ctrl+F"), keys: t("pdfTextEditor.help.find.openKey", "Ctrl + F"),
t("pdfTextEditor.help.find.openDesc", "Open find bar (and replace)"), description: t(
], "pdfTextEditor.help.find.openDesc",
[ "Open find bar (and replace)",
t("pdfTextEditor.help.find.nextKey", "F3 / Ctrl+G"), ),
t( },
{
keys: t("pdfTextEditor.help.find.nextKey", "F3 / Ctrl + G"),
description: t(
"pdfTextEditor.help.find.nextDesc", "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"), heading: t("pdfTextEditor.help.navigation.heading", "Navigation"),
items: [ items: [
[ {
t("pdfTextEditor.help.navigation.pageKey", "PageDown / PageUp"), keys: t("pdfTextEditor.help.navigation.pageKey", "PageUp / PageDown"),
t("pdfTextEditor.help.navigation.pageDesc", "Next / previous page"), description: t(
], "pdfTextEditor.help.navigation.pageDesc",
[ "Previous / next page",
t( ),
},
{
keys: t(
"pdfTextEditor.help.navigation.firstLastKey", "pdfTextEditor.help.navigation.firstLastKey",
"Ctrl+Home / Ctrl+End", "Ctrl + Home / Ctrl + End",
), ),
t("pdfTextEditor.help.navigation.firstLastDesc", "First / last page"), description: 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",
), ),
], },
{
keys: t("pdfTextEditor.help.navigation.zoomKey", "Ctrl + Wheel"),
description: t(
"pdfTextEditor.help.navigation.zoomDesc",
"Zoom in / out",
),
},
], ],
}, },
{ {
heading: t("pdfTextEditor.help.document.heading", "Document"), heading: t("pdfTextEditor.help.document.heading", "Document"),
items: [ items: [
[ {
t("pdfTextEditor.help.document.saveKey", "Ctrl+S"), keys: t("pdfTextEditor.help.document.saveKey", "Ctrl + S"),
t("pdfTextEditor.help.document.saveDesc", "Save to your workspace"), description: t(
], "pdfTextEditor.help.document.saveDesc",
[ "Save to your workspace",
t("pdfTextEditor.help.document.helpKey", "? / F1"), ),
t("pdfTextEditor.help.document.helpDesc", "This help"), },
], {
[ keys: t("pdfTextEditor.help.document.helpKey", "? / F1"),
t("pdfTextEditor.help.document.escKey", "Esc"), description: t("pdfTextEditor.help.document.helpDesc", "This help"),
t( },
{
keys: t("pdfTextEditor.help.document.escKey", "Esc"),
description: t(
"pdfTextEditor.help.document.escDesc", "pdfTextEditor.help.document.escDesc",
"Clear selection / close find / close help", "Clear selection / close find / close help",
), ),
], },
], ],
}, },
]; ];
@@ -291,29 +220,31 @@ export function HelpOverlay({ opened, onClose }: HelpOverlayProps) {
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("pdfTextEditor.help.title", "Keyboard shortcuts")} title={t("pdfTextEditor.help.title", "Keyboard shortcuts")}
size="md" size="lg"
data-testid="pdf-editor-help-overlay" data-testid="pdf-editor-help-overlay"
> >
<Stack gap="md"> <Stack gap="lg">
{SHORTCUTS.map((section, sectionIdx) => ( {SHORTCUTS.map((section) => (
<Stack key={section.heading} gap="xs"> <section key={section.heading}>
{sectionIdx > 0 && <Divider />} <h3 className="pdf-editor-help__section-title">
<Text fw={600} size="sm">
{section.heading} {section.heading}
</Text> </h3>
{section.items.map(([keys, desc]) => ( <div className="pdf-editor-help__card">
<Group {section.items.map((item) => (
key={`${section.heading}-${keys}`} <div
justify="space-between" className="pdf-editor-help__row"
wrap="nowrap" key={`${section.heading}-${item.description}`}
> >
<Text size="sm" c="dimmed"> <Text className="pdf-editor-help__label">
{desc} {item.description}
</Text> </Text>
<Kbd>{keys}</Kbd> <span className="pdf-editor-help__keys">
</Group> <KeyCombo combo={item.keys} />
))} </span>
</Stack> </div>
))}
</div>
</section>
))} ))}
</Stack> </Stack>
</Modal> </Modal>
@@ -11,7 +11,8 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEditorStore } from "@app/tools/pdfTextEditor/hooks/useEditorStore"; import { useEditorStore } from "@app/tools/pdfTextEditor/hooks/useEditorStore";
import { ensurePageRead } from "@app/tools/pdfTextEditor/hooks/useDocumentLoader"; 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 { useToolbarController } from "@app/tools/pdfTextEditor/hooks/useToolbarController";
import { ZoomPill } from "@app/tools/pdfTextEditor/components/ZoomPill"; import { ZoomPill } from "@app/tools/pdfTextEditor/components/ZoomPill";
import { MarqueeSelector } from "@app/tools/pdfTextEditor/components/MarqueeSelector"; 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 // inspector derives from the same controller, so both surfaces read one
// source of truth. // source of truth.
const controller = useToolbarController(store, state, selection); 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) { if (!state.hasDocument && !state.loading) {
return ( return (
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}> <Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
<Toolbar {...toolbar} /> {topBar}
<Center <Center
style={{ flex: 1, minHeight: 0 }} style={{ flex: 1, minHeight: 0 }}
data-testid="pdf-editor-stage-empty" data-testid="pdf-editor-stage-empty"
@@ -110,7 +129,14 @@ export function PageStage() {
return ( return (
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}> <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 <Box
pos="relative" pos="relative"
ref={stageRootRef} ref={stageRootRef}
@@ -142,19 +168,9 @@ export function PageStage() {
(f) => f.type === "application/pdf" || /\.pdf$/i.test(f.name), (f) => f.type === "application/pdf" || /\.pdf$/i.test(f.name),
); );
if (!pdf) return; if (!pdf) return;
// Replacing the open document discards in-progress edits - confirm // Replacing the open document discards in-progress edits. The panel
// first when dirty, so an accidental drop can't silently lose work. // owns that confirmation (DiscardChangesModal) for every route in,
if ( // so the drop just hands the file over.
store.getState().dirty &&
!window.confirm(
t(
"pdfTextEditor.confirmReplaceDirty",
"You have unsaved changes. Replace the open document and discard them?",
),
)
) {
return;
}
const input = document.querySelector<HTMLInputElement>( const input = document.querySelector<HTMLInputElement>(
'[data-testid="pdf-editor-file-input"]', '[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 type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument";
import { assertSavedPdf } from "@app/tools/pdfTextEditor/util/savedBytes";
/** `FPDF_SaveAsCopy` flags. */ /** `FPDF_SaveAsCopy` flags. */
const FPDF_INCREMENTAL = 1; 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(); const writerPtr = m.PDFiumExt_OpenFileWriter();
try { try {
// The writer the shim hands back is the FPDF_FILEWRITE the flagged // PDFium reports a failed save by return code, not by throwing. Ignoring
// entry point expects, so incremental mode needs no extra plumbing. // it meant a failure produced an empty/partial buffer that the editor
const withFlags = (m as unknown as SaveFlagsModule).FPDF_SaveAsCopy; // then wrote over the user's file.
if (options.incremental && typeof withFlags === "function") { const ok =
withFlags(doc.docPtr, writerPtr, FPDF_INCREMENTAL); options.incremental && typeof withFlags === "function"
} else { ? withFlags(doc.docPtr, writerPtr, FPDF_INCREMENTAL)
m.PDFiumExt_SaveAsCopy(doc.docPtr, writerPtr); : 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 size = m.PDFiumExt_GetFileWriterSize(writerPtr);
const outBuf = m.pdfium.wasmExports.malloc(size); const outBuf = m.pdfium.wasmExports.malloc(size);
@@ -62,6 +82,7 @@ export class PdfiumSave {
size, size,
); );
view.set(heap); view.set(heap);
assertSavedPdf(view);
return view; return view;
} finally { } finally {
m.pdfium.wasmExports.free(outBuf); m.pdfium.wasmExports.free(outBuf);
@@ -23,10 +23,20 @@ export class PdfiumTextWriter {
*/ */
static commitRunText(doc: EditorDocument, page: Page, run: TextRun): boolean { static commitRunText(doc: EditorDocument, page: Page, run: TextRun): boolean {
if (!run.pdfiumObjPtr) return false; 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 m = doc.module;
const ptr = writeUtf16(m, run.text); const ptr = writeUtf16(m, run.text);
try { try {
m.FPDFText_SetText(run.pdfiumObjPtr, ptr); 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 { } finally {
m.pdfium.wasmExports.free(ptr); 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 { 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 { Selection } from "@app/tools/pdfTextEditor/store/Selection";
import { pageGuides } from "@app/tools/pdfTextEditor/util/guides"; import { pageGuides } from "@app/tools/pdfTextEditor/util/guides";
import { PdfiumTextReader } from "@app/tools/pdfTextEditor/pdfium/PdfiumTextReader"; import { PdfiumTextReader } from "@app/tools/pdfTextEditor/pdfium/PdfiumTextReader";
@@ -78,6 +81,11 @@ export interface EditorViewState {
widthMode: WidthMode; widthMode: WidthMode;
/** Show per-page rulers and alignment guides. */ /** Show per-page rulers and alignment guides. */
showRulers: boolean; 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; const POSITION_REFRESH_MS = 600;
@@ -101,6 +109,8 @@ const INITIAL: EditorViewState = {
groupingMode: "auto", groupingMode: "auto",
widthMode: "grow", widthMode: "grow",
showRulers: false, showRulers: false,
findOpen: false,
helpOpen: false,
}; };
// Single observable store for the editor's React layer. Components never reach // Single observable store for the editor's React layer. Components never reach
@@ -203,6 +213,16 @@ export class EditorStore {
this.patch({ showRulers }); 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 { get groupingMode(): GroupingMode {
return this.state.groupingMode; return this.state.groupingMode;
} }
@@ -301,7 +321,14 @@ export class EditorStore {
/** Apply a command via the history stack, re-snapshot, and notify. */ /** Apply a command via the history stack, re-snapshot, and notify. */
dispatch(cmd: Command): void { dispatch(cmd: Command): void {
if (!this.doc) return; 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.resnapshot();
this.patch({ dirty: this.isDirty() }); this.patch({ dirty: this.isDirty() });
this.schedulePositionRefresh(); this.schedulePositionRefresh();
@@ -391,8 +418,8 @@ export class EditorStore {
if (!this.doc) return; if (!this.doc) return;
try { try {
this.history.undo(this.doc); this.history.undo(this.doc);
} catch { } catch (err) {
this.recoverFromBrokenStep(); this.recoverFromBrokenStep(err);
return; return;
} }
this.resnapshot(); this.resnapshot();
@@ -403,8 +430,8 @@ export class EditorStore {
if (!this.doc) return; if (!this.doc) return;
try { try {
this.history.redo(this.doc); this.history.redo(this.doc);
} catch { } catch (err) {
this.recoverFromBrokenStep(); this.recoverFromBrokenStep(err);
return; return;
} }
this.resnapshot(); this.resnapshot();
@@ -412,10 +439,23 @@ export class EditorStore {
} }
// A half-applied command leaves the run model describing objects that no // A half-applied command leaves the run model describing objects that no
// longer match the page, so rebuild it from PDFium rather than guess. // longer match the page, so rebuild it from PDFium rather than guess. A
private recoverFromBrokenStep(): void { // command that rolled itself back changed nothing, so it keeps its history.
private recoverFromBrokenStep(err?: unknown): void {
const doc = this.doc; const doc = this.doc;
if (!doc) return; 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); this.repopulateAllPages(doc, this.state.groupingMode);
// Rebuilt runs get fresh ids, so no existing history entry can apply. // Rebuilt runs get fresh ids, so no existing history entry can apply.
this.history.clear(); this.history.clear();
@@ -423,7 +463,7 @@ export class EditorStore {
this.bakedDirty = true; this.bakedDirty = true;
this.selection.clear(); this.selection.clear();
this.resnapshot(); 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. */ /** 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 { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand";
import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument";
@@ -12,12 +15,16 @@ const COALESCE_WINDOW_MS = 600;
export class HistoryStepError extends Error { export class HistoryStepError extends Error {
readonly phase: "apply" | "revert"; readonly phase: "apply" | "revert";
readonly cause: unknown; 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) { constructor(phase: "apply" | "revert", cause: unknown) {
super(`Command failed to ${phase}`); super(`Command failed to ${phase}`);
this.name = "HistoryStepError"; this.name = "HistoryStepError";
this.phase = phase; 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 // Read the clock BEFORE apply: the window is meant to measure the user's
// idle time between edits. // idle time between edits.
const startedAt = Date.now(); 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 key = cmd.coalesceKey?.() ?? null;
const top = this.undoStack[this.undoStack.length - 1]; const top = this.undoStack[this.undoStack.length - 1];
// The command a merge would join. Unwrap a group to its most recent // 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 type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument";
import { preserveShadings } from "@app/tools/pdfTextEditor/pdfdoc/passes/preserveShadings"; import { preserveShadings } from "@app/tools/pdfTextEditor/pdfdoc/passes/preserveShadings";
import { PdfiumSave } from "@app/tools/pdfTextEditor/pdfium/PdfiumSave"; 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. */ /** Serialize the editor document to a Blob plus the download filename. */
export async function exportToBlob( export async function exportToBlob(
@@ -32,12 +33,21 @@ export async function exportToBlob(
const repaired = await preserveShadings(bytes, doc.openedBytes, { const repaired = await preserveShadings(bytes, doc.openedBytes, {
pages: regenerated, 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 { } catch {
/* the unrepaired save is still a correct save */ /* 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) }; 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/Surface";
export * from "@app/ui/PanelHeader"; export * from "@app/ui/PanelHeader";
export * from "@app/ui/CodeBlock"; export * from "@app/ui/CodeBlock";
export * from "@app/ui/KeyCombo";
export * from "@app/ui/SectionDivider"; export * from "@app/ui/SectionDivider";
export * from "@app/ui/CarouselDots"; export * from "@app/ui/CarouselDots";
export * from "@app/ui/Card"; export * from "@app/ui/Card";