From 929a862e745e1ff3c33c354125971833afe6fc32 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 15 Sep 2026 19:33:45 -0400 Subject: [PATCH] feat(reader): touch selection handles, copy fallback, highlight tap editing - Custom drag handles (start/end) for post-lift selection adjustment: the native handles are disabled with the rest of the native touch selection controller; these are positioned at the selection's boundary carets and driven through the same clamped caret mapping - Copy button falls back to execCommand via a transient textarea on plain HTTP (navigator.clipboard is unavailable on LAN addresses); both paths clear the selection and dismiss the popover - Only dismiss the selection popover on actual position changes in relocate (detail.section is a fresh object on every relocate, so reference comparison always saw a move); a tap's no-op relocate was closing the just-opened highlight edit popover - Pin foliate-js f872a01 (synthetic click dispatch on quick taps) and 422e8e0 (don't snap taps that never panned) --- package.json | 2 +- web/src/reader/reader.ts | 225 +++++++++++++++++++++++++++++++++------ web/static/input.css | 31 ++++++ 3 files changed, 224 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index a45b00a..81c70cf 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dev": "npm run build:ts:dev && npm run build:css" }, "dependencies": { - "@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#9cfe266929ba650438d908253671a75a42dc4ccf", + "@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#422e8e0", "alpinejs": "^3.15.8", "chart.js": "^4.5.1", "highlight.js": "^11.11.1", diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 6667935..68b0284 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -713,28 +713,6 @@ document.addEventListener("alpine:init", () => { // out to the host document, so the viewport listeners miss them). if (window.matchMedia("(pointer: coarse)").matches) { this.attachTapZoneListeners(doc as unknown as HTMLElement, true); - // Chrome (notably on older builds) selects the word under a - // quick tap and then raises its own search/selection sheet for - // it. Make the document unselectable for the first moments of - // every touch so a tap cannot create a selection at all; the - // long-press selection engages later (~500ms), well after the - // style is restored. - let tapGuardTimer: ReturnType | null = null; - doc.addEventListener( - "touchstart", - () => { - const el = doc.documentElement; - el.style.userSelect = "none"; - (el.style as any).webkitUserSelect = "none"; - if (tapGuardTimer) clearTimeout(tapGuardTimer); - tapGuardTimer = setTimeout(() => { - tapGuardTimer = null; - el.style.userSelect = ""; - (el.style as any).webkitUserSelect = ""; - }, 350); - }, - { passive: true }, - ); // Same for selectionchange: a selection inside the iframe must // cancel armed tap actions and feed the host-surface guard. doc.addEventListener("selectionchange", () => { @@ -917,7 +895,18 @@ document.addEventListener("alpine:init", () => { this.view.addEventListener("relocate", (e: any) => { const { fraction, location, pageItem, cfi, tocItem, section } = e.detail; - this.hideSelectionPopover(); + // Only dismiss the popover on an actual position change — the + // paginator also emits no-op relocates (e.g. a tap's page-aligned + // anchor), which would otherwise close a just-opened highlight + // edit popover. NOTE: detail.section is an object (recreated on + // every relocate), so it can't be compared by reference; the + // fraction threshold alone covers section changes (any section + // boundary moves the whole-book fraction far more than 0.1%). + const prev = this.lastRelocateDetail; + const moved = + !prev || + Math.abs((prev.fraction ?? 0) - (fraction ?? 0)) > 0.001; + if (moved) this.hideSelectionPopover(); this.lastCfi = cfi || ""; this.lastRelocateDetail = { fraction, @@ -1038,6 +1027,10 @@ document.addEventListener("alpine:init", () => { this.pokeChrome(); }, pokeChrome() { + // Never raise the chrome while a touch selection is live — the + // selection popover owns the screen until the selection is + // consumed or dismissed. + if (this.anySelection) return; this.chromeVisible = true; if (this.chromeBehavior !== "auto-hide") return; if (this.hideTimer) clearTimeout(this.hideTimer); @@ -1154,10 +1147,7 @@ document.addEventListener("alpine:init", () => { ) return; const sel = isDoc ? (surface as any).getSelection?.() : null; - // Chrome on touch selects the word under a quick tap; only stand - // down for a selection made by an actual hold — the spurious - // tap selections are cleared by the popover's pointerup path. - if (sel?.toString?.() && Date.now() - downT >= 300) return; + if (sel?.toString?.()) return; // Host-surface blind spot: selections living in content iframes // (or the host's own fixed-layout text layer) never show in a // per-surface check — the tracked flag covers them. @@ -1200,6 +1190,13 @@ document.addEventListener("alpine:init", () => { } this.tapZoneTimer = setTimeout(() => { this.tapZoneTimer = null; + // Touch pages by swipe only (the paginator's pan + snap); a tap + // anywhere just toggles the chrome. The edge zones remain for + // mouse-driven taps. + if (window.matchMedia("(pointer: coarse)").matches) { + this.toggleChromeManually(); + return; + } const zone = this.tapZoneSize / 100; if (relX < zone) this.goLeft(); else if (relX > 1 - zone) this.goRight(); @@ -1275,6 +1272,7 @@ document.addEventListener("alpine:init", () => { this.selectionPopover.mode === "create" ) this.hideSelectionPopover(); + this.hideSelectionHandles(); return; } // The browser's selection auto-scroll (drags near a viewport @@ -1414,10 +1412,11 @@ document.addEventListener("alpine:init", () => { // On touch the browser's own selection menu attaches ABOVE the // selection (and paints over page content) — place ours BELOW it // so both are visible. The popover anchors bottom-center at y-10 - // and is ~44px tall, hence the offset. + // and is ~44px tall; the drag handles occupy the first 24px below + // the selection, so the offset must clear them too. const coarse = window.matchMedia("(pointer: coarse)").matches; const y = coarse - ? (iframeRect?.top ?? 0) + rect.bottom + 64 + ? (iframeRect?.top ?? 0) + rect.bottom + 90 : (iframeRect?.top ?? 0) + rect.top; this.openSelectionPopover({ mode: "create", @@ -1427,6 +1426,8 @@ document.addEventListener("alpine:init", () => { cfi, cfiEnd, }); + if (coarse && this.view?.book?.dir !== "rtl") + this.showSelectionHandles(doc); }, renderAllHighlights() { for (const hl of this.highlightItems) { @@ -1729,12 +1730,170 @@ document.addEventListener("alpine:init", () => { } }, async copySelectionText() { + const text = this.selectionPopover.text; try { - await navigator.clipboard.writeText(this.selectionPopover.text); - this.hideSelectionPopover(); - } catch (_e) { - /* clipboard unavailable */ + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + this.finishCopy(); + return; + } + } catch { + /* fall through to the legacy path */ } + // Plain HTTP (typical LAN access) has no navigator.clipboard; + // a transient textarea + execCommand works from this user + // gesture on insecure contexts. + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.append(ta); + ta.select(); + document.execCommand("copy"); + ta.remove(); + this.finishCopy(); + } catch { + /* ignore clipboard errors */ + } + }, + finishCopy() { + try { + this.currentDoc?.getSelection()?.removeAllRanges(); + } catch { + /* doc may be gone */ + } + this.hideSelectionPopover(); + }, + // ----- touch selection handles ----- + // The native selection handles cannot be used (the custom gesture's + // touchstart preventDefault disables them along with the rest of the + // native controller), so adjustment after lift happens through these. + // Purely imperative DOM — position updates run on every drag frame. + selectionHandles: { + root: null as HTMLElement | null, + start: null as HTMLElement | null, + end: null as HTMLElement | null, + drag: null as "start" | "end" | null, + doc: null as any, + }, + ensureSelectionHandles() { + const h = this.selectionHandles as any; + if (h.root) return; + const root = document.createElement("div"); + root.id = "selection-handles"; + const make = (which: "start" | "end") => { + const el = document.createElement("div"); + el.className = "sel-handle"; + el.addEventListener("pointerdown", (e: PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + h.drag = which; + el.setPointerCapture(e.pointerId); + }); + el.addEventListener("pointermove", (e: PointerEvent) => { + if (h.drag !== which) return; + e.preventDefault(); + this.dragSelectionHandle(e); + }); + const release = () => { + h.drag = null; + }; + el.addEventListener("pointerup", release); + el.addEventListener("pointercancel", release); + root.append(el); + return el; + }; + h.start = make("start"); + h.end = make("end"); + h.root = root; + document.body.append(root); + }, + showSelectionHandles(doc: any) { + this.ensureSelectionHandles(); + const h = this.selectionHandles as any; + h.doc = doc; + h.root!.style.display = "block"; + this.positionSelectionHandles(doc); + }, + hideSelectionHandles() { + const h = this.selectionHandles as any; + if (!h.root) return; + h.root.style.display = "none"; + h.drag = null; + }, + // Host-space position of a selection boundary: the left edge of the + // start caret or the right edge of the end caret, at the line bottom. + caretHostPos(doc: any, node: any, offset: number, forward: boolean) { + try { + const p = doc.createRange(); + p.setStart(node, offset); + if (node?.nodeType === 3) { + if (forward && offset < node.length) p.setEnd(node, offset + 1); + else if (!forward && offset > 0) p.setStart(node, offset - 1); + } + if (p.collapsed) return null; + const rects = p.getClientRects(); + const r = rects[forward ? 0 : rects.length - 1]; + if (!r) return null; + const off = doc.defaultView?.frameElement?.getBoundingClientRect(); + return { + x: (off?.left ?? 0) + (forward ? r.left : r.right), + y: (off?.top ?? 0) + r.bottom, + }; + } catch { + return null; + } + }, + positionSelectionHandles(doc: any) { + const h = this.selectionHandles as any; + if (!h.root || h.root.style.display === "none") return; + const sel = doc.getSelection(); + if (!sel?.rangeCount || sel.isCollapsed) { + this.hideSelectionHandles(); + return; + } + const range = sel.getRangeAt(0); + const startPos = this.caretHostPos(doc, range.startContainer, + range.startOffset, true); + const endPos = this.caretHostPos(doc, range.endContainer, + range.endOffset, false); + if (!startPos || !endPos) { + this.hideSelectionHandles(); + return; + } + h.start!.style.transform = `translate(${startPos.x - 6}px, ${startPos.y}px)`; + h.end!.style.transform = `translate(${endPos.x - 6}px, ${endPos.y}px)`; + }, + dragSelectionHandle(ev: PointerEvent) { + const h = this.selectionHandles as any; + const doc = h.doc; + const r: any = this.renderer; + if (!doc || !r || r.scrolled) return; + if (this.view?.book?.dir === "rtl") return; + const frame = doc.defaultView?.frameElement; + if (!frame) return; + const off = frame.getBoundingClientRect(); + // pointer → iframe content space, clamped to the visible page + const size = r.size; + const left = r.start - size; + const right = r.start; + const x = Math.min(Math.max(ev.clientX - off.left, left + 2), right - 2); + const y = ev.clientY - off.top; + const caret = doc.caretRangeFromPoint?.(x, y) + ?? doc.caretPositionFromPoint?.(x, y); + if (!caret) return; + const node = caret.startContainer ?? caret.offsetNode; + const offset = caret.startOffset ?? caret.offset; + const sel = doc.getSelection(); + if (!sel?.rangeCount || !node) return; + const range = sel.getRangeAt(0); + if (h.drag === "start") + sel.setBaseAndExtent(node, offset, range.endContainer, range.endOffset); + else + sel.setBaseAndExtent(range.startContainer, range.startOffset, + node, offset); + this.positionSelectionHandles(doc); }, goToHighlight(hl: { cfi: string; diff --git a/web/static/input.css b/web/static/input.css index b7af1b5..d769911 100644 --- a/web/static/input.css +++ b/web/static/input.css @@ -1434,3 +1434,34 @@ } } } + +/* Touch selection handles (custom gesture — the native ones are disabled) */ +#selection-handles { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 55; +} +#selection-handles .sel-handle { + position: absolute; + top: 0; + left: 0; + width: 12px; + height: 24px; + pointer-events: auto; + touch-action: none; + background: #8ab4f8; + border-radius: 6px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45); + opacity: 0.95; +} +#selection-handles .sel-handle::before { + content: ""; + position: absolute; + top: -4px; + left: 50%; + width: 2px; + height: 5px; + background: inherit; + transform: translateX(-50%); +}