Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion docs/design/editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
94 changes: 80 additions & 14 deletions src/odr/internal/html/frontend/pdf-annotation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]")
Expand Down Expand Up @@ -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();
Expand All @@ -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;
});
Expand All @@ -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;
Expand Down Expand Up @@ -326,7 +372,7 @@
}
window.clearTimeout(settle);
settle = window.setTimeout(function () {
markSelection(false);
markSelection(tool, color, false);
}, 50);
}

Expand Down Expand Up @@ -415,6 +461,7 @@
stroke = null;
strokeNode = null;
strokePointer = null;
changed();
}

function applyOptions() {
Expand All @@ -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);
Expand All @@ -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 () {
Expand All @@ -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 () {
Expand Down
109 changes: 109 additions & 0 deletions test/browser/annotation/tests.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
<span class="sr">Selectable text on page two</span>
</div>
</div>
<p id="outside">Text on no page</p>

<script src="checks.js"></script>
<script src="pdf-annotation.js"></script>
Expand Down Expand Up @@ -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();
Expand Down
Loading