diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d073a45b..dae2346c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- The pdf view reports the pending annotation count on + `odr.onAnnotationChange({count})`, and `odr.annotation.press` and `recolor` + do what a tool button does. + - The wasm `Document.edit`, `save`, `isEditable` and `isSavable` work for a plain text file, which saves as UTF-8. diff --git a/docs/design/editing.md b/docs/design/editing.md index b28ee3acd..664e02e49 100644 --- a/docs/design/editing.md +++ b/docs/design/editing.md @@ -483,4 +483,6 @@ version drifting until a new check page caught it. `odr.annotation` is its own API and `PdfFile::annotate` its own write path. It is a different gesture from editing text, so whether it should share the mode is a real question rather than an oversight - ([`txt-editing.md`](txt-editing.md) carries it too). + ([`txt-editing.md`](txt-editing.md) carries it too). It reports on a + callback of its own, `odr.onAnnotationChange`, and a pdf page does not + carry `editing.js`. diff --git a/src/odr/internal/html/frontend/pdf-annotation.js b/src/odr/internal/html/frontend/pdf-annotation.js index 555953b14..1bb0a3143 100644 --- a/src/odr/internal/html/frontend/pdf-annotation.js +++ b/src/odr/internal/html/frontend/pdf-annotation.js @@ -18,6 +18,21 @@ overscrollBehavior: "contain", }; + /// Raised as the pending count changes; a stroke counts once it ends. + odr.onAnnotationChange = function () {}; + + var reported = 0; + + function changed() { + if (pending.length === reported) { + return; + } + reported = pending.length; + if (typeof odr.onAnnotationChange === "function") { + odr.onAnnotationChange({ count: pending.length }); + } + } + function pages() { return Array.prototype.slice.call( document.querySelectorAll("[data-odr-space]") @@ -269,8 +284,8 @@ /// One annotation per page the selection covers. `keep` holds the selection, /// which the automatic path cannot: the next `selectionchange` re-marks it. - function markSelection(keep) { - if (!tool || tool === "ink") { + function markSelection(type, rgb, keep) { + if (!type || type === "ink") { return false; } var byPage = selectionBoxes(); @@ -279,9 +294,9 @@ pending.push({ id: nextId++, page: +index, - type: tool, + type: type, boxes: byPage[index], - color: color.slice(), + color: rgb.slice(), }); added = true; }); @@ -290,10 +305,41 @@ window.getSelection().removeAllRanges(); } redraw(); + changed(); } return added; } + function arm(value) { + tool = value || null; + pages().forEach(function (page) { + page.classList.toggle("an-draw", tool === "ink"); + }); + document.documentElement.classList.toggle("an-drawing", tool === "ink"); + } + + function rgbOf(value) { + return value.slice(0, 3).map(Number); + } + + function applyStyle(style) { + if (style && style.color) { + color = rgbOf(style.color); + } + if (style && style.width !== undefined) { + width = Number(style.width); + } + } + + /// Marks the selection once and disarms, if there is anything to mark. + function markOnce(type, style) { + var rgb = style && style.color ? rgbOf(style.color) : color; + if (!markSelection(type, rgb, false)) { + return false; + } + arm(null); + return true; + } var stroke = null; var strokeNode = null; @@ -326,7 +372,7 @@ } window.clearTimeout(settle); settle = window.setTimeout(function () { - markSelection(false); + markSelection(tool, color, false); }, 50); } @@ -415,6 +461,7 @@ stroke = null; strokeNode = null; strokePointer = null; + changed(); } function applyOptions() { @@ -433,19 +480,35 @@ odr.annotation = { /// null, "highlight", "underline", "strikeOut", "squiggly" or "ink". - setTool: function (value) { - tool = value || null; - pages().forEach(function (page) { - page.classList.toggle("an-draw", tool === "ink"); - }); - document.documentElement.classList.toggle("an-drawing", tool === "ink"); - }, + setTool: arm, getTool: function () { return tool; }, + /// A tool button: marks a selection once, else arms @p type or disarms + /// it. @p style is `{color, width}`. Answers the tool left armed. + press: function (type, style) { + if (markOnce(type, style)) { + return tool; + } + if (type && type === tool) { + arm(null); + } else { + applyStyle(style); + arm(type); + } + return tool; + }, + /// Marks a selection once, as `press` does, else restyles @p type if it is + /// armed. Answers the tool left armed. + recolor: function (type, style) { + if (!markOnce(type, style) && type && type === tool) { + applyStyle(style); + } + return tool; + }, /// DeviceRGB, each component in [0, 1]. setColor: function (value) { - color = value.slice(0, 3).map(Number); + color = rgbOf(value); }, setWidth: function (value) { width = Number(value); @@ -470,7 +533,7 @@ /// Marks the selection with the armed tool, and answers whether anything /// was added. The selection is left standing. mark: function () { - return markSelection(true); + return markSelection(tool, color, true); }, /// What is pending, newest last. Geometry is in page-box points. list: function () { @@ -481,14 +544,17 @@ return a.id !== id; }); redraw(); + changed(); }, undo: function () { pending.pop(); redraw(); + changed(); }, clear: function () { pending = []; redraw(); + changed(); }, /// The payload `PdfFile::annotate` takes, in pdf user space. getAnnotations: function () { diff --git a/test/browser/annotation/tests.html b/test/browser/annotation/tests.html index 09493478b..a194a3d53 100644 --- a/test/browser/annotation/tests.html +++ b/test/browser/annotation/tests.html @@ -59,6 +59,7 @@ Selectable text on page two +

Text on no page

@@ -347,6 +348,114 @@ api.clear(); api.setTool(null); + + // --- the host hears of every change to what is pending ---------------- + const heard = []; + window.odr.onAnnotationChange = (event) => heard.push(event.count); + api.setTool("highlight"); + selectRun(0); + api.mark(); + check("a mark is reported", heard.join() === "1", heard); + api.undo(); + check("an undo is reported", heard.join() === "1,0", heard); + api.undo(); + api.clear(); + check("an undo or a clear that changes nothing is not", heard.length === 2, heard); + + heard.length = 0; + api.setTool("ink"); + document.dispatchEvent(new PointerEvent("pointerdown", at(100, 500))); + document.dispatchEvent(new PointerEvent("pointermove", at(140, 540))); + check("a stroke is not reported while it is drawn", heard.length === 0, heard); + document.dispatchEvent(new PointerEvent("pointerup", at(140, 540))); + check("but once it ends", heard.join() === "1", heard); + api.remove(api.list()[0].id); + check("a removal is reported", heard.join() === "1,0", heard); + api.setTool(null); + + // --- a tool button: mark a standing selection, or arm and disarm ------ + heard.length = 0; + selectRun(0); + check( + "a press over a selection arms nothing", + api.press("underline", { color: [0, 0, 1] }) === null + ); + const pressed = payload().annotations; + check( + "and marks it once, in the colour pressed", + pressed.length === 1 && + pressed[0].type === "underline" && + pressed[0].color.join() === "0,0,1", + pressed + ); + check("the selection goes with it", window.getSelection().isCollapsed); + check("the host hears of it", heard.join() === "1", heard); + + check("a press with nothing selected arms", api.press("highlight") === "highlight"); + check("a second press disarms", api.press("highlight") === null); + api.press("highlight", { color: [1, 0, 0] }); + check("a press on another tool swaps", api.press("squiggly") === "squiggly"); + check("null disarms", api.press(null) === null); + const outside = document.createRange(); + outside.selectNodeContents(document.getElementById("outside")); + window.getSelection().removeAllRanges(); + window.getSelection().addRange(outside); + check( + "a press over a selection on no page arms", + api.press("underline") === "underline" && api.list().length === 1, + api.list() + ); + api.press(null); + selectRun(0); + check("the pen arms over a selection too", api.press("ink", { width: 4 }) === "ink"); + check("it marks nothing", api.list().length === 1, api.list()); + api.press(null); + + // the selection a press clears must not come back as a second mark + api.clear(); + api.setOptions({ markOnSelection: true }); + selectRun(0); + api.press("strikeOut"); + await new Promise((r) => setTimeout(r, 100)); + check("with markOnSelection, still once", api.list().length === 1, api.list()); + api.setOptions({ markOnSelection: false }); + + // --- a new colour marks a selection, or restyles the armed tool ------- + api.clear(); + selectRun(0); + check( + "recolor over a selection marks it once", + api.recolor("highlight", { color: [0, 1, 0] }) === null && + api.list().length === 1 && + api.list()[0].color.join() === "0,1,0", + api.list() + ); + api.clear(); + api.press("highlight", { color: [1, 1, 0] }); + api.recolor("highlight", { color: [0, 1, 1] }); + selectRun(0); + api.mark(); + check( + "recolor restyles the armed tool", + api.list()[0].color.join() === "0,1,1", + api.list()[0].color + ); + api.clear(); + // mark() left it standing + window.getSelection().removeAllRanges(); + api.recolor("underline", { color: [1, 0, 1] }); + check("and arms nothing that was not armed", api.getTool() === "highlight"); + selectRun(0); + api.mark(); + check( + "nor restyles the armed one", + api.list()[0].color.join() === "0,1,1", + api.list()[0].color + ); + + window.odr.onAnnotationChange = () => {}; + api.clear(); + api.setTool(null); } run();