mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(reader): preserve iOS text selection adjustment
Keep pagination and the selection popup idle until native selection gestures finish, including cancelled touches. Cover paginated and scrolled flows while retaining ordinary swipe snapping. Closes #1487
This commit is contained in:
@@ -876,9 +876,11 @@ export class Paginator extends HTMLElement {
|
|||||||
this.#touchScrolled = true
|
this.#touchScrolled = true
|
||||||
this.scrollBy(dx, dy)
|
this.scrollBy(dx, dy)
|
||||||
}
|
}
|
||||||
#onTouchEnd() {
|
#onTouchEnd(e) {
|
||||||
this.#touchScrolled = false
|
this.#touchScrolled = false
|
||||||
if (this.scrolled) return
|
if (this.scrolled) return
|
||||||
|
const doc = e.currentTarget?.getSelection ? e.currentTarget : this.#view?.document
|
||||||
|
if (hasActiveTextSelection(doc)) return
|
||||||
|
|
||||||
// XXX: Firefox seems to report scale as 1... sometimes...?
|
// XXX: Firefox seems to report scale as 1... sometimes...?
|
||||||
// at this point I'm basically throwing `requestAnimationFrame` at
|
// at this point I'm basically throwing `requestAnimationFrame` at
|
||||||
|
|||||||
@@ -849,6 +849,7 @@ const {
|
|||||||
deleteAnnotation,
|
deleteAnnotation,
|
||||||
redrawAnnotation,
|
redrawAnnotation,
|
||||||
setTextSelectedHandler,
|
setTextSelectedHandler,
|
||||||
|
setSelectionInteractionStartHandler,
|
||||||
setAnnotationClickHandler,
|
setAnnotationClickHandler,
|
||||||
view: foliateView,
|
view: foliateView,
|
||||||
bookLanguage,
|
bookLanguage,
|
||||||
@@ -880,6 +881,7 @@ function handleAnnotationClick(cfi: string, popupPosition: { x: number; y: numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTextSelectedHandler(handleTextSelected)
|
setTextSelectedHandler(handleTextSelected)
|
||||||
|
setSelectionInteractionStartHandler(selection.dismiss)
|
||||||
setAnnotationClickHandler(handleAnnotationClick)
|
setAnnotationClickHandler(handleAnnotationClick)
|
||||||
|
|
||||||
onUnmounted(clearFoliateSource)
|
onUnmounted(clearFoliateSource)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ describe('Foliate navigation', () => {
|
|||||||
let Paginator: new () => {
|
let Paginator: new () => {
|
||||||
sections: { load: () => Promise<string | null> }[]
|
sections: { load: () => Promise<string | null> }[]
|
||||||
goTo: (target: { index: number }) => Promise<void>
|
goTo: (target: { index: number }) => Promise<void>
|
||||||
|
snap: (vx: number, vy: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -220,8 +221,13 @@ describe('Foliate navigation', () => {
|
|||||||
expect(getPageScrollOffset(1, 800, true, false)).toBe(800)
|
expect(getPageScrollOffset(1, 800, true, false)).toBe(800)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not intercept paginated touch movement while native text selection is active', () => {
|
it('does not intercept or snap paginated touch gestures while native text selection is active', () => {
|
||||||
const paginator = new Paginator() as InstanceType<typeof Paginator> & EventTarget
|
const paginator = new Paginator() as InstanceType<typeof Paginator> & EventTarget
|
||||||
|
const snap = vi.spyOn(paginator, 'snap').mockImplementation(() => {})
|
||||||
|
const animationFrame = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||||
|
callback(0)
|
||||||
|
return 0
|
||||||
|
})
|
||||||
const doc = new EventTarget() as EventTarget & Document
|
const doc = new EventTarget() as EventTarget & Document
|
||||||
Object.defineProperty(doc, 'getSelection', {
|
Object.defineProperty(doc, 'getSelection', {
|
||||||
value: () => ({ rangeCount: 1, isCollapsed: false }),
|
value: () => ({ rangeCount: 1, isCollapsed: false }),
|
||||||
@@ -244,7 +250,58 @@ describe('Foliate navigation', () => {
|
|||||||
})
|
})
|
||||||
doc.dispatchEvent(move)
|
doc.dispatchEvent(move)
|
||||||
|
|
||||||
|
const end = new Event('touchend', { bubbles: true, cancelable: true }) as TouchEvent
|
||||||
|
Object.defineProperties(end, {
|
||||||
|
touches: { value: [] },
|
||||||
|
changedTouches: { value: [touch] },
|
||||||
|
})
|
||||||
|
doc.dispatchEvent(end)
|
||||||
|
|
||||||
expect(move.defaultPrevented).toBe(false)
|
expect(move.defaultPrevented).toBe(false)
|
||||||
|
expect(end.defaultPrevented).toBe(false)
|
||||||
|
expect(snap).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
animationFrame.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still snaps ordinary paginated touch gestures', () => {
|
||||||
|
const paginator = new Paginator() as InstanceType<typeof Paginator> & EventTarget
|
||||||
|
const snap = vi.spyOn(paginator, 'snap').mockImplementation(() => {})
|
||||||
|
const originalVisualViewport = Object.getOwnPropertyDescriptor(globalThis, 'visualViewport')
|
||||||
|
Object.defineProperty(globalThis, 'visualViewport', {
|
||||||
|
configurable: true,
|
||||||
|
value: { scale: 1 },
|
||||||
|
})
|
||||||
|
const animationFrame = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||||
|
callback(0)
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
const doc = new EventTarget() as EventTarget & Document
|
||||||
|
Object.defineProperty(doc, 'getSelection', {
|
||||||
|
value: () => null,
|
||||||
|
})
|
||||||
|
paginator.dispatchEvent(new CustomEvent('load', { detail: { doc } }))
|
||||||
|
|
||||||
|
const touch = { clientX: 30, clientY: 40, screenX: 30, screenY: 40 }
|
||||||
|
const start = new Event('touchstart', { bubbles: true, cancelable: true }) as TouchEvent
|
||||||
|
Object.defineProperties(start, {
|
||||||
|
touches: { value: [touch] },
|
||||||
|
changedTouches: { value: [touch] },
|
||||||
|
})
|
||||||
|
doc.dispatchEvent(start)
|
||||||
|
|
||||||
|
const end = new Event('touchend', { bubbles: true, cancelable: true }) as TouchEvent
|
||||||
|
Object.defineProperties(end, {
|
||||||
|
touches: { value: [] },
|
||||||
|
changedTouches: { value: [touch] },
|
||||||
|
})
|
||||||
|
doc.dispatchEvent(end)
|
||||||
|
|
||||||
|
expect(snap).toHaveBeenCalledWith(0, undefined)
|
||||||
|
|
||||||
|
animationFrame.mockRestore()
|
||||||
|
if (originalVisualViewport) Object.defineProperty(globalThis, 'visualViewport', originalVisualViewport)
|
||||||
|
else Reflect.deleteProperty(globalThis, 'visualViewport')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps physical left and right navigation using OPF RTL page progression', async () => {
|
it('maps physical left and right navigation using OPF RTL page progression', async () => {
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ vi.mock('../useFoliateAnnotations', () => ({
|
|||||||
vi.mock('../useFoliateSelection', () => ({
|
vi.mock('../useFoliateSelection', () => ({
|
||||||
useFoliateSelection: () => ({
|
useFoliateSelection: () => ({
|
||||||
setHandler: vi.fn<() => void>(),
|
setHandler: vi.fn<() => void>(),
|
||||||
|
setInteractionStartHandler: vi.fn<() => void>(),
|
||||||
handleSelectionEnd: vi.fn<() => void>(),
|
handleSelectionEnd: vi.fn<() => void>(),
|
||||||
handleSelectionChange: vi.fn<() => void>(),
|
handleSelectionChange: vi.fn<() => void>(),
|
||||||
|
handleInteractionStart: vi.fn<() => void>(),
|
||||||
|
handleInteractionEnd: vi.fn<() => void>(),
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,78 @@ describe('useFoliateInput', () => {
|
|||||||
input.cleanup()
|
input.cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(['paginated', 'scrolled'])('brackets a selection gesture in %s flow until touchend', (flow) => {
|
||||||
|
const handleInteractionStart = vi.fn<(doc: Document) => void>()
|
||||||
|
const handleInteractionEnd = vi.fn<(doc: Document) => void>()
|
||||||
|
const selection = {
|
||||||
|
isCollapsed: false,
|
||||||
|
rangeCount: 1,
|
||||||
|
} as Selection
|
||||||
|
const view: ViewLike = {
|
||||||
|
prev: vi.fn<() => void>(),
|
||||||
|
next: vi.fn<() => void>(),
|
||||||
|
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
|
||||||
|
renderer: { getAttribute: (name) => (name === 'flow' ? flow : null) },
|
||||||
|
}
|
||||||
|
const input = useFoliateInput(
|
||||||
|
() => view,
|
||||||
|
undefined,
|
||||||
|
vi.fn<() => void>(),
|
||||||
|
vi.fn<() => void>(),
|
||||||
|
undefined,
|
||||||
|
handleInteractionStart,
|
||||||
|
handleInteractionEnd,
|
||||||
|
)
|
||||||
|
const doc = makeDocTarget(selection)
|
||||||
|
input.attachIframeClicks(doc)
|
||||||
|
|
||||||
|
doc.dispatchEvent(makeTouchEvent('touchstart', [{ clientX: 20, clientY: 30 }]))
|
||||||
|
doc.dispatchEvent(new Event('selectionchange'))
|
||||||
|
|
||||||
|
expect(handleInteractionStart).toHaveBeenCalledWith(doc)
|
||||||
|
expect(handleInteractionEnd).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
doc.dispatchEvent(makeTouchEvent('touchend', [], [{ clientX: 30, clientY: 40 }]))
|
||||||
|
|
||||||
|
expect(handleInteractionEnd).toHaveBeenCalledWith(doc)
|
||||||
|
|
||||||
|
input.cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finishes selection interaction without navigating when touch is cancelled', () => {
|
||||||
|
const goLeft = vi.fn<() => void>()
|
||||||
|
const goRight = vi.fn<() => void>()
|
||||||
|
const handleInteractionEnd = vi.fn<(doc: Document) => void>()
|
||||||
|
const view: ViewLike = {
|
||||||
|
prev: vi.fn<() => void>(),
|
||||||
|
next: vi.fn<() => void>(),
|
||||||
|
goLeft,
|
||||||
|
goRight,
|
||||||
|
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
|
||||||
|
renderer: { getAttribute: (name) => (name === 'flow' ? 'paginated' : null) },
|
||||||
|
}
|
||||||
|
const input = useFoliateInput(
|
||||||
|
() => view,
|
||||||
|
undefined,
|
||||||
|
vi.fn<() => void>(),
|
||||||
|
vi.fn<() => void>(),
|
||||||
|
undefined,
|
||||||
|
vi.fn<(doc: Document) => void>(),
|
||||||
|
handleInteractionEnd,
|
||||||
|
)
|
||||||
|
const doc = makeDocTarget()
|
||||||
|
input.attachIframeClicks(doc)
|
||||||
|
|
||||||
|
doc.dispatchEvent(makeTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
|
||||||
|
doc.dispatchEvent(makeTouchEvent('touchcancel', [], [{ clientX: 20, clientY: 100 }]))
|
||||||
|
|
||||||
|
expect(handleInteractionEnd).toHaveBeenCalledWith(doc)
|
||||||
|
expect(goLeft).not.toHaveBeenCalled()
|
||||||
|
expect(goRight).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
input.cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ endX: 40, expectedDirection: 'right' },
|
{ endX: 40, expectedDirection: 'right' },
|
||||||
{ endX: 160, expectedDirection: 'left' },
|
{ endX: 160, expectedDirection: 'left' },
|
||||||
|
|||||||
@@ -147,4 +147,94 @@ describe('useFoliateSelection', () => {
|
|||||||
|
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('waits for an active touch interaction to finish before publishing the selection', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
const range = makeRange('Adjusted selection', {
|
||||||
|
left: 120,
|
||||||
|
top: 220,
|
||||||
|
bottom: 260,
|
||||||
|
width: 80,
|
||||||
|
height: 40,
|
||||||
|
})
|
||||||
|
const selection = {
|
||||||
|
isCollapsed: false,
|
||||||
|
rangeCount: 1,
|
||||||
|
getRangeAt: () => range,
|
||||||
|
} as unknown as Selection
|
||||||
|
const foliateSelection = useFoliateSelection(() => ({
|
||||||
|
renderer: { getContents: () => [{ index: 2 }] },
|
||||||
|
getCFI: () => 'epubcfi(/6/10)',
|
||||||
|
}))
|
||||||
|
const onSelected = vi.fn<(detail: unknown) => void>()
|
||||||
|
const onInteractionStart = vi.fn<() => void>()
|
||||||
|
foliateSelection.setHandler(onSelected)
|
||||||
|
foliateSelection.setInteractionStartHandler(onInteractionStart)
|
||||||
|
|
||||||
|
const doc = makeDoc({ selection })
|
||||||
|
foliateSelection.handleInteractionStart(doc)
|
||||||
|
foliateSelection.handleSelectionChange(doc)
|
||||||
|
vi.advanceTimersByTime(1_000)
|
||||||
|
|
||||||
|
expect(onInteractionStart).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onSelected).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
foliateSelection.handleInteractionEnd(doc)
|
||||||
|
vi.advanceTimersByTime(49)
|
||||||
|
expect(onSelected).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1)
|
||||||
|
expect(onSelected).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('publishes a selection created during a touch interaction only after touch end', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
let currentSelection = {
|
||||||
|
isCollapsed: true,
|
||||||
|
rangeCount: 0,
|
||||||
|
} as Selection
|
||||||
|
const range = makeRange('New selection', {
|
||||||
|
left: 120,
|
||||||
|
top: 220,
|
||||||
|
bottom: 260,
|
||||||
|
width: 80,
|
||||||
|
height: 40,
|
||||||
|
})
|
||||||
|
const doc = {
|
||||||
|
defaultView: {
|
||||||
|
getSelection: () => currentSelection,
|
||||||
|
frameElement: null,
|
||||||
|
},
|
||||||
|
} as unknown as Document
|
||||||
|
const foliateSelection = useFoliateSelection(() => ({
|
||||||
|
renderer: { getContents: () => [{ index: 2 }] },
|
||||||
|
getCFI: () => 'epubcfi(/6/10)',
|
||||||
|
}))
|
||||||
|
const onSelected = vi.fn<(detail: unknown) => void>()
|
||||||
|
const onInteractionStart = vi.fn<() => void>()
|
||||||
|
foliateSelection.setHandler(onSelected)
|
||||||
|
foliateSelection.setInteractionStartHandler(onInteractionStart)
|
||||||
|
|
||||||
|
foliateSelection.handleInteractionStart(doc)
|
||||||
|
currentSelection = {
|
||||||
|
isCollapsed: false,
|
||||||
|
rangeCount: 1,
|
||||||
|
getRangeAt: () => range,
|
||||||
|
} as unknown as Selection
|
||||||
|
foliateSelection.handleSelectionChange(doc)
|
||||||
|
vi.advanceTimersByTime(1_000)
|
||||||
|
|
||||||
|
expect(onInteractionStart).not.toHaveBeenCalled()
|
||||||
|
expect(onSelected).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
foliateSelection.handleInteractionEnd(doc)
|
||||||
|
vi.advanceTimersByTime(50)
|
||||||
|
expect(onSelected).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -115,7 +115,15 @@ export function useFoliate(
|
|||||||
|
|
||||||
const annotations = useFoliateAnnotations()
|
const annotations = useFoliateAnnotations()
|
||||||
const selection = useFoliateSelection(() => viewRef.value)
|
const selection = useFoliateSelection(() => viewRef.value)
|
||||||
const input = useFoliateInput(() => viewRef.value, onMiddleTap, selection.handleSelectionEnd, selection.handleSelectionChange, canNavigate)
|
const input = useFoliateInput(
|
||||||
|
() => viewRef.value,
|
||||||
|
onMiddleTap,
|
||||||
|
selection.handleSelectionEnd,
|
||||||
|
selection.handleSelectionChange,
|
||||||
|
canNavigate,
|
||||||
|
selection.handleInteractionStart,
|
||||||
|
selection.handleInteractionEnd,
|
||||||
|
)
|
||||||
|
|
||||||
async function loadScript() {
|
async function loadScript() {
|
||||||
if (customElements.get('foliate-view')) return
|
if (customElements.get('foliate-view')) return
|
||||||
@@ -438,6 +446,7 @@ export function useFoliate(
|
|||||||
deleteAnnotation: (cfi: string) => annotations.deleteAnnotation(viewRef.value, cfi),
|
deleteAnnotation: (cfi: string) => annotations.deleteAnnotation(viewRef.value, cfi),
|
||||||
redrawAnnotation: (cfi: string, color: string, style: string) => annotations.redrawAnnotation(viewRef.value, cfi, color, style),
|
redrawAnnotation: (cfi: string, color: string, style: string) => annotations.redrawAnnotation(viewRef.value, cfi, color, style),
|
||||||
setTextSelectedHandler: selection.setHandler,
|
setTextSelectedHandler: selection.setHandler,
|
||||||
|
setSelectionInteractionStartHandler: selection.setInteractionStartHandler,
|
||||||
setAnnotationClickHandler,
|
setAnnotationClickHandler,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export function useFoliateInput(
|
|||||||
handleSelectionEnd: (doc: Document) => void,
|
handleSelectionEnd: (doc: Document) => void,
|
||||||
handleSelectionChange: (doc: Document) => void,
|
handleSelectionChange: (doc: Document) => void,
|
||||||
canNavigate: (() => boolean) | undefined = undefined,
|
canNavigate: (() => boolean) | undefined = undefined,
|
||||||
|
handleSelectionInteractionStart: ((doc: Document) => void) | undefined = undefined,
|
||||||
|
handleSelectionInteractionEnd: ((doc: Document) => void) | undefined = undefined,
|
||||||
) {
|
) {
|
||||||
const clickedDocs = new WeakSet<Document>()
|
const clickedDocs = new WeakSet<Document>()
|
||||||
|
|
||||||
@@ -80,8 +82,9 @@ export function useFoliateInput(
|
|||||||
return canNavigate ? canNavigate() : true
|
return canNavigate ? canNavigate() : true
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchStart(e: TouchEvent) {
|
function handleTouchStart(e: TouchEvent, doc: Document) {
|
||||||
if (e.touches.length !== 1) return
|
if (e.touches.length !== 1) return
|
||||||
|
handleSelectionInteractionStart?.(doc)
|
||||||
const touch = e.touches[0]!
|
const touch = e.touches[0]!
|
||||||
touchStartX = touch.clientX
|
touchStartX = touch.clientX
|
||||||
touchStartY = touch.clientY
|
touchStartY = touch.clientY
|
||||||
@@ -108,21 +111,22 @@ export function useFoliateInput(
|
|||||||
if (deltaX > 10 && deltaX > deltaY && !isTextSelectionInProgress) return
|
if (deltaX > 10 && deltaX > deltaY && !isTextSelectionInProgress) return
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchEnd(e: TouchEvent, doc: Document) {
|
function handleTouchEnd(e: TouchEvent, doc: Document, cancelled = false) {
|
||||||
const touchEndTime = Date.now()
|
const touchEndTime = Date.now()
|
||||||
const touchDuration = touchEndTime - touchStartTime
|
const touchDuration = touchEndTime - touchStartTime
|
||||||
lastTouchTime = touchEndTime
|
lastTouchTime = touchEndTime
|
||||||
|
handleSelectionInteractionEnd?.(doc)
|
||||||
|
|
||||||
const selection = doc.defaultView?.getSelection()
|
const selection = doc.defaultView?.getSelection()
|
||||||
const hasSelection = selection && !selection.isCollapsed && selection.rangeCount > 0
|
const hasSelection = selection && !selection.isCollapsed && selection.rangeCount > 0
|
||||||
|
|
||||||
if (hasSelection) {
|
if (hasSelection) {
|
||||||
isTextSelectionInProgress = false
|
isTextSelectionInProgress = false
|
||||||
setTimeout(() => handleSelectionEnd(doc), 50)
|
if (!handleSelectionInteractionEnd) setTimeout(() => handleSelectionEnd(doc), 50)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isTextSelectionInProgress && e.changedTouches.length === 1) {
|
if (!cancelled && !isTextSelectionInProgress && e.changedTouches.length === 1) {
|
||||||
const touch = e.changedTouches[0]!
|
const touch = e.changedTouches[0]!
|
||||||
const deltaX = touch.clientX - touchStartX
|
const deltaX = touch.clientX - touchStartX
|
||||||
const deltaY = Math.abs(touch.clientY - touchStartY)
|
const deltaY = Math.abs(touch.clientY - touchStartY)
|
||||||
@@ -178,6 +182,7 @@ export function useFoliateInput(
|
|||||||
doc.addEventListener(
|
doc.addEventListener(
|
||||||
'mousedown',
|
'mousedown',
|
||||||
() => {
|
() => {
|
||||||
|
handleSelectionInteractionStart?.(doc)
|
||||||
if (longHoldTimeout) clearTimeout(longHoldTimeout)
|
if (longHoldTimeout) clearTimeout(longHoldTimeout)
|
||||||
longHoldTimeout = setTimeout(() => {
|
longHoldTimeout = setTimeout(() => {
|
||||||
longHoldTimeout = null
|
longHoldTimeout = null
|
||||||
@@ -187,7 +192,8 @@ export function useFoliateInput(
|
|||||||
)
|
)
|
||||||
|
|
||||||
doc.addEventListener('mouseup', () => {
|
doc.addEventListener('mouseup', () => {
|
||||||
handleSelectionEnd(doc)
|
if (handleSelectionInteractionEnd) handleSelectionInteractionEnd(doc)
|
||||||
|
else handleSelectionEnd(doc)
|
||||||
})
|
})
|
||||||
|
|
||||||
doc.addEventListener(
|
doc.addEventListener(
|
||||||
@@ -214,9 +220,10 @@ export function useFoliateInput(
|
|||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
|
|
||||||
doc.addEventListener('touchstart', (e: TouchEvent) => handleTouchStart(e), { passive: true })
|
doc.addEventListener('touchstart', (e: TouchEvent) => handleTouchStart(e, doc), { passive: true })
|
||||||
doc.addEventListener('touchmove', (e: TouchEvent) => handleTouchMove(e, doc), { passive: true })
|
doc.addEventListener('touchmove', (e: TouchEvent) => handleTouchMove(e, doc), { passive: true })
|
||||||
doc.addEventListener('touchend', (e: TouchEvent) => handleTouchEnd(e, doc), { passive: true })
|
doc.addEventListener('touchend', (e: TouchEvent) => handleTouchEnd(e, doc), { passive: true })
|
||||||
|
doc.addEventListener('touchcancel', (e: TouchEvent) => handleTouchEnd(e, doc, true), { passive: true })
|
||||||
|
|
||||||
doc.addEventListener('selectionchange', () => handleSelectionChange(doc))
|
doc.addEventListener('selectionchange', () => handleSelectionChange(doc))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,63 +7,100 @@ export interface SelectionDetail {
|
|||||||
|
|
||||||
export function useFoliateSelection(getView: () => unknown) {
|
export function useFoliateSelection(getView: () => unknown) {
|
||||||
let onTextSelected: ((detail: SelectionDetail) => void) | undefined
|
let onTextSelected: ((detail: SelectionDetail) => void) | undefined
|
||||||
|
let onInteractionStart: (() => void) | undefined
|
||||||
let selectionChangeTimeout: ReturnType<typeof setTimeout> | null = null
|
let selectionChangeTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const interactingDocs = new WeakSet<Document>()
|
||||||
|
const changedDocs = new WeakSet<Document>()
|
||||||
|
|
||||||
function setHandler(fn: (detail: SelectionDetail) => void) {
|
function setHandler(fn: (detail: SelectionDetail) => void) {
|
||||||
onTextSelected = fn
|
onTextSelected = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setInteractionStartHandler(fn: () => void) {
|
||||||
|
onInteractionStart = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelectionTimer() {
|
||||||
|
if (selectionChangeTimeout) clearTimeout(selectionChangeTimeout)
|
||||||
|
selectionChangeTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitSelection(doc: Document) {
|
||||||
|
const selection = doc.defaultView?.getSelection()
|
||||||
|
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
const text = range.toString().trim()
|
||||||
|
if (!text) return
|
||||||
|
|
||||||
|
const iframe = doc.defaultView?.frameElement as HTMLIFrameElement | null
|
||||||
|
const rangeRect = range.getBoundingClientRect()
|
||||||
|
let popupX = rangeRect.left + rangeRect.width / 2
|
||||||
|
let selectionTop = rangeRect.top
|
||||||
|
let selectionBottom = rangeRect.bottom
|
||||||
|
|
||||||
|
if (iframe) {
|
||||||
|
const iframeRect = iframe.getBoundingClientRect()
|
||||||
|
popupX = iframeRect.left + rangeRect.left + rangeRect.width / 2
|
||||||
|
selectionTop = iframeRect.top + rangeRect.top
|
||||||
|
selectionBottom = iframeRect.top + rangeRect.bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
const minSpaceAbove = 120
|
||||||
|
const showBelow = selectionTop < minSpaceAbove
|
||||||
|
const popupY = showBelow ? selectionBottom + 10 : selectionTop - 50
|
||||||
|
const clampedX = Math.max(100, Math.min(popupX, window.innerWidth - 150))
|
||||||
|
|
||||||
|
const view = getView() as {
|
||||||
|
renderer?: { getContents?: () => { index: number }[] }
|
||||||
|
getCFI?: (index: number, range: Range) => string | null
|
||||||
|
} | null
|
||||||
|
const contents = view?.renderer?.getContents?.()
|
||||||
|
const content = contents?.[0]
|
||||||
|
const selectionCfi = content ? (view?.getCFI?.(content.index, range) ?? null) : null
|
||||||
|
|
||||||
|
onTextSelected?.({ text, cfi: selectionCfi, range: range.cloneRange(), popupPosition: { x: clampedX, y: popupY, showBelow } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSelection(doc: Document, delay: number) {
|
||||||
|
clearSelectionTimer()
|
||||||
|
selectionChangeTimeout = setTimeout(() => {
|
||||||
|
selectionChangeTimeout = null
|
||||||
|
emitSelection(doc)
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
function handleSelectionEnd(doc: Document) {
|
function handleSelectionEnd(doc: Document) {
|
||||||
setTimeout(() => {
|
scheduleSelection(doc, 10)
|
||||||
const selection = doc.defaultView?.getSelection()
|
}
|
||||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return
|
|
||||||
const range = selection.getRangeAt(0)
|
|
||||||
const text = range.toString().trim()
|
|
||||||
if (!text) return
|
|
||||||
|
|
||||||
const iframe = doc.defaultView?.frameElement as HTMLIFrameElement | null
|
function handleInteractionStart(doc: Document) {
|
||||||
const rangeRect = range.getBoundingClientRect()
|
clearSelectionTimer()
|
||||||
let popupX = rangeRect.left + rangeRect.width / 2
|
interactingDocs.add(doc)
|
||||||
let selectionTop = rangeRect.top
|
const selection = doc.defaultView?.getSelection()
|
||||||
let selectionBottom = rangeRect.bottom
|
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return
|
||||||
|
changedDocs.add(doc)
|
||||||
|
onInteractionStart?.()
|
||||||
|
}
|
||||||
|
|
||||||
if (iframe) {
|
function handleInteractionEnd(doc: Document) {
|
||||||
const iframeRect = iframe.getBoundingClientRect()
|
if (!interactingDocs.has(doc)) return
|
||||||
popupX = iframeRect.left + rangeRect.left + rangeRect.width / 2
|
interactingDocs.delete(doc)
|
||||||
selectionTop = iframeRect.top + rangeRect.top
|
|
||||||
selectionBottom = iframeRect.top + rangeRect.bottom
|
|
||||||
}
|
|
||||||
|
|
||||||
const minSpaceAbove = 120
|
const selection = doc.defaultView?.getSelection()
|
||||||
const showBelow = selectionTop < minSpaceAbove
|
const hasSelection = Boolean(selection && !selection.isCollapsed && selection.rangeCount > 0)
|
||||||
const popupY = showBelow ? selectionBottom + 10 : selectionTop - 50
|
const selectionChanged = changedDocs.has(doc)
|
||||||
const clampedX = Math.max(100, Math.min(popupX, window.innerWidth - 150))
|
changedDocs.delete(doc)
|
||||||
|
if (selectionChanged || hasSelection) scheduleSelection(doc, 50)
|
||||||
const view = getView() as {
|
|
||||||
renderer?: { getContents?: () => { index: number }[] }
|
|
||||||
getCFI?: (index: number, range: Range) => string | null
|
|
||||||
} | null
|
|
||||||
const contents = view?.renderer?.getContents?.()
|
|
||||||
const content = contents?.[0]
|
|
||||||
const selectionCfi = content ? (view?.getCFI?.(content.index, range) ?? null) : null
|
|
||||||
|
|
||||||
onTextSelected?.({ text, cfi: selectionCfi, range: range.cloneRange(), popupPosition: { x: clampedX, y: popupY, showBelow } })
|
|
||||||
}, 10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectionChange(doc: Document) {
|
function handleSelectionChange(doc: Document) {
|
||||||
if (selectionChangeTimeout) clearTimeout(selectionChangeTimeout)
|
clearSelectionTimer()
|
||||||
selectionChangeTimeout = setTimeout(() => {
|
if (interactingDocs.has(doc)) {
|
||||||
const selection = doc.defaultView?.getSelection()
|
changedDocs.add(doc)
|
||||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return
|
return
|
||||||
const range = selection.getRangeAt(0)
|
}
|
||||||
const text = range.toString().trim()
|
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) scheduleSelection(doc, 300)
|
||||||
if (!text) return
|
|
||||||
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
|
|
||||||
handleSelectionEnd(doc)
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { setHandler, handleSelectionEnd, handleSelectionChange }
|
return { setHandler, setInteractionStartHandler, handleSelectionEnd, handleSelectionChange, handleInteractionStart, handleInteractionEnd }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user