feat(pdf-editor): one select, one text tool, and a signature you can ink and turn
Select does what selecting does in every editor people already know. Double-click bare page and a caret opens there; double-click the document's own words and they open for replacement; what is selected copies with Ctrl+C, pastes with Ctrl+V and goes with Del. Nothing was taken away — a drag on empty page still gathers an area, and a drag that starts on words still selects words to copy. Type text and Edit text were two buttons for one question the click already answers. They are one Text tool: land on the document's own words and it offers to replace them, land anywhere else and it starts new text. The words light up under the cursor so which is which is visible before clicking, not after. The toolbar says what it is for. Select, Text and Sign are labelled and set apart; the drawing kit and the markup kit are their own groups. Sign gets a pen icon over a signature rather than a squiggle that could have been anything. Signatures take ink — black, blue, red, green — chosen while drawing and kept with the signature, because people sign in a particular colour and it belongs to the signature, not to whichever swatch was armed. And they turn: a grip above the box, free rotation, Shift to snap to 15°, for the signing line that is not square to the page. Two faults the tests found, both invisible by eye: The rotate grip was drawn in the right place and could not be grabbed — the selection bar floats directly above a mark, which is exactly where the grip sits, and it swallowed every click. The bar now stands clear of it. Undo would not undo a first rotation. Restoring a mark with Object.assign copies the keys the original HAD, so a property the drag introduced survived the restore; the journal then recorded the rotated state as the state to go back to. Restoring now forgets keys the original never had, which fixes every future property with the same shape. Also: building a document from pictures or joins refuses to start a second one on top of the first, and says so rather than failing quietly.
This commit is contained in:
parent
62460cd3aa
commit
ce066bb4ca
8 changed files with 329 additions and 63 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "pdf-editor",
|
"id": "pdf-editor",
|
||||||
"name": "PDF Editor",
|
"name": "PDF Editor",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"description": "Open a PDF in a full tab: read it, mark it up with highlight, underline, strike-through, pen, shapes, arrows, text and a drawn signature, then move, resize, restyle or delete anything you placed. Reorder, rotate or drop pages, fill in form fields, redact for real, and save a new copy. Build a PDF from pictures, one page each, or join several PDFs into one. Marks are written into the saved file as real PDF content. The original is never touched.",
|
"description": "Open a PDF in a full tab: read it, mark it up with highlight, underline, strike-through, pen, shapes, arrows, text and a drawn signature, then move, resize, restyle or delete anything you placed. Reorder, rotate or drop pages, fill in form fields, redact for real, and save a new copy. Build a PDF from pictures, one page each, or join several PDFs into one. Marks are written into the saved file as real PDF content. The original is never touched.",
|
||||||
"author": "Silent Mode",
|
"author": "Silent Mode",
|
||||||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
|
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,16 @@ body[data-dragover="1"] .dz-actions { visibility: hidden; }
|
||||||
.linkish { background: none; border: none; padding: 0; font: inherit; cursor: pointer;
|
.linkish { background: none; border: none; padding: 0; font: inherit; cursor: pointer;
|
||||||
color: var(--acid); text-decoration: underline; }
|
color: var(--acid); text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ---- the three main tools ------------------------------------------- */
|
||||||
|
/* Point at it, write on it, sign it. Wider, labelled, and set apart from the
|
||||||
|
drawing kit, because they are the things most people open a PDF to do and
|
||||||
|
they used to be four identical squares in a row of thirteen. */
|
||||||
|
.tgroup.primary { gap: 4px; }
|
||||||
|
.tool.big { width: auto; padding: 0 10px; gap: 6px; }
|
||||||
|
.tool.big .tlabel { font-size: 12px; line-height: 1; }
|
||||||
|
.tool.big svg { width: 15px; height: 15px; }
|
||||||
|
@media (max-width: 900px) { .tool.big .tlabel { display: none; } .tool.big { width: 30px; padding: 0; } }
|
||||||
|
|
||||||
/* ---- typing on the page --------------------------------------------- */
|
/* ---- typing on the page --------------------------------------------- */
|
||||||
/* Positioned by lib/inline.js against the page's own coordinates, so it sits
|
/* Positioned by lib/inline.js against the page's own coordinates, so it sits
|
||||||
where the text will sit. No background: the page is the background, which
|
where the text will sit. No background: the page is the background, which
|
||||||
|
|
@ -187,6 +197,11 @@ body[data-dragover="1"] .dz-actions { visibility: hidden; }
|
||||||
icon, and a word does not fit in one. */
|
icon, and a word does not fit in one. */
|
||||||
.sigrow .sigacts .btn { flex: none; width: auto; height: 26px; white-space: nowrap;
|
.sigrow .sigacts .btn { flex: none; width: auto; height: 26px; white-space: nowrap;
|
||||||
padding: 0 10px; font-size: 11.5px; line-height: 1; }
|
padding: 0 10px; font-size: 11.5px; line-height: 1; }
|
||||||
|
/* Ink for the pad. Dark by default: a signature is not a highlight. */
|
||||||
|
.sigink { display: flex; gap: 6px; margin: 0 0 8px; }
|
||||||
|
.sigink .swatch { width: 22px; height: 22px; border-radius: 50%;
|
||||||
|
border: 2px solid var(--line); cursor: pointer; padding: 0; }
|
||||||
|
.sigink .swatch.active { border-color: var(--acid); }
|
||||||
|
|
||||||
/* ---- modals --------------------------------------------------------- */
|
/* ---- modals --------------------------------------------------------- */
|
||||||
.scrim { position: fixed; inset: 0; background: rgba(4,7,12,.62); z-index: 50;
|
.scrim { position: fixed; inset: 0; background: rgba(4,7,12,.62); z-index: 50;
|
||||||
|
|
@ -336,9 +351,11 @@ body[data-mode="select"] .smOverlay .mark .hit { pointer-events: stroke; }
|
||||||
live so a click can land on a run; the form layer steps aside so a widget
|
live so a click can land on a run; the form layer steps aside so a widget
|
||||||
does not swallow it. The hover outline is the only cue that the invisible
|
does not swallow it. The hover outline is the only cue that the invisible
|
||||||
text layer is a target at all. */
|
text layer is a target at all. */
|
||||||
body[data-mode="pickrun"] .annotationLayer { pointer-events: none !important; }
|
body[data-mode="text"] .annotationLayer { pointer-events: none !important; }
|
||||||
body[data-mode="pickrun"] .pdfViewer .page { cursor: text; }
|
body[data-mode="text"] .pdfViewer .page { cursor: text; }
|
||||||
body[data-mode="pickrun"] .textLayer span:hover {
|
/* The document's own words light up under the cursor, so it is obvious that
|
||||||
|
clicking them replaces them and clicking anywhere else starts new text. */
|
||||||
|
body[data-mode="text"] .textLayer span:hover {
|
||||||
outline: 2px solid rgb(from var(--acid) r g b / .9);
|
outline: 2px solid rgb(from var(--acid) r g b / .9);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
background: rgb(from var(--acid) r g b / .16);
|
background: rgb(from var(--acid) r g b / .16);
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,46 @@
|
||||||
controls can right-anchor without the tools crowding the left edge. -->
|
controls can right-anchor without the tools crowding the left edge. -->
|
||||||
<div class="toolbar" id="toolbar">
|
<div class="toolbar" id="toolbar">
|
||||||
<div class="tool-cluster">
|
<div class="tool-cluster">
|
||||||
<div class="tgroup" role="group" aria-label="Tools">
|
<!-- Three things you do to a document — point at it, write on it, sign it —
|
||||||
<button class="tool active" data-tool="select" title="Select — move or delete a mark, select text (V)">
|
set apart from the drawing kit, which is a kit. -->
|
||||||
|
<div class="tgroup primary" role="group" aria-label="Main tools">
|
||||||
|
<button class="tool big active" data-tool="select" title="Select (V) — click a mark, drag an area around several; double-click to type or to edit what is there">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2l4 11 1.6-4.4L13 7z"/></svg>
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2l4 11 1.6-4.4L13 7z"/></svg>
|
||||||
|
<span class="tlabel">Select</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="tool big" data-tool="text" title="Text (T) — click the document's own words to replace them, or anywhere else to type new text">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 3h10M8 3v10M6 13h4"/></svg>
|
||||||
|
<span class="tlabel">Text</span>
|
||||||
|
</button>
|
||||||
|
<button class="tool big" data-tool="signature" title="Signature (G) — choose one, or draw a new one">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M1.5 12.5c2.2 0 2.6-7 4.2-7s1 6 2.6 6 2-3.5 3-3.5"/>
|
||||||
|
<path d="M12.6 2.6l1.8 1.8-4.2 4.2-2.4.6.6-2.4z"/>
|
||||||
|
<path d="M2 15h12"/>
|
||||||
|
</svg>
|
||||||
|
<span class="tlabel">Sign</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tgroup" role="group" aria-label="Draw">
|
||||||
|
<button class="tool" data-tool="pen" title="Pen — freehand (P)">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14c1-3 3-6 6-8s5-3 6-3l-3 4c-2 1-4 3-6 4s-2 2-3 3z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool" data-tool="line" title="Line (L)">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 13L13 3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool" data-tool="arrow" title="Arrow (A)">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13L13 3M13 3H8M13 3v5"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool" data-tool="rect" title="Rectangle (R)">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="11" height="9"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool" data-tool="ellipse" title="Ellipse (O)">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><ellipse cx="8" cy="8" rx="5.5" ry="4"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tgroup" role="group" aria-label="Mark up text">
|
||||||
<button class="tool" data-tool="highlight" title="Highlight — drag across text (H)">
|
<button class="tool" data-tool="highlight" title="Highlight — drag across text (H)">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h10"/><path d="M5 9.5l4.5-6.5 3 2L8 11.5z"/></svg>
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h10"/><path d="M5 9.5l4.5-6.5 3 2L8 11.5z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -48,30 +84,6 @@
|
||||||
<button class="tool" data-tool="strikeout" title="Strike through — drag across text (K)">
|
<button class="tool" data-tool="strikeout" title="Strike through — drag across text (K)">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 3.5h8"/><path d="M8 3.5V12"/><path d="M2.5 8h11"/></svg>
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 3.5h8"/><path d="M8 3.5V12"/><path d="M2.5 8h11"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="tool" data-tool="pen" title="Pen — freehand (P)">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14c1-3 3-6 6-8s5-3 6-3l-3 4c-2 1-4 3-6 4s-2 2-3 3z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="tool" data-tool="rect" title="Rectangle (R)">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="11" height="9"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="tool" data-tool="ellipse" title="Ellipse (O)">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><ellipse cx="8" cy="8" rx="5.5" ry="4"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="tool" data-tool="line" title="Line (L)">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 13L13 3"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="tool" data-tool="arrow" title="Arrow (A)">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13L13 3M13 3H8M13 3v5"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="tool" data-tool="text" title="Text stamp (T)">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 3h10M8 3v10M6 13h4"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="tool" data-tool="edittext" title="Replace the document's own text (E)">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7M5.5 4v7"/><path d="M11.5 3.5l2 2L9 10l-2.5.5L7 8z"/><path d="M2 14h12"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="tool" data-tool="signature" title="Signature stamp (G)">
|
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 11c2 0 3-7 4.5-7S8 10 9.5 10 12 6 13.5 6"/><path d="M2 14h12"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="tool" data-tool="redact" title="Redact — drag a box over content to remove it" data-warn="1">
|
<button class="tool" data-tool="redact" title="Redact — drag a box over content to remove it" data-warn="1">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="4.5" width="11" height="7" fill="currentColor"/></svg>
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="4.5" width="11" height="7" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -225,6 +237,12 @@
|
||||||
<!-- The pad, shown for a new signature or to redraw an existing one. -->
|
<!-- The pad, shown for a new signature or to redraw an existing one. -->
|
||||||
<div id="sign-draw" hidden>
|
<div id="sign-draw" hidden>
|
||||||
<p class="mhint">Draw in the box. The line under it is a guide, not part of what is saved.</p>
|
<p class="mhint">Draw in the box. The line under it is a guide, not part of what is saved.</p>
|
||||||
|
<div class="sigink" id="sign-colors" role="group" aria-label="Ink colour">
|
||||||
|
<button class="swatch active" data-ink="#101418" style="background:#101418" title="Black"></button>
|
||||||
|
<button class="swatch" data-ink="#1c4ed8" style="background:#1c4ed8" title="Blue"></button>
|
||||||
|
<button class="swatch" data-ink="#c81e1e" style="background:#c81e1e" title="Red"></button>
|
||||||
|
<button class="swatch" data-ink="#166534" style="background:#166534" title="Green"></button>
|
||||||
|
</div>
|
||||||
<canvas id="sign-pad" width="640" height="220"></canvas>
|
<canvas id="sign-pad" width="640" height="220"></canvas>
|
||||||
<label class="mfield block">Name
|
<label class="mfield block">Name
|
||||||
<input type="text" id="sign-name" spellcheck="false" placeholder="Signature">
|
<input type="text" id="sign-name" spellcheck="false" placeholder="Signature">
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { Tools } from "./lib/tools.js";
|
||||||
import { SignaturePad } from "./lib/signature.js";
|
import { SignaturePad } from "./lib/signature.js";
|
||||||
import { InlineText } from "./lib/inline.js";
|
import { InlineText } from "./lib/inline.js";
|
||||||
import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js";
|
import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js";
|
||||||
import { boundsOf, clampFont, unionOf } from "./lib/shape.js";
|
import { boundsOf, clampFont, translatePatch, unionOf } from "./lib/shape.js";
|
||||||
import { reflow } from "./lib/reflow.js";
|
import { reflow } from "./lib/reflow.js";
|
||||||
import { buildDocx } from "./lib/docx.js";
|
import { buildDocx } from "./lib/docx.js";
|
||||||
import { imagesToPdf, mergePdfs } from "./lib/create.js";
|
import { imagesToPdf, mergePdfs } from "./lib/create.js";
|
||||||
|
|
@ -206,6 +206,7 @@ async function loadBytes(bytes, name, scratchId = null) {
|
||||||
editRun: onEditRun,
|
editRun: onEditRun,
|
||||||
chooseSignature,
|
chooseSignature,
|
||||||
signature: () => savedSignature,
|
signature: () => savedSignature,
|
||||||
|
rotating: (deg) => status(`${Math.round(deg)}° — hold Shift to snap to 15°`),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
session.tools.setTool("select");
|
session.tools.setTool("select");
|
||||||
|
|
@ -302,8 +303,18 @@ async function openFiles(list) {
|
||||||
* marked unsaved the moment it opens — otherwise closing the tab would throw
|
* marked unsaved the moment it opens — otherwise closing the tab would throw
|
||||||
* away work that never touched a disk, without asking.
|
* away work that never touched a disk, without asking.
|
||||||
*/
|
*/
|
||||||
|
// Building a document takes a while — reading a dozen photos, then opening
|
||||||
|
// the result — and a second one started meanwhile tears down the first
|
||||||
|
// halfway through. One at a time.
|
||||||
|
let building = false;
|
||||||
|
|
||||||
async function fromImages(files) {
|
async function fromImages(files) {
|
||||||
|
if (building) { toast("Still building the last one — one at a time", true); return; }
|
||||||
if (!(await confirmDiscard())) return;
|
if (!(await confirmDiscard())) return;
|
||||||
|
building = true;
|
||||||
|
try { await fromImagesInner(files); } finally { building = false; }
|
||||||
|
}
|
||||||
|
async function fromImagesInner(files) {
|
||||||
$("images-count").textContent =
|
$("images-count").textContent =
|
||||||
`${files.length} picture${files.length === 1 ? "" : "s"}, one per page, in the order your file browser listed them.`;
|
`${files.length} picture${files.length === 1 ? "" : "s"}, one per page, in the order your file browser listed them.`;
|
||||||
syncImagesDialog();
|
syncImagesDialog();
|
||||||
|
|
@ -333,7 +344,12 @@ async function fromImages(files) {
|
||||||
* reorder them" — yes, once they are one document.
|
* reorder them" — yes, once they are one document.
|
||||||
*/
|
*/
|
||||||
async function joinPdfs(files) {
|
async function joinPdfs(files) {
|
||||||
|
if (building) { toast("Still building the last one — one at a time", true); return; }
|
||||||
if (!(await confirmDiscard())) return;
|
if (!(await confirmDiscard())) return;
|
||||||
|
building = true;
|
||||||
|
try { await joinPdfsInner(files); } finally { building = false; }
|
||||||
|
}
|
||||||
|
async function joinPdfsInner(files) {
|
||||||
setBusy(true, "Joining…");
|
setBusy(true, "Joining…");
|
||||||
let out;
|
let out;
|
||||||
try {
|
try {
|
||||||
|
|
@ -355,6 +371,11 @@ async function joinPdfs(files) {
|
||||||
// Shared tail for the two build paths: say where the document came from, say
|
// Shared tail for the two build paths: say where the document came from, say
|
||||||
// that it is not on disk yet, and name anything that had to be skipped.
|
// that it is not on disk yet, and name anything that had to be skipped.
|
||||||
function afterBuild(what, failed) {
|
function afterBuild(what, failed) {
|
||||||
|
// A second build started while this one was still opening would have torn
|
||||||
|
// the first document down between the load returning and this running.
|
||||||
|
// `building` below makes that impossible; this stays because being wrong
|
||||||
|
// about it should not throw on a page the user is still looking at.
|
||||||
|
if (!session.model) return;
|
||||||
session.model.markUnsaved();
|
session.model.markUnsaved();
|
||||||
paintChrome();
|
paintChrome();
|
||||||
status(`${what} · not saved yet — use Save a copy`);
|
status(`${what} · not saved yet — use Save a copy`);
|
||||||
|
|
@ -495,8 +516,7 @@ const TOOL_HINTS = {
|
||||||
ellipse: "Drag to draw an ellipse.",
|
ellipse: "Drag to draw an ellipse.",
|
||||||
line: "Drag to draw a line.",
|
line: "Drag to draw a line.",
|
||||||
arrow: "Drag from tail to head.",
|
arrow: "Drag from tail to head.",
|
||||||
text: "Click where the text should start.",
|
text: "Click the document's own words to replace them, or anywhere else to type new text.",
|
||||||
edittext: "Click a line of the document's own text to replace it.",
|
|
||||||
signature: "Click to place your signature.",
|
signature: "Click to place your signature.",
|
||||||
redact: "Drag a box over what has to go. Redacted pages are rebuilt as images on save.",
|
redact: "Drag a box over what has to go. Redacted pages are rebuilt as images on save.",
|
||||||
};
|
};
|
||||||
|
|
@ -751,8 +771,13 @@ function positionSelectionBar() {
|
||||||
const box = selbar.getBoundingClientRect();
|
const box = selbar.getBoundingClientRect();
|
||||||
const cr = $("viewerContainer").getBoundingClientRect();
|
const cr = $("viewerContainer").getBoundingClientRect();
|
||||||
const GAP = 10;
|
const GAP = 10;
|
||||||
|
// A mark with a rotate grip has something of its own directly above it.
|
||||||
|
// Without this the bar lands on the grip and eats the click — which is
|
||||||
|
// exactly what it did, silently: the grip was drawn, was in the right
|
||||||
|
// place, and could not be grabbed.
|
||||||
|
const clear = picked.length === 1 && a.kind === "signature" ? 34 : 0;
|
||||||
// Above the mark by preference; below when there is no room up there.
|
// Above the mark by preference; below when there is no room up there.
|
||||||
let y = top - box.height - GAP;
|
let y = top - box.height - GAP - clear;
|
||||||
if (y < cr.top + 4) y = Math.min(bottom + GAP, cr.bottom - box.height - 4);
|
if (y < cr.top + 4) y = Math.min(bottom + GAP, cr.bottom - box.height - 4);
|
||||||
let x = (left + right) / 2 - box.width / 2;
|
let x = (left + right) / 2 - box.width / 2;
|
||||||
x = Math.min(Math.max(x, cr.left + 4), cr.right - box.width - 4);
|
x = Math.min(Math.max(x, cr.left + 4), cr.right - box.width - 4);
|
||||||
|
|
@ -904,6 +929,50 @@ function finishInlineText(s) {
|
||||||
status("");
|
status("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- copy and paste ---------------------------------------------------
|
||||||
|
// A clipboard of this editor's own, not the system one. What is on the board
|
||||||
|
// is a set of marks — geometry, colour, the words in a stamp — and there is
|
||||||
|
// no sensible thing to put on the system clipboard that another application
|
||||||
|
// could paste. Copying between two PDFs open in two tabs is the obvious
|
||||||
|
// next want, and is exactly what this does not do yet.
|
||||||
|
let clipboard = [];
|
||||||
|
|
||||||
|
function copySelection(cut = false) {
|
||||||
|
const picked = session.tools?.selectedAnnots?.() || [];
|
||||||
|
if (!picked.length) { toast("Nothing selected to copy"); return; }
|
||||||
|
clipboard = picked.map((a) => {
|
||||||
|
const copy = structuredClone(a);
|
||||||
|
delete copy.id; delete copy._w; delete copy._editing;
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
if (cut) session.tools.deleteSelected();
|
||||||
|
paintChrome();
|
||||||
|
toast(`${clipboard.length} mark${clipboard.length === 1 ? "" : "s"} ${cut ? "cut" : "copied"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteMarks() {
|
||||||
|
if (!session.model) return;
|
||||||
|
if (!clipboard.length) { toast("Nothing to paste"); return; }
|
||||||
|
// Onto the page being looked at, nudged clear of the original so the copy
|
||||||
|
// is visibly a second thing rather than a perfect overlap you cannot grab.
|
||||||
|
const uid = session.strip?.current || session.model.visible()[0];
|
||||||
|
if (!uid) return;
|
||||||
|
const off = 14;
|
||||||
|
const made = [];
|
||||||
|
session.model.edit(`paste ${clipboard.length} marks`, () => {
|
||||||
|
for (const a of clipboard) {
|
||||||
|
const copy = { ...structuredClone(a), page: uid };
|
||||||
|
Object.assign(copy, translatePatch(copy, off, -off));
|
||||||
|
made.push(session.model.pushAnnot(copy));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
selectTool("select");
|
||||||
|
session.tools.selectMany(made.map((m) => m.id));
|
||||||
|
paintChrome();
|
||||||
|
toast(`${made.length} mark${made.length === 1 ? "" : "s"} pasted`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Select every mark on the page the user is looking at. */
|
/** Select every mark on the page the user is looking at. */
|
||||||
function selectAllOnPage() {
|
function selectAllOnPage() {
|
||||||
const t = session.tools, m = session.model, strip = session.strip;
|
const t = session.tools, m = session.model, strip = session.strip;
|
||||||
|
|
@ -1071,9 +1140,23 @@ function showSignaturePad(existing = null) {
|
||||||
if (!pad) {
|
if (!pad) {
|
||||||
pad = new SignaturePad($("sign-pad"));
|
pad = new SignaturePad($("sign-pad"));
|
||||||
pad.onChange((has) => { $("sign-ok").disabled = !has; });
|
pad.onChange((has) => { $("sign-ok").disabled = !has; });
|
||||||
|
for (const b of $("sign-colors").querySelectorAll("[data-ink]")) {
|
||||||
|
b.addEventListener("click", () => {
|
||||||
|
pad.setColor(b.dataset.ink);
|
||||||
|
for (const o of $("sign-colors").querySelectorAll("[data-ink]")) {
|
||||||
|
o.classList.toggle("active", o === b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pad.clear();
|
pad.clear();
|
||||||
padEditing = existing;
|
padEditing = existing;
|
||||||
|
// Redrawing keeps the ink it was signed in.
|
||||||
|
const ink = existing?.color || "#101418";
|
||||||
|
pad.setColor(ink);
|
||||||
|
for (const o of $("sign-colors").querySelectorAll("[data-ink]")) {
|
||||||
|
o.classList.toggle("active", o.dataset.ink === ink);
|
||||||
|
}
|
||||||
if (existing) pad.load(existing.strokes, existing.aspect);
|
if (existing) pad.load(existing.strokes, existing.aspect);
|
||||||
$("sign-title").textContent = existing ? "Redraw this signature" : "Draw a signature";
|
$("sign-title").textContent = existing ? "Redraw this signature" : "Draw a signature";
|
||||||
$("sign-name").value = existing?.name || "";
|
$("sign-name").value = existing?.name || "";
|
||||||
|
|
@ -1426,7 +1509,6 @@ $("viewerContainer").addEventListener("click", (e) => {
|
||||||
const TOOL_KEYS = {
|
const TOOL_KEYS = {
|
||||||
v: "select", h: "highlight", u: "underline", k: "strikeout", p: "pen",
|
v: "select", h: "highlight", u: "underline", k: "strikeout", p: "pen",
|
||||||
r: "rect", o: "ellipse", l: "line", a: "arrow", t: "text", g: "signature",
|
r: "rect", o: "ellipse", l: "line", a: "arrow", t: "text", g: "signature",
|
||||||
e: "edittext",
|
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", (e) => {
|
window.addEventListener("keydown", (e) => {
|
||||||
// A modal owns the keyboard while it is up: Escape cancels, and Enter
|
// A modal owns the keyboard while it is up: Escape cancels, and Enter
|
||||||
|
|
@ -1464,6 +1546,9 @@ window.addEventListener("keydown", (e) => {
|
||||||
selectAllOnPage();
|
selectAllOnPage();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (meta && e.key.toLowerCase() === "c") { e.preventDefault(); copySelection(); return; }
|
||||||
|
if (meta && e.key.toLowerCase() === "x") { e.preventDefault(); copySelection(true); return; }
|
||||||
|
if (meta && e.key.toLowerCase() === "v") { e.preventDefault(); pasteMarks(); return; }
|
||||||
|
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
// Step back one level at a time: drop the selection first, and only then
|
// Step back one level at a time: drop the selection first, and only then
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ const NS = "http://www.w3.org/2000/svg";
|
||||||
const HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom
|
const HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom
|
||||||
let clipSeq = 0; // clipPath ids must be unique across every page in the document
|
let clipSeq = 0; // clipPath ids must be unique across every page in the document
|
||||||
const HIT_TOLERANCE = 14; // CSS px of grab room around a thin stroke
|
const HIT_TOLERANCE = 14; // CSS px of grab room around a thin stroke
|
||||||
|
const ROTATE_ARM = 22; // CSS px between the box and its rotate grip
|
||||||
|
/** Kinds that can be turned. A signature, because signing lines are not
|
||||||
|
* always square to the page; nothing else has asked for it. */
|
||||||
|
const ROTATABLE = new Set(["signature"]);
|
||||||
function el(name, attrs) {
|
function el(name, attrs) {
|
||||||
const n = document.createElementNS(NS, name);
|
const n = document.createElementNS(NS, name);
|
||||||
for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
|
for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
|
||||||
|
|
@ -335,6 +339,16 @@ export class Overlay {
|
||||||
const p = at[n];
|
const p = at[n];
|
||||||
if (p) this._handle(n, p[0], p[1], cursor[n]);
|
if (p) this._handle(n, p[0], p[1], cursor[n]);
|
||||||
}
|
}
|
||||||
|
// A grip above the box, for the marks it makes sense to turn. A signature
|
||||||
|
// goes on a line that is not always square to the page — a scanned form,
|
||||||
|
// a photographed contract — and straightening one by hand beats
|
||||||
|
// straightening the whole page.
|
||||||
|
if (ROTATABLE.has(sel.kind)) {
|
||||||
|
const ry = T - ROTATE_ARM;
|
||||||
|
this.chrome.append(el("line", { class: "rotarm", x1: MX, y1: T, x2: MX, y2: ry }));
|
||||||
|
const g = this._handle("rotate", MX, ry, "grab");
|
||||||
|
if (g) g.classList.add("rot");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -379,6 +393,7 @@ export class Overlay {
|
||||||
});
|
});
|
||||||
h.dataset.handle = name;
|
h.dataset.handle = name;
|
||||||
this.chrome.append(h);
|
this.chrome.append(h);
|
||||||
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stroke and fill for a shape, honouring its fill and opacity.
|
// Stroke and fill for a shape, honouring its fill and opacity.
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,29 @@ export function arrowBarbs(x1, y1, x2, y2, width) {
|
||||||
*/
|
*/
|
||||||
export function signatureStrokes(a) {
|
export function signatureStrokes(a) {
|
||||||
const { x, y, w, h } = a;
|
const { x, y, w, h } = a;
|
||||||
return (a.strokes || []).map((s) => s.map(([nx, ny]) => [x + nx * w, y + (1 - ny) * h]));
|
const pts = (a.strokes || []).map((s) => s.map(([nx, ny]) => [x + nx * w, y + (1 - ny) * h]));
|
||||||
|
const deg = Number(a.rotate) || 0;
|
||||||
|
if (!deg) return pts;
|
||||||
|
// Turned about the middle of its own box, in user space — which means the
|
||||||
|
// renderer and the writer get the same rotated points from one piece of
|
||||||
|
// arithmetic, instead of each rotating in a different coordinate system.
|
||||||
|
const cx = x + w / 2, cy = y + h / 2;
|
||||||
|
const r = (deg * Math.PI) / 180;
|
||||||
|
const cos = Math.cos(r), sin = Math.sin(r);
|
||||||
|
return pts.map((s) => s.map(([px, py]) => {
|
||||||
|
const dx = px - cx, dy = py - cy;
|
||||||
|
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The box a rotated signature actually occupies, for selection and hit-testing. */
|
||||||
|
export function rotatedBounds(a) {
|
||||||
|
const pts = signatureStrokes(a).flat();
|
||||||
|
if (!pts.length) return { x: a.x, y: a.y, w: a.w, h: a.h };
|
||||||
|
const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
|
||||||
|
const x0 = Math.min(...xs), x1 = Math.max(...xs);
|
||||||
|
const y0 = Math.min(...ys), y1 = Math.max(...ys);
|
||||||
|
return { x: x0, y: y0, w: Math.max(1, x1 - x0), h: Math.max(1, y1 - y0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalise a box that may have been dragged right-to-left or bottom-to-top. */
|
/** Normalise a box that may have been dragged right-to-left or bottom-to-top. */
|
||||||
|
|
@ -136,6 +158,10 @@ export function boundsOf(a) {
|
||||||
// and caches it on the mark as `_w` so selection boxes are tight.
|
// and caches it on the mark as `_w` so selection boxes are tight.
|
||||||
return { x: a.x, y: a.y - textBlockHeight(a), w: a._w || (a.size || 12) * 6, h: textBlockHeight(a) };
|
return { x: a.x, y: a.y - textBlockHeight(a), w: a._w || (a.size || 12) * 6, h: textBlockHeight(a) };
|
||||||
}
|
}
|
||||||
|
// A turned signature occupies more than the box it was placed in, and
|
||||||
|
// the selection outline has to agree with what is on screen.
|
||||||
|
case "signature":
|
||||||
|
return a.rotate ? rotatedBounds(a) : { x: a.x, y: a.y, w: a.w, h: a.h };
|
||||||
default: return { x: a.x, y: a.y, w: a.w, h: a.h };
|
default: return { x: a.x, y: a.y, w: a.w, h: a.h };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -240,6 +266,15 @@ export function scaleToBox(a, from, to) {
|
||||||
baseline: fy(a.baseline),
|
baseline: fy(a.baseline),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (a.kind === "signature" && a.rotate) {
|
||||||
|
// `from`/`to` describe the TURNED extent; the mark's own box is the
|
||||||
|
// unturned one. Scale that box about its centre by the same factors and
|
||||||
|
// the turned extent follows, because the rotation is about that centre.
|
||||||
|
const cx = to.x + to.w / 2, cy = to.y + to.h / 2;
|
||||||
|
const w = Math.max(MIN_EXTENT, a.w * sx);
|
||||||
|
const h = Math.max(MIN_EXTENT, a.h * sy);
|
||||||
|
return { x: cx - w / 2, y: cy - h / 2, w, h };
|
||||||
|
}
|
||||||
return { x: to.x, y: to.y, w: Math.max(MIN_EXTENT, to.w), h: Math.max(MIN_EXTENT, to.h) };
|
return { x: to.x, y: to.y, w: Math.max(MIN_EXTENT, to.w), h: Math.max(MIN_EXTENT, to.h) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ export class SignaturePad {
|
||||||
this.ctx = canvas.getContext("2d");
|
this.ctx = canvas.getContext("2d");
|
||||||
this.strokes = []; // in canvas pixel space while drawing
|
this.strokes = []; // in canvas pixel space while drawing
|
||||||
this.cur = null;
|
this.cur = null;
|
||||||
|
// The ink. Kept with the signature rather than taken from the toolbar at
|
||||||
|
// placing time: people sign in a particular colour — blue to show a
|
||||||
|
// scan is an original, black to match the form — and that belongs to the
|
||||||
|
// signature, not to whatever swatch happened to be armed.
|
||||||
|
this.color = "#101418";
|
||||||
this._onChange = null;
|
this._onChange = null;
|
||||||
canvas.addEventListener("pointerdown", (e) => this._down(e));
|
canvas.addEventListener("pointerdown", (e) => this._down(e));
|
||||||
canvas.addEventListener("pointermove", (e) => this._move(e));
|
canvas.addEventListener("pointermove", (e) => this._move(e));
|
||||||
|
|
@ -97,7 +102,7 @@ export class SignaturePad {
|
||||||
ctx.moveTo(24, canvas.height * 0.72);
|
ctx.moveTo(24, canvas.height * 0.72);
|
||||||
ctx.lineTo(canvas.width - 24, canvas.height * 0.72);
|
ctx.lineTo(canvas.width - 24, canvas.height * 0.72);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.strokeStyle = "#101418";
|
ctx.strokeStyle = this.color;
|
||||||
ctx.lineWidth = 2.6;
|
ctx.lineWidth = 2.6;
|
||||||
ctx.lineCap = "round";
|
ctx.lineCap = "round";
|
||||||
ctx.lineJoin = "round";
|
ctx.lineJoin = "round";
|
||||||
|
|
@ -129,6 +134,12 @@ export class SignaturePad {
|
||||||
return {
|
return {
|
||||||
strokes: live.map((s) => s.map(([x, y]) => [(x - x0) / w, (y - y0) / h])),
|
strokes: live.map((s) => s.map(([x, y]) => [(x - x0) / w, (y - y0) / h])),
|
||||||
aspect: Math.min(1.2, Math.max(0.12, h / w)),
|
aspect: Math.min(1.2, Math.max(0.12, h / w)),
|
||||||
|
color: this.color,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setColor(c) {
|
||||||
|
this.color = c;
|
||||||
|
this._paint();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,22 @@ const DESCENDER = 0.26; // how far below the baseline a glyph can reach, as
|
||||||
/** Tools that mark up existing text rather than drawing free-hand. */
|
/** Tools that mark up existing text rather than drawing free-hand. */
|
||||||
export const TEXT_MARKUP = new Set(["highlight", "underline", "strikeout"]);
|
export const TEXT_MARKUP = new Set(["highlight", "underline", "strikeout"]);
|
||||||
/** Tools that place something with a single click. */
|
/** Tools that place something with a single click. */
|
||||||
const STAMP_TOOLS = new Set(["text", "signature"]);
|
const STAMP_TOOLS = new Set(["signature"]);
|
||||||
/** Tools that act on a run of the document's OWN text. */
|
|
||||||
const RUN_TOOLS = new Set(["edittext"]);
|
/**
|
||||||
|
* Put `a` back exactly as `orig` was — including forgetting keys `orig`
|
||||||
|
* never had.
|
||||||
|
*
|
||||||
|
* Object.assign alone copies the keys the original HAD, so a property the
|
||||||
|
* drag introduced survives the restore. That is not a tidiness point: the
|
||||||
|
* journal snapshots the restored mark as the "before", so a first rotation
|
||||||
|
* of a never-rotated signature was recorded as already rotated, and undo
|
||||||
|
* had nothing to undo.
|
||||||
|
*/
|
||||||
|
function restoreExactly(a, orig) {
|
||||||
|
for (const k of Object.keys(a)) if (!(k in orig)) delete a[k];
|
||||||
|
Object.assign(a, orig);
|
||||||
|
}
|
||||||
|
|
||||||
export class Tools {
|
export class Tools {
|
||||||
constructor({ strip, model, host }) {
|
constructor({ strip, model, host }) {
|
||||||
|
|
@ -70,7 +83,10 @@ export class Tools {
|
||||||
get mode() {
|
get mode() {
|
||||||
if (this.tool === "select") return "select";
|
if (this.tool === "select") return "select";
|
||||||
if (TEXT_MARKUP.has(this.tool)) return "highlight";
|
if (TEXT_MARKUP.has(this.tool)) return "highlight";
|
||||||
if (RUN_TOOLS.has(this.tool)) return "pickrun";
|
// The text tool needs the document's own words to be hit-testable, so a
|
||||||
|
// click on them can replace them. A stamp tool must NOT, so its click
|
||||||
|
// lands on the page rather than inside a text selection.
|
||||||
|
if (this.tool === "text") return "text";
|
||||||
if (STAMP_TOOLS.has(this.tool)) return "stamp";
|
if (STAMP_TOOLS.has(this.tool)) return "stamp";
|
||||||
return "draw";
|
return "draw";
|
||||||
}
|
}
|
||||||
|
|
@ -197,6 +213,18 @@ export class Tools {
|
||||||
if (handle && this.selected) {
|
if (handle && this.selected) {
|
||||||
const a = this.model.annot(this.selected);
|
const a = this.model.annot(this.selected);
|
||||||
const ov = a && this.strip.overlayFor(a.page);
|
const ov = a && this.strip.overlayFor(a.page);
|
||||||
|
if (a && ov?.viewport && handle === "rotate") {
|
||||||
|
// Turning is measured from the mark's centre, so the grip follows the
|
||||||
|
// pointer round rather than jumping to it.
|
||||||
|
const b = boundsOf(a);
|
||||||
|
this.drag = {
|
||||||
|
kind: "rotate", uid: a.page, id: a.id, orig: structuredClone(a),
|
||||||
|
cx0: a.x + a.w / 2, cy0: a.y + a.h / 2,
|
||||||
|
from: null, cx: e.clientX, cy: e.clientY, moved: false, box: b,
|
||||||
|
};
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (a && ov?.viewport) {
|
if (a && ov?.viewport) {
|
||||||
const view = this.strip.views.get(a.page);
|
const view = this.strip.views.get(a.page);
|
||||||
const r = view.pv.div.getBoundingClientRect();
|
const r = view.pv.div.getBoundingClientRect();
|
||||||
|
|
@ -237,21 +265,6 @@ export class Tools {
|
||||||
|
|
||||||
if (TEXT_MARKUP.has(this.tool)) return; // let the browser select text
|
if (TEXT_MARKUP.has(this.tool)) return; // let the browser select text
|
||||||
|
|
||||||
// Editing the document's own text: the click has to land on a run of it,
|
|
||||||
// which is exactly what a text-layer span is. Anywhere else is a miss, and
|
|
||||||
// saying so beats silently doing nothing.
|
|
||||||
if (this.tool === "edittext") {
|
|
||||||
e.preventDefault();
|
|
||||||
const span = e.target?.closest?.(".textLayer span");
|
|
||||||
if (!span || !span.textContent.trim()) {
|
|
||||||
this.host.toast?.("Click on a line of the document's text to replace it.", true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const run = this.describeRun(span, hit.uid);
|
|
||||||
if (run) this.host.editRun?.(run);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [x, y] = ov.toPdf(hit.vx, hit.vy);
|
const [x, y] = ov.toPdf(hit.vx, hit.vy);
|
||||||
|
|
||||||
if (this.tool === "select") {
|
if (this.tool === "select") {
|
||||||
|
|
@ -263,8 +276,33 @@ export class Tools {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const double = !!id && this._lastClick?.id === id && now - this._lastClick.t < DOUBLE_CLICK_MS;
|
const double = !!id && this._lastClick?.id === id && now - this._lastClick.t < DOUBLE_CLICK_MS;
|
||||||
this._lastClick = id ? { id, t: now } : null;
|
this._lastClick = id ? { id, t: now } : null;
|
||||||
|
// A second kind of double-click, by POSITION rather than by mark: the
|
||||||
|
// page itself and the document's own words are not marks, and both
|
||||||
|
// answer to being double-clicked.
|
||||||
|
const near = this._lastPress &&
|
||||||
|
Math.hypot(e.clientX - this._lastPress.x, e.clientY - this._lastPress.y) < 6;
|
||||||
|
const doubleAnywhere = !!near && now - this._lastPress.t < DOUBLE_CLICK_MS;
|
||||||
|
this._lastPress = { x: e.clientX, y: e.clientY, t: now };
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
const span = e.target?.closest?.(".textLayer span");
|
||||||
|
// Double-click on the document's own words: replace that run, the
|
||||||
|
// same thing the text tool does, because "edit what is there" and
|
||||||
|
// "select something" are one gesture in every editor people already
|
||||||
|
// know.
|
||||||
|
if (doubleAnywhere && span && span.textContent.trim()) {
|
||||||
|
this._setSelected(null);
|
||||||
|
e.preventDefault();
|
||||||
|
this._replaceRunAt(e, hit.uid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Double-click on bare page: start typing there.
|
||||||
|
if (doubleAnywhere && !span) {
|
||||||
|
this._setSelected(null);
|
||||||
|
e.preventDefault();
|
||||||
|
this.host.beginText?.({ uid: hit.uid, x, y });
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Empty page: start a rubber band. A press that never moves is just
|
// Empty page: start a rubber band. A press that never moves is just
|
||||||
// a click on nothing, and onUp treats it as one — it clears the
|
// a click on nothing, and onUp treats it as one — it clears the
|
||||||
// selection, exactly as it always did.
|
// selection, exactly as it always did.
|
||||||
|
|
@ -273,6 +311,10 @@ export class Tools {
|
||||||
// Starting a band there means calling preventDefault, and that is
|
// Starting a band there means calling preventDefault, and that is
|
||||||
// what gives a text field its caret, so the field would go dead.
|
// what gives a text field its caret, so the field would go dead.
|
||||||
if (e.target?.closest?.(".annotationLayer")) return;
|
if (e.target?.closest?.(".annotationLayer")) return;
|
||||||
|
// ...or on the document's own words, where a drag means selecting
|
||||||
|
// them to copy. A band is started from the space between things,
|
||||||
|
// which is where a hand reaches for one anyway.
|
||||||
|
if (span) return;
|
||||||
this.drag = { kind: "marquee", uid: hit.uid, x0: x, y0: y, x, y,
|
this.drag = { kind: "marquee", uid: hit.uid, x0: x, y0: y, x, y,
|
||||||
cx: e.clientX, cy: e.clientY, moved: false };
|
cx: e.clientX, cy: e.clientY, moved: false };
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -303,9 +345,13 @@ export class Tools {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.tool === "text") {
|
if (this.tool === "text") {
|
||||||
// A caret on the page, not a dialog about the page.
|
// One text tool, two jobs, told apart by what is under the click:
|
||||||
this.host.beginText?.({ uid: hit.uid, x, y });
|
// words that are already in the document get replaced, and anywhere
|
||||||
|
// else gets a caret. They were two buttons, which asked the user to
|
||||||
|
// decide in advance something the click already says.
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (this._replaceRunAt(e, hit.uid)) return;
|
||||||
|
this.host.beginText?.({ uid: hit.uid, x, y });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.tool === "signature") {
|
if (this.tool === "signature") {
|
||||||
|
|
@ -321,6 +367,26 @@ export class Tools {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the press landed on a run of the document's own text, open it for
|
||||||
|
* replacement and say so.
|
||||||
|
*
|
||||||
|
* A text-layer span IS a run, which is why this is a one-line test: pdf.js
|
||||||
|
* groups glyphs into spans by the same runs the PDF itself declares.
|
||||||
|
* @returns true when it took the click.
|
||||||
|
*/
|
||||||
|
_replaceRunAt(e, uid) {
|
||||||
|
const span = e.target?.closest?.(".textLayer span");
|
||||||
|
if (!span || !span.textContent.trim()) return false;
|
||||||
|
const run = this.describeRun(span, uid);
|
||||||
|
if (!run) return false;
|
||||||
|
// A press on text starts a browser selection; the dialog is about to
|
||||||
|
// cover it, and a half-highlighted line underneath reads as a fault.
|
||||||
|
try { window.getSelection()?.removeAllRanges(); } catch {}
|
||||||
|
this.host.editRun?.(run);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A move drag, of one mark or of the whole group.
|
* A move drag, of one mark or of the whole group.
|
||||||
*
|
*
|
||||||
|
|
@ -362,6 +428,24 @@ export class Tools {
|
||||||
this.strip.setPreview(d.uid, { kind: "marquee", x0: d.x0, y0: d.y0, x1: x, y1: y });
|
this.strip.setPreview(d.uid, { kind: "marquee", x0: d.x0, y0: d.y0, x1: x, y1: y });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (d.kind === "rotate") {
|
||||||
|
const a = this.model.annot(d.id);
|
||||||
|
if (!a) return;
|
||||||
|
// User space has y upwards, so a turn that looks clockwise on screen is
|
||||||
|
// counter-clockwise here; negating keeps "drag right" meaning "turn
|
||||||
|
// right" to the person doing it.
|
||||||
|
const angle = -Math.atan2(y - d.cy0, x - d.cx0) * 180 / Math.PI - 90;
|
||||||
|
if (d.from === null) d.from = angle - (d.orig.rotate || 0);
|
||||||
|
let deg = angle - d.from;
|
||||||
|
// Shift snaps, for the common case of straightening something by a few
|
||||||
|
// degrees or turning it exactly a quarter.
|
||||||
|
if (e.shiftKey) deg = Math.round(deg / 15) * 15;
|
||||||
|
a.rotate = ((deg % 360) + 360) % 360;
|
||||||
|
d.moved = true;
|
||||||
|
this.strip.refreshOverlay(d.uid, this.selection);
|
||||||
|
this.host.rotating?.(a.rotate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (d.kind === "move") {
|
if (d.kind === "move") {
|
||||||
// Live and un-journalled; the whole gesture is committed once on release
|
// Live and un-journalled; the whole gesture is committed once on release
|
||||||
// so undo steps back over the drag, not over each mouse move.
|
// so undo steps back over the drag, not over each mouse move.
|
||||||
|
|
@ -399,7 +483,7 @@ export class Tools {
|
||||||
// resize half-done: applied to the live mark, never journalled, and with
|
// resize half-done: applied to the live mark, never journalled, and with
|
||||||
// this.drag still set so the next press behaves oddly.
|
// this.drag still set so the next press behaves oddly.
|
||||||
const inFlight = this.drag;
|
const inFlight = this.drag;
|
||||||
const live = inFlight && (inFlight.kind === "resize" || inFlight.kind === "move");
|
const live = inFlight && ["resize", "move", "rotate", "marquee"].includes(inFlight.kind);
|
||||||
if (!live && TEXT_MARKUP.has(this.tool)) {
|
if (!live && TEXT_MARKUP.has(this.tool)) {
|
||||||
this._commitTextMarkup();
|
this._commitTextMarkup();
|
||||||
return;
|
return;
|
||||||
|
|
@ -417,7 +501,7 @@ export class Tools {
|
||||||
this.host.onChange?.();
|
this.host.onChange?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (d.kind === "move" || d.kind === "resize") {
|
if (d.kind === "move" || d.kind === "resize" || d.kind === "rotate") {
|
||||||
const a = this.model.annot(d.id);
|
const a = this.model.annot(d.id);
|
||||||
if (!a || !d.moved) { this.strip.refreshOverlay(d.uid, this.selection); return; }
|
if (!a || !d.moved) { this.strip.refreshOverlay(d.uid, this.selection); return; }
|
||||||
if (d.origs && d.origs.size > 1) this._commitGroupMove(d);
|
if (d.origs && d.origs.size > 1) this._commitGroupMove(d);
|
||||||
|
|
@ -472,7 +556,7 @@ export class Tools {
|
||||||
// journal's "before" is the state the drag actually started from.
|
// journal's "before" is the state the drag actually started from.
|
||||||
for (const [id, orig] of d.origs) {
|
for (const [id, orig] of d.origs) {
|
||||||
const a = this.model.annot(id);
|
const a = this.model.annot(id);
|
||||||
if (a) Object.assign(a, orig);
|
if (a) restoreExactly(a, orig);
|
||||||
}
|
}
|
||||||
this.model.moveAnnots([...after.keys()], (a) => {
|
this.model.moveAnnots([...after.keys()], (a) => {
|
||||||
const to = after.get(a.id);
|
const to = after.get(a.id);
|
||||||
|
|
@ -486,7 +570,7 @@ export class Tools {
|
||||||
|
|
||||||
_commitLiveEdit(a, orig) {
|
_commitLiveEdit(a, orig) {
|
||||||
const after = structuredClone(a);
|
const after = structuredClone(a);
|
||||||
Object.assign(a, orig);
|
restoreExactly(a, orig);
|
||||||
const patch = {};
|
const patch = {};
|
||||||
for (const k of Object.keys(after)) {
|
for (const k of Object.keys(after)) {
|
||||||
if (k !== "id" && k !== "page" && k !== "kind") patch[k] = after[k];
|
if (k !== "id" && k !== "page" && k !== "kind") patch[k] = after[k];
|
||||||
|
|
@ -661,7 +745,8 @@ export class Tools {
|
||||||
const w = SIG_WIDTH_PT;
|
const w = SIG_WIDTH_PT;
|
||||||
const h = w * (sig.aspect || 0.34);
|
const h = w * (sig.aspect || 0.34);
|
||||||
const a = this.model.addAnnot({
|
const a = this.model.addAnnot({
|
||||||
kind: "signature", page: uid, color: this.ink,
|
// The ink the signature was drawn in, not whatever swatch is armed.
|
||||||
|
kind: "signature", page: uid, color: sig.color || this.ink,
|
||||||
width: 1.6, x, y: y - h, w, h, strokes: sig.strokes,
|
width: 1.6, x, y: y - h, w, h, strokes: sig.strokes,
|
||||||
});
|
});
|
||||||
this._afterStamp(a);
|
this._afterStamp(a);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue