mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
feat(editor): guard unsaved edits and rework the editor toolbar
This commit is contained in:
@@ -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 side‑stitch booklet printing (optimized for binding on the side)."
|
SIDE_STITCH_BOOKLET_SORT = "Arrange pages for side‑stitch 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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user