diff --git a/komelia-epub-reader/komga-webui/src/components/EpubReader.vue b/komelia-epub-reader/komga-webui/src/components/EpubReader.vue index 404a46bba..88dab2537 100644 --- a/komelia-epub-reader/komga-webui/src/components/EpubReader.vue +++ b/komelia-epub-reader/komga-webui/src/components/EpubReader.vue @@ -313,6 +313,49 @@

+
+
+
+
{{ dictionaryLookup.word }}
+
+ {{ dictionaryLookup.entries[0].phonetic }} +
+
+ +
+ +
Loading...
+
+ {{ dictionaryLookup.error }} +
+
+ +
+
+ = ref(undefined) +type DictionaryDefinition = { + definition?: string + example?: string +} +type DictionaryMeaning = { + partOfSpeech?: string + definitions?: DictionaryDefinition[] +} +type DictionaryEntry = { + word?: string + phonetic?: string + meanings?: DictionaryMeaning[] +} +type DictionaryLookup = { + word: string + x: number + y: number + loading: boolean + error?: string + entries: DictionaryEntry[] +} +type DictionaryFetchWindow = Window & { + originalFetch?: typeof fetch +} +const dictionaryLookup: Ref = ref(undefined) +let dictionaryLookupRequest = 0 +let lastDictionaryTap: {time: number; x: number; y: number; document: Document} | undefined +const dictionaryPopupMargin = 12 +const dictionaryPopupWidth = 340 +const dictionaryPopupMaxHeight = 420 // const forceUpdate = ref(false) const progressionTitle: Ref = ref(undefined) const progressionPage: Ref = ref(undefined) @@ -783,6 +856,7 @@ function keyPressed(e: KeyboardEvent) { function clickThrough(e: MouseEvent) { let x = e.x let y = e.y + const sourceDocument = (e.target as Node | null)?.ownerDocument ?? document if ((e.target as Node)?.ownerDocument != document) { const iframe = e.view?.frameElement if (iframe == null) return @@ -794,16 +868,267 @@ function clickThrough(e: MouseEvent) { y = rect.top + (e.y * scaleComputed) } - if (e.detail === 1) { + if (e.detail <= 1) { + if (isDictionaryDoubleTap(e, x, y, sourceDocument)) { + clearTimeout(clickTimer.value) + lastDictionaryTap = undefined + if (openDictionaryFromSelection(e, x, y) || openDictionaryFromPoint(e, x, y)) { + e.preventDefault() + e.stopPropagation() + } + return + } + + clearTimeout(clickTimer.value) clickTimer.value = setTimeout(() => { - singleClick(x, y) - }, 200) + if (!openDictionaryFromSelection(e, x, y)) { + singleClick(x, y) + } + }, 280) + return } - if (e.detail === 2) { + + if (e.detail >= 2) { clearTimeout(clickTimer.value) + lastDictionaryTap = undefined + if (openDictionaryFromSelection(e, x, y) || openDictionaryFromPoint(e, x, y)) { + e.preventDefault() + e.stopPropagation() + } } } +function isDictionaryDoubleTap(e: MouseEvent, x: number, y: number, sourceDocument: Document): boolean { + const now = e.timeStamp || Date.now() + const previousTap = lastDictionaryTap + lastDictionaryTap = {time: now, x, y, document: sourceDocument} + + if (!previousTap || previousTap.document !== sourceDocument || now - previousTap.time > 500) { + return false + } + + return Math.hypot(x - previousTap.x, y - previousTap.y) < 32 +} + +function openDictionaryFromSelection(e: MouseEvent, fallbackX: number, fallbackY: number): boolean { + const selection = e.view?.getSelection() + const selectedText = selection?.toString().trim() ?? '' + if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !isSingleDictionaryWord(selectedText)) { + return false + } + + const range = selection.getRangeAt(0) + const rangeDocument = range.commonAncestorContainer.ownerDocument + const rangeElement = getContainerElement(range.commonAncestorContainer) + if (rangeDocument && (!rangeElement || !rangeDocument.body?.contains(rangeElement))) { + return false + } + + const rect = getRangeRect(range) + const position = rect + ? translateReaderPoint(e.view, rect.left + rect.width / 2, rect.bottom) + : {x: fallbackX, y: fallbackY} + + openDictionaryLookup(selectedText, position.x, position.y) + return true +} + +function openDictionaryFromPoint(e: MouseEvent, fallbackX: number, fallbackY: number): boolean { + const sourceDocument = (e.target as Node | null)?.ownerDocument ?? document + const target = sourceDocument.elementFromPoint(e.clientX, e.clientY) + if (!canOpenDictionaryForTarget(target)) { + return false + } + + const caretRange = getCaretRangeFromPoint(sourceDocument, e.clientX, e.clientY) + if (!caretRange) { + return false + } + + const textNode = getTextNode(caretRange.startContainer, caretRange.startOffset) + if (!textNode) { + return false + } + + const text = textNode.textContent ?? '' + const wordBounds = getWordBounds(text, caretRange.startOffset) + if (!wordBounds) { + return false + } + + const range = sourceDocument.createRange() + range.setStart(textNode, wordBounds.start) + range.setEnd(textNode, wordBounds.end) + + const selection = sourceDocument.defaultView?.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + + const rect = getRangeRect(range) + const position = rect + ? translateReaderPoint(sourceDocument.defaultView, rect.left + rect.width / 2, rect.bottom) + : {x: fallbackX, y: fallbackY} + + openDictionaryLookup(text.slice(wordBounds.start, wordBounds.end), position.x, position.y) + return true +} + +function canOpenDictionaryForTarget(target: Element | null): boolean { + return !!target && !target.closest('a, button, input, textarea, select, img, svg, image') +} + +function getCaretRangeFromPoint(sourceDocument: Document, x: number, y: number): Range | undefined { + const range = sourceDocument.caretRangeFromPoint?.(x, y) + if (range) { + return range + } + + const caretDocument = sourceDocument as Document & { + caretPositionFromPoint?: (x: number, y: number) => {offsetNode: Node; offset: number} | null + } + const position = caretDocument.caretPositionFromPoint?.(x, y) + if (!position) { + return undefined + } + + const positionRange = sourceDocument.createRange() + positionRange.setStart(position.offsetNode, position.offset) + positionRange.collapse(true) + return positionRange +} + +function getTextNode(node: Node, offset: number): Text | undefined { + if (node.nodeType === Node.TEXT_NODE) { + return node as Text + } + + const childNode = node.childNodes.item(Math.max(0, offset - 1)) || node.childNodes.item(offset) + if (childNode?.nodeType === Node.TEXT_NODE) { + return childNode as Text + } + + return undefined +} + +function getWordBounds(text: string, offset: number): {start: number; end: number} | undefined { + const wordRegex = /[\p{L}\p{M}\p{N}'-]+/gu + const clampedOffset = Math.max(0, Math.min(offset, text.length)) + + for (const match of text.matchAll(wordRegex)) { + const start = match.index || 0 + const end = start + match[0].length + if ( + (clampedOffset >= start && clampedOffset <= end) || + (clampedOffset - 1 >= start && clampedOffset - 1 < end) + ) { + return {start, end} + } + } + + return undefined +} + +function getRangeRect(range: Range): DOMRect | undefined { + const rects = range.getClientRects() + if (rects.length > 0) { + return rects[0] + } + + const rect = range.getBoundingClientRect() + return rect.width || rect.height ? rect : undefined +} + +function getContainerElement(node: Node): HTMLElement | undefined { + return (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement) as HTMLElement | undefined +} + +function translateReaderPoint(view: Window | null | undefined, x: number, y: number): {x: number; y: number} { + const iframe = view?.frameElement as HTMLElement | null | undefined + if (!iframe) { + return {x, y} + } + + const iframeWrapper = iframe.parentElement?.parentElement + const scale = iframeWrapper ? iframeWrapper.getBoundingClientRect().width / iframeWrapper.offsetWidth : 1 + const rect = iframe.getBoundingClientRect() + return { + x: rect.left + (x * scale), + y: rect.top + (y * scale) + } +} + +function isSingleDictionaryWord(value: string): boolean { + const matches = [...value.matchAll(/[\p{L}\p{M}\p{N}'-]+/gu)] + return matches.length === 1 && matches[0][0] === value +} + +async function openDictionaryLookup(rawWord: string, x: number, y: number) { + const word = rawWord.trim() + if (!isSingleDictionaryWord(word)) { + return + } + + const requestId = ++dictionaryLookupRequest + const position = getDictionaryPopupPosition(x, y) + dictionaryLookup.value = { + word, + x: position.x, + y: position.y, + loading: true, + entries: [], + } + + try { + const response = await fetchDictionary(word) + if (!response.ok) { + throw new Error(response.status === 404 ? `No definition found for "${word}".` : 'Dictionary lookup failed.') + } + + const entries = await response.json() as DictionaryEntry[] + if (requestId === dictionaryLookupRequest && dictionaryLookup.value) { + dictionaryLookup.value = {...dictionaryLookup.value, loading: false, entries} + } + } catch (error: any) { + if (requestId === dictionaryLookupRequest && dictionaryLookup.value) { + dictionaryLookup.value = { + ...dictionaryLookup.value, + loading: false, + error: error?.message ?? 'Dictionary lookup failed.', + } + } + } +} + +function getDictionaryPopupPosition(x: number, y: number): {x: number; y: number} { + const popupWidth = Math.min(dictionaryPopupWidth, window.innerWidth - (dictionaryPopupMargin * 2)) + const popupHeight = Math.min(dictionaryPopupMaxHeight, window.innerHeight - (dictionaryPopupMargin * 2)) + const minTop = showToolbars.value ? 56 : dictionaryPopupMargin + const maxLeft = Math.max(dictionaryPopupMargin, window.innerWidth - popupWidth - dictionaryPopupMargin) + const left = Math.max(dictionaryPopupMargin, Math.min(x - (popupWidth / 2), maxLeft)) + const belowTop = y + 8 + const aboveTop = y - popupHeight - 8 + const top = belowTop + popupHeight + dictionaryPopupMargin > window.innerHeight + ? Math.max(minTop, aboveTop) + : Math.max(minTop, belowTop) + + return { + x: left, + y: Math.min(top, Math.max(minTop, window.innerHeight - popupHeight - dictionaryPopupMargin)) + } +} + +function fetchDictionary(word: string): Promise { + const dictionaryWindow = window as DictionaryFetchWindow + const fetchFunction = dictionaryWindow.originalFetch ?? window.fetch + const url = `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word.toLowerCase())}` + + return fetchFunction.call(window, url, {cache: 'no-store'}) +} + +function closeDictionary() { + dictionaryLookup.value = undefined +} + function singleClick(x: number, y: number) { if (verticalScroll.value) { if (settings.navigationClick) { @@ -1108,4 +1433,81 @@ function markProgress(location: Locator) { .hidden { display: none !important; } + +.dictionary-popup { + position: fixed; + z-index: 30; + width: min(340px, calc(100vw - 32px)); + max-height: min(420px, calc(100vh - 72px)); + overflow: auto; + padding: 12px 14px; + border-radius: 6px; + border: 1px solid rgba(127, 127, 127, .35); + box-shadow: 0 8px 24px rgba(0, 0, 0, .25); + font-size: .92rem; + line-height: 1.35; +} + +.dictionary-popup.day { + background-color: #fff; + color: #1f1f1f; +} + +.dictionary-popup.sepia { + background-color: #faf4e8; + color: #2d2820; +} + +.dictionary-popup.night { + background-color: #1f1f1f; + color: #f2f2f2; +} + +.dictionary-popup-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.dictionary-popup-word { + font-weight: 700; + font-size: 1.08rem; +} + +.dictionary-popup-phonetic, +.dictionary-popup-muted, +.dictionary-popup-example { + opacity: .72; +} + +.dictionary-popup-close { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 1.5rem; + line-height: 1; + padding: 0 2px; +} + +.dictionary-popup-part { + font-weight: 700; + margin-top: 8px; +} + +.dictionary-popup ol { + margin: 4px 0 8px 20px; + padding: 0; +} + +.dictionary-popup li { + margin-bottom: 6px; +} + +.dictionary-popup-example { + margin-top: 2px; + font-style: italic; +} diff --git a/komelia-epub-reader/ttu-ebook-reader/src/lib/components/Reader.svelte b/komelia-epub-reader/ttu-ebook-reader/src/lib/components/Reader.svelte index ebcb96df6..6227db4a5 100644 --- a/komelia-epub-reader/ttu-ebook-reader/src/lib/components/Reader.svelte +++ b/komelia-epub-reader/ttu-ebook-reader/src/lib/components/Reader.svelte @@ -68,6 +68,7 @@ } from '$lib/components/book-reader/book-reader-image-gallery/book-reader-image-gallery'; import BookReaderImageGallery from '$lib/components/book-reader/book-reader-image-gallery/book-reader-image-gallery.svelte'; + import DictionaryPopup from '$lib/components/book-reader/dictionary-popup.svelte'; import { getChapterData, nextChapter$, @@ -99,6 +100,11 @@ pulseElement } from '$lib/functions/range-util'; import {externalFunctions} from "$lib/external"; + import { + getDictionaryLookupFromPoint, + getDictionaryLookupFromSelection, + type DictionaryLookup + } from '$lib/functions/dictionary-lookup'; interface Props { onSettingsClick: () => void; @@ -124,6 +130,8 @@ let customReadingPointRange: Range | undefined = $state(); let lastSelectedRange: Range | undefined = $state(); let lastSelectedRangeWasEmpty = $state(true); + let lastDictionaryTap: {time: number; x: number; y: number} | undefined = $state(); + let dictionaryLookup: DictionaryLookup | undefined = $state(); let isSelectingCustomReadingPoint = $state(false); let showCustomReadingPoint = $state(false); let storedExploredCharacter = $state(0); @@ -270,13 +278,15 @@ const textSelector$ = fromEvent(document, 'selectionchange').pipe( debounceTime(200), tap(() => { - const currentSelected = window.getSelection()?.toString() || ''; + const selection = window.getSelection(); + const currentSelected = selection?.toString() || ''; if (!currentSelected && lastSelectedRangeWasEmpty) { lastSelectedRange = undefined; } else if (currentSelected) { - lastSelectedRange = window.getSelection()?.getRangeAt(0); + lastSelectedRange = selection?.getRangeAt(0); lastSelectedRangeWasEmpty = false; + openDictionaryFromSelection(selection); } else { lastSelectedRangeWasEmpty = true; } @@ -343,6 +353,9 @@ onMount(() => { // settings = await SettingsStore.getSettingsStore(); document.addEventListener('ttu-action', handleAction, false) + document.addEventListener('dblclick', handleDictionaryDoubleClick, true); + document.addEventListener('pointerup', handleDictionaryPointerUp, true); + document.addEventListener('touchend', handleDictionaryTouchEnd, true); }); function handleAction({detail}: any) { @@ -358,6 +371,9 @@ onDestroy(() => { document.removeEventListener('ttu-action', handleAction, false); + document.removeEventListener('dblclick', handleDictionaryDoubleClick, true); + document.removeEventListener('pointerup', handleDictionaryPointerUp, true); + document.removeEventListener('touchend', handleDictionaryTouchEnd, true); readerImageGalleryPictures$.next([]); }); @@ -437,6 +453,98 @@ } } + function handleDictionaryDoubleClick(event: MouseEvent) { + if (event.button !== 0) { + return; + } + + openDictionaryAtPoint(event, event.clientX, event.clientY); + } + + function handleDictionaryPointerUp(event: PointerEvent) { + if (event.pointerType === 'mouse' || !event.isPrimary) { + return; + } + + handleDictionaryTap(event, event.clientX, event.clientY); + } + + function handleDictionaryTouchEnd(event: TouchEvent) { + if ('PointerEvent' in window) { + return; + } + + if (event.changedTouches.length !== 1) { + return; + } + + const touch = event.changedTouches[0]; + handleDictionaryTap(event, touch.clientX, touch.clientY); + } + + function handleDictionaryTap(event: Event, x: number, y: number) { + const now = performance.now(); + const maxDelay = 350; + const maxDistance = 28; + const isDoubleTap = lastDictionaryTap && + now - lastDictionaryTap.time <= maxDelay && + Math.hypot(x - lastDictionaryTap.x, y - lastDictionaryTap.y) <= + maxDistance; + + if (isDoubleTap) { + openDictionaryAtPoint(event, x, y); + lastDictionaryTap = undefined; + } else { + lastDictionaryTap = { + time: now, + x, + y + }; + } + } + + function openDictionaryFromSelection(selection: Selection | null | undefined) { + const contentEl = document.querySelector('.book-content'); + if (!contentEl) { + return; + } + + const lookup = getDictionaryLookupFromSelection(document, contentEl, selection); + if (lookup) { + dictionaryLookup = lookup; + } + } + + function openDictionaryAtPoint(event: Event, x: number, y: number) { + const contentEl = document.querySelector('.book-content'); + if (!contentEl || !canOpenDictionary(event.target, contentEl)) { + return; + } + + const lookup = getDictionaryLookupFromPoint(document, contentEl, x, y); + if (!lookup) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + dictionaryLookup = lookup; + + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(lookup.range); + } + + function canOpenDictionary(target: EventTarget | null, contentEl: HTMLElement) { + if (!(target instanceof Element) || !contentEl.contains(target)) { + return false; + } + + return !target.closest( + 'a, button, input, textarea, select, img, svg, image, [data-popover], [data-ttu-spoiler-img]' + ); + } + function onKeydown(ev: KeyboardEvent) { if ( $skipKeyDownListener$ || @@ -831,6 +939,19 @@ bind:showCustomReadingPoint on:bookmark={bookmarkPage} /> + {#if dictionaryLookup} + { + dictionaryLookup = undefined; + window.getSelection()?.removeAllRanges(); + }} + /> + {/if} {$initBookmarkData$ ?? ''} {$setWritingMode$ ?? ''} {$textSelector$ ?? ''} diff --git a/komelia-epub-reader/ttu-ebook-reader/src/lib/components/book-reader/dictionary-popup.svelte b/komelia-epub-reader/ttu-ebook-reader/src/lib/components/book-reader/dictionary-popup.svelte new file mode 100644 index 000000000..fe89f7e15 --- /dev/null +++ b/komelia-epub-reader/ttu-ebook-reader/src/lib/components/book-reader/dictionary-popup.svelte @@ -0,0 +1,157 @@ + + + diff --git a/komelia-epub-reader/ttu-ebook-reader/src/lib/functions/dictionary-lookup.ts b/komelia-epub-reader/ttu-ebook-reader/src/lib/functions/dictionary-lookup.ts new file mode 100644 index 000000000..0abe8014d --- /dev/null +++ b/komelia-epub-reader/ttu-ebook-reader/src/lib/functions/dictionary-lookup.ts @@ -0,0 +1,181 @@ +export interface DictionaryLookup { + word: string; + range: Range; + x: number; + y: number; +} + +interface CaretPositionDocument { + caretPositionFromPoint?: (x: number, y: number) => { + offsetNode: Node; + offset: number; + } | null; +} + +export function getDictionaryLookupFromPoint( + document: Document, + contentEl: HTMLElement, + x: number, + y: number +): DictionaryLookup | undefined { + const target = document.elementFromPoint(x, y); + if (!target || !contentEl.contains(target)) { + return undefined; + } + + const caretRange = getCaretRangeFromPoint(document, x, y); + if (!caretRange) { + return undefined; + } + + const textNode = getTextNode(caretRange.startContainer, caretRange.startOffset); + if (!textNode || !contentEl.contains(textNode.parentElement)) { + return undefined; + } + + const text = textNode.textContent || ''; + const wordBounds = getWordBounds(text, caretRange.startOffset); + if (!wordBounds) { + return undefined; + } + + const range = document.createRange(); + range.setStart(textNode, wordBounds.start); + range.setEnd(textNode, wordBounds.end); + + const rect = range.getBoundingClientRect(); + + return { + word: text.slice(wordBounds.start, wordBounds.end).trim(), + range, + x: rect.left + rect.width / 2 || x, + y: rect.bottom || y + }; +} + +export function getDictionaryLookupFromSelection( + document: Document, + contentEl: HTMLElement, + selection: Selection | null | undefined +): DictionaryLookup | undefined { + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + return undefined; + } + + const selectedText = selection.toString().trim(); + if (!isSingleWord(selectedText)) { + return undefined; + } + + const range = selection.getRangeAt(0); + const container = range.commonAncestorContainer; + const containerElement = container instanceof Element ? container : container.parentElement; + if (!containerElement || !contentEl.contains(containerElement)) { + return undefined; + } + + const rect = getRangeRect(range); + if (!rect) { + return undefined; + } + + return { + word: selectedText, + range: range.cloneRange(), + x: rect.left + rect.width / 2, + y: rect.bottom + }; +} + +function getCaretRangeFromPoint(document: Document, x: number, y: number) { + const caretDocument = document as CaretPositionDocument; + const range = document.caretRangeFromPoint?.(x, y); + if (range) { + return range; + } + + const position = caretDocument.caretPositionFromPoint?.(x, y); + if (!position) { + return undefined; + } + + const positionRange = document.createRange(); + positionRange.setStart(position.offsetNode, position.offset); + positionRange.collapse(true); + + return positionRange; +} + +function getTextNode(node: Node, offset: number): Text | undefined { + if (node instanceof Text) { + return node; + } + + const childNode = node.childNodes.item(Math.max(0, offset - 1)) || node.childNodes.item(offset); + if (childNode instanceof Text) { + return childNode; + } + + return undefined; +} + +function getRangeRect(range: Range) { + const rects = range.getClientRects(); + return rects.length ? rects[0] : range.getBoundingClientRect(); +} + +function isSingleWord(value: string) { + const matches = [...value.matchAll(/[\p{L}\p{M}\p{N}'-]+/gu)]; + return matches.length === 1 && matches[0][0] === value; +} + +function getWordBounds(text: string, offset: number) { + const segmentBounds = getSegmentedWordBounds(text, offset); + if (segmentBounds) { + return segmentBounds; + } + + const wordRegex = /[\p{L}\p{M}\p{N}'-]+/gu; + const clampedOffset = Math.max(0, Math.min(offset, text.length)); + + for (const match of text.matchAll(wordRegex)) { + const start = match.index || 0; + const end = start + match[0].length; + if ( + (clampedOffset >= start && clampedOffset <= end) || + (clampedOffset - 1 >= start && clampedOffset - 1 < end) + ) { + return {start, end}; + } + } + + return undefined; +} + +function getSegmentedWordBounds(text: string, offset: number) { + if (!('Segmenter' in Intl)) { + return undefined; + } + + const segmenter = new Intl.Segmenter(document.documentElement.lang || navigator.language, { + granularity: 'word' + }); + const clampedOffset = Math.max(0, Math.min(offset, text.length)); + + for (const segmentData of segmenter.segment(text)) { + if (!segmentData.isWordLike) { + continue; + } + + const start = segmentData.index; + const end = start + segmentData.segment.length; + if ( + (clampedOffset >= start && clampedOffset <= end) || + (clampedOffset - 1 >= start && clampedOffset - 1 < end) + ) { + return {start, end}; + } + } + + return undefined; +}