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:
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"dev": "npm run build:ts:dev && npm run build:css"
|
"dev": "npm run build:ts:dev && npm run build:css"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"alpinejs": "^3.15.8",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
|||||||
+192
-33
@@ -713,28 +713,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
// out to the host document, so the viewport listeners miss them).
|
// out to the host document, so the viewport listeners miss them).
|
||||||
if (window.matchMedia("(pointer: coarse)").matches) {
|
if (window.matchMedia("(pointer: coarse)").matches) {
|
||||||
this.attachTapZoneListeners(doc as unknown as HTMLElement, true);
|
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
|
// Same for selectionchange: a selection inside the iframe must
|
||||||
// cancel armed tap actions and feed the host-surface guard.
|
// cancel armed tap actions and feed the host-surface guard.
|
||||||
doc.addEventListener("selectionchange", () => {
|
doc.addEventListener("selectionchange", () => {
|
||||||
@@ -917,7 +895,18 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.view.addEventListener("relocate", (e: any) => {
|
this.view.addEventListener("relocate", (e: any) => {
|
||||||
const { fraction, location, pageItem, cfi, tocItem, section } =
|
const { fraction, location, pageItem, cfi, tocItem, section } =
|
||||||
e.detail;
|
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.lastCfi = cfi || "";
|
||||||
this.lastRelocateDetail = {
|
this.lastRelocateDetail = {
|
||||||
fraction,
|
fraction,
|
||||||
@@ -1038,6 +1027,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.pokeChrome();
|
this.pokeChrome();
|
||||||
},
|
},
|
||||||
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;
|
this.chromeVisible = true;
|
||||||
if (this.chromeBehavior !== "auto-hide") return;
|
if (this.chromeBehavior !== "auto-hide") return;
|
||||||
if (this.hideTimer) clearTimeout(this.hideTimer);
|
if (this.hideTimer) clearTimeout(this.hideTimer);
|
||||||
@@ -1154,10 +1147,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const sel = isDoc ? (surface as any).getSelection?.() : null;
|
const sel = isDoc ? (surface as any).getSelection?.() : null;
|
||||||
// Chrome on touch selects the word under a quick tap; only stand
|
if (sel?.toString?.()) return;
|
||||||
// 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;
|
|
||||||
// Host-surface blind spot: selections living in content iframes
|
// Host-surface blind spot: selections living in content iframes
|
||||||
// (or the host's own fixed-layout text layer) never show in a
|
// (or the host's own fixed-layout text layer) never show in a
|
||||||
// per-surface check — the tracked flag covers them.
|
// per-surface check — the tracked flag covers them.
|
||||||
@@ -1200,6 +1190,13 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
this.tapZoneTimer = setTimeout(() => {
|
this.tapZoneTimer = setTimeout(() => {
|
||||||
this.tapZoneTimer = null;
|
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;
|
const zone = this.tapZoneSize / 100;
|
||||||
if (relX < zone) this.goLeft();
|
if (relX < zone) this.goLeft();
|
||||||
else if (relX > 1 - zone) this.goRight();
|
else if (relX > 1 - zone) this.goRight();
|
||||||
@@ -1275,6 +1272,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.selectionPopover.mode === "create"
|
this.selectionPopover.mode === "create"
|
||||||
)
|
)
|
||||||
this.hideSelectionPopover();
|
this.hideSelectionPopover();
|
||||||
|
this.hideSelectionHandles();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The browser's selection auto-scroll (drags near a viewport
|
// 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
|
// On touch the browser's own selection menu attaches ABOVE the
|
||||||
// selection (and paints over page content) — place ours BELOW it
|
// selection (and paints over page content) — place ours BELOW it
|
||||||
// so both are visible. The popover anchors bottom-center at y-10
|
// 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 coarse = window.matchMedia("(pointer: coarse)").matches;
|
||||||
const y = coarse
|
const y = coarse
|
||||||
? (iframeRect?.top ?? 0) + rect.bottom + 64
|
? (iframeRect?.top ?? 0) + rect.bottom + 90
|
||||||
: (iframeRect?.top ?? 0) + rect.top;
|
: (iframeRect?.top ?? 0) + rect.top;
|
||||||
this.openSelectionPopover({
|
this.openSelectionPopover({
|
||||||
mode: "create",
|
mode: "create",
|
||||||
@@ -1427,6 +1426,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
cfi,
|
cfi,
|
||||||
cfiEnd,
|
cfiEnd,
|
||||||
});
|
});
|
||||||
|
if (coarse && this.view?.book?.dir !== "rtl")
|
||||||
|
this.showSelectionHandles(doc);
|
||||||
},
|
},
|
||||||
renderAllHighlights() {
|
renderAllHighlights() {
|
||||||
for (const hl of this.highlightItems) {
|
for (const hl of this.highlightItems) {
|
||||||
@@ -1729,12 +1730,170 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async copySelectionText() {
|
async copySelectionText() {
|
||||||
|
const text = this.selectionPopover.text;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(this.selectionPopover.text);
|
if (navigator.clipboard?.writeText) {
|
||||||
this.hideSelectionPopover();
|
await navigator.clipboard.writeText(text);
|
||||||
} catch (_e) {
|
this.finishCopy();
|
||||||
/* clipboard unavailable */
|
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: {
|
goToHighlight(hl: {
|
||||||
cfi: string;
|
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