feat(web): highlight extent adjustment — draggable handles with word/punctuation snap

Objective 9 (web half). The selection popover's edit mode gains an
Adjust button: the overlay's show-annotation event stashes the live DOM
range, start/end teardrop handles render over it (host layer above the
iframe), and pointer drags map into the iframe's caretRangeFromPoint.
Snapping: land next to punctuation → the boundary sits exactly there;
land inside a word → the word's edge in the drag direction; whitespace
trims toward the adjacent word. A live preview repaints through the
overlayer under a scratch key; relocation (page turn) cancels safely.

Release recomputes the range CFI + collapsed-end CFI (view.getCFI) and
saves through the popover's own saveHighlightChanges path (PUT +
remove-old/add-new overlay dance), keeping selection_text and the
anchors in lockstep.

Verified live E2E in Brave: extent extended by one word with the
comma/space snap behavior, saved to the server row, handles cleaned;
all snap paths unit-exercised in-page (word end/start, punctuation
exact on both sides, whitespace trim); the test highlight was restored
to its original extent through the same adjust flow (punctuation-precision
drag back to the comma).
This commit is contained in:
John O'Keefe committed 2026-10-05 16:50:27 -04:00
1 parent f45adc6362
commit c8e85817aa
4 files changed
+298 -1

No files matched your search

+264
View File
@@ -486,6 +486,16 @@ document.addEventListener("alpine:init", () => {
pdfPage: -1,
pdfRects: [] as number[][],
},
/** Extent-adjustment context (objective 9): stashed by show-annotation,
* consumed by startAdjust. Null outside adjust mode. */
adjustContext: null as null | {
id: string;
index: number;
doc: any;
range: Range;
color: string;
},
adjustCancelOnRelocate: null as null | (() => void),
pdfSelDoc: null as any,
progressText: "",
progressLabel: "",
@@ -857,6 +867,9 @@ document.addEventListener("alpine:init", () => {
const frame = doc?.defaultView?.frameElement as HTMLElement | null;
const iframeRect = frame?.getBoundingClientRect();
const rect = range.getBoundingClientRect();
// Stash the live range for extent adjustment (objective 9): the
// overlayer hands back the exact DOM range it painted from.
this.adjustContext = { id: h.id, index, doc, range, color: h.color };
this.openSelectionPopover({
mode: "edit",
x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2,
@@ -1257,6 +1270,257 @@ document.addEventListener("alpine:init", () => {
this.selectionPopover.open = false;
this.selectionPopover.noteOpen = false;
},
// ----- highlight extent adjustment (objective 9) -----
/** Enter adjust mode: hide the popover, render start/end handles over
* the clicked highlight's range, and take over drags. The range is
* edited live with an overlayer preview; release recomputes the CFIs
* and saves through the same path the popover's Save uses. */
startAdjust() {
const ctx = this.adjustContext;
if (!ctx || !ctx.range) return;
this.hideSelectionPopover();
const layer = this.ensureAdjustLayer();
layer.innerHTML = "";
const mkHandle = (which: "start" | "end") => {
const dot = document.createElement("div");
dot.className = "adjust-handle";
dot.dataset.which = which;
// Pointer events (mouse + touch + pen); the page's own touch
// handlers are not engaged because the handles live in the HOST
// document, above the iframe.
dot.addEventListener("pointerdown", (e) => {
e.preventDefault();
dot.setPointerCapture(e.pointerId);
dot.dataset.dragging = "1";
});
dot.addEventListener("pointermove", (e) => {
if (!dot.dataset.dragging) return;
this.adjustMove(which, e.clientX, e.clientY);
});
dot.addEventListener("pointerup", (e) => {
if (!dot.dataset.dragging) return;
delete dot.dataset.dragging;
dot.releasePointerCapture?.(e.pointerId);
this.adjustFinalize();
});
dot.addEventListener("pointercancel", () => {
delete dot.dataset.dragging;
});
layer.appendChild(dot);
return dot;
};
mkHandle("start");
mkHandle("end");
this.positionAdjustHandles();
// A page turn (or any relocation that re-renders the section) ends
// the adjustment safely — the preview is discarded, nothing saved.
this.adjustCancelOnRelocate = () => this.cancelAdjust();
this.view?.addEventListener("relocate", this.adjustCancelOnRelocate, {
once: true,
});
},
ensureAdjustLayer(): HTMLElement {
let layer = document.getElementById("adjust-handles");
if (!layer) {
layer = document.createElement("div");
layer.id = "adjust-handles";
layer.style.position = "fixed";
layer.style.inset = "0";
layer.style.pointerEvents = "none";
layer.style.zIndex = "70";
document.body.appendChild(layer);
}
return layer;
},
/** Host-document coordinates of the working range's two ends. */
adjustHandlePoints(): { start: DOMRect | null; end: DOMRect | null; frame: HTMLElement | null } {
const ctx = this.adjustContext;
if (!ctx) return { start: null, end: null, frame: null };
const frame = ctx.doc?.defaultView?.frameElement as HTMLElement | null;
const iframeRect = frame?.getBoundingClientRect();
const rStart = ctx.range.cloneRange();
rStart.collapse(true);
const rEnd = ctx.range.cloneRange();
rEnd.collapse(false);
const s = rStart.getBoundingClientRect();
const e = rEnd.getBoundingClientRect();
const map = (r: DOMRect) =>
new DOMRect(
(iframeRect?.left ?? 0) + r.left,
(iframeRect?.top ?? 0) + r.top,
r.width,
r.height,
);
return { start: map(s), end: map(e), frame };
},
positionAdjustHandles() {
const layer = document.getElementById("adjust-handles");
if (!layer) return;
const { start, end } = this.adjustHandlePoints();
const dots = layer.querySelectorAll<HTMLDivElement>(".adjust-handle");
dots.forEach((dot) => {
const r = dot.dataset.which === "start" ? start : end;
if (!r) return;
// Handles sit ON the boundary point; a 26px dot centered on it.
dot.style.left = `${r.left - 13}px`;
dot.style.top = `${r.top - 6}px`;
});
},
/** Drag move: map the pointer into the iframe's content coordinates,
* read the caret there, snap it, update the working range, and
* redraw the preview highlight. */
adjustMove(which: "start" | "end", clientX: number, clientY: number) {
const ctx = this.adjustContext;
if (!ctx) return;
const contents = this.renderer?.getContents?.() ?? [];
const content = contents.find((c: any) => c.index === ctx.index);
if (!content?.doc) return;
const frame = content.doc.defaultView?.frameElement as HTMLElement | null;
const iframeRect = frame?.getBoundingClientRect();
if (!iframeRect) return;
// The iframe is the coordinate space (AGENTS.md #18): client − frame
// offset = content point.
const x = clientX - (iframeRect.left ?? 0);
const y = clientY - (iframeRect.top ?? 0);
// caretRangeFromPoint is Chromium/WebKit; caretPositionFromPoint is
// the standards track — support both.
const caretRange =
content.doc.caretRangeFromPoint?.(x, y) ??
(() => {
const pos = content.doc.caretPositionFromPoint?.(x, y);
if (!pos) return null;
const r = content.doc.createRange();
r.setStart(pos.offsetNode, pos.offset);
r.collapse(true);
return r;
})();
if (!caretRange) return;
const snapped = this.snapBoundary(
caretRange.startContainer,
caretRange.startOffset,
which === "start",
);
if (!snapped) return;
// Rebuild the working range: the opposite end stays anchored.
const working = ctx.range.cloneRange();
try {
if (which === "start") working.setStart(snapped.node, snapped.offset);
else working.setEnd(snapped.node, snapped.offset);
} catch {
return;
}
// Degenerate (handles crossed): clamp to a point, never throw.
if (working.collapsed && which === "start") {
// start pushed past end: keep at the end boundary
working.setStart(snapped.node, snapped.offset);
}
ctx.range = working;
this.drawAdjustPreview();
this.positionAdjustHandles();
},
/** Word-boundary snap with punctuation precision (objective 9):
* land next to punctuation → the boundary sits exactly there;
* land inside a word → snap to that word's edge in the drag
* direction; land on whitespace → trim toward the word. */
snapBoundary(node: Node, offset: number, isStart: boolean): { node: Node; offset: number } | null {
if (node.nodeType !== Node.TEXT_NODE) {
// Element landing: descend to the nearest text position.
const doc = node.ownerDocument;
if (!doc) return null;
const walker = doc.createTreeWalker(node, NodeFilter.SHOW_TEXT);
const first = walker.nextNode();
if (first) return { node: first, offset: 0 };
return null;
}
const text = node.nodeValue ?? "";
const isWordChar = (ch: string) => /[\p{L}\p{N}]/u.test(ch);
const isPunct = (ch: string) => ch && !isWordChar(ch) && !/\s/.test(ch);
const before = offset > 0 ? text[offset - 1] : "";
const at = offset < text.length ? text[offset] : "";
// Punctuation precision: the boundary takes the exact caret spot
// when it touches punctuation (e.g. the period of "sentence.").
if ((before && isPunct(before)) || (at && isPunct(at))) {
return { node, offset };
}
if (at && isWordChar(at)) {
let o = offset;
if (isStart) {
while (o > 0 && isWordChar(text[o - 1])) o--;
} else {
while (o < text.length && isWordChar(text[o])) o++;
}
return { node, offset: o };
}
// Whitespace: trim toward the adjacent word (leading whitespace off
// a start boundary, trailing off an end boundary).
if (isStart) {
let o = offset;
while (o < text.length && !isWordChar(text[o]) && text[o] !== "") o++;
return { node, offset: o };
}
let o = offset;
while (o > 0 && !isWordChar(text[o - 1])) o--;
return { node, offset: o };
},
/** Live preview: repaint the working range in the overlayer under a
* scratch key (removed on the next move or on finalize). */
drawAdjustPreview() {
const ctx = this.adjustContext;
if (!ctx) return;
const contents = this.renderer?.getContents?.() ?? [];
const content = contents.find((c: any) => c.index === ctx.index);
if (!content?.overlayer) return;
content.overlayer.remove("__adjust-preview");
content.overlayer.add("__adjust-preview", ctx.range, Overlayer.highlight, {
color: ctx.color,
});
},
/** Release: recompute the anchors from the working range, save through
* the popover's own path, and clean the mode up. */
async adjustFinalize() {
const ctx = this.adjustContext;
if (!ctx) return;
const { index, doc, range, id } = ctx;
const text = range.toString().replace(/\s+/g, " ").trim();
this.cancelAdjust();
if (!text) {
showToast("Highlights can't be empty", "error");
return;
}
let cfi = "";
let cfiEnd = "";
try {
cfi = this.view.getCFI(index, range);
const endRange = range.cloneRange();
endRange.collapse(false);
cfiEnd = this.view.getCFI(index, endRange);
} catch {
showToast("Could not anchor the adjusted selection", "error");
return;
}
// Route through the popover state: saveHighlightChanges reads p.cfi /
// p.cfiEnd / p.text and handles the PUT + overlay re-render dance.
const p = this.selectionPopover;
p.id = id;
p.cfi = cfi;
p.cfiEnd = cfiEnd;
p.text = text;
await this.saveHighlightChanges();
},
cancelAdjust() {
const ctx = this.adjustContext;
if (ctx) {
const contents = this.renderer?.getContents?.() ?? [];
const content = contents.find((c: any) => c.index === ctx.index);
content?.overlayer?.remove("__adjust-preview");
}
document.getElementById("adjust-handles")?.remove();
if (this.adjustCancelOnRelocate) {
this.view?.removeEventListener?.("relocate", this.adjustCancelOnRelocate);
this.adjustCancelOnRelocate = null;
}
this.adjustContext = null;
},
scheduleSelectionPopover(doc: any, index: number) {
if (this.selPopTimer) clearTimeout(this.selPopTimer);
this.selPopTimer = setTimeout(() => {