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)
This commit is contained in:
+192
-33
@@ -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<typeof setTimeout> | 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;
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user