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:
1 parent
f45adc6362
commit
c8e85817aa
4 files changed
+298
-1
No files matched your search
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user