Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion components/AnnotationMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
<script setup lang="ts">
import { ANNOTATION_COLORS, ANNOTATION_INDICATOR_COLORS_MAP } from '~/constants/annotations'

const { isIOS } = useAppDetection()

const MENU_PADDING = 8

const props = defineProps<{
Expand All @@ -45,7 +47,8 @@ const menuEl = useTemplateRef<HTMLDivElement>('menuEl')
const { width: menuWidth } = useElementSize(menuEl)
const { width: viewportWidth, height: viewportHeight } = useWindowSize()

const shouldAppearFromBottom = computed(() => props.position.y > viewportHeight.value / 2)
const isInBottomHalfViewport = computed(() => props.position.y > viewportHeight.value / 2)
const shouldAppearFromBottom = computed(() => !isIOS.value || isInBottomHalfViewport.value)

const menuStyle = computed(() => {
const minX = menuWidth.value / 2 + MENU_PADDING * 2
Expand Down
23 changes: 17 additions & 6 deletions composables/use-app-detection.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
const APP_USER_AGENT_PREFIX = '3ook-com-app'
// e.g. "3ook-com-app/1.1.0 (iOS 18.0) Build/42"
const APP_USER_AGENT_REGEX = /^3ook-com-app\/[\d.]+ \((iOS|Android) [\d.]+\)/
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need this to match old version like 3ook-com-app/1.1.0 or 3ook-com-app/1.1.0 (ios 18.0) also?


export function useAppDetection() {
const getRouteQuery = useRouteQuery()

const isAppUserAgent = import.meta.server
? useRequestHeaders(['user-agent'])['user-agent']?.startsWith(APP_USER_AGENT_PREFIX) || false
: navigator.userAgent?.startsWith(APP_USER_AGENT_PREFIX) || false
const userAgent = import.meta.server
? useRequestHeaders(['user-agent'])['user-agent'] || ''
: navigator.userAgent || ''

const appUAMatches = userAgent.match(APP_USER_AGENT_REGEX)
const appOSName = appUAMatches?.[1]

const isApp = computed(() => {
return isAppUserAgent || getRouteQuery('app') === '1'
return !!appUAMatches || getRouteQuery('app') === '1'
})

return { isApp }
const isIOS = computed(() => appOSName === 'iOS' || /iPhone|iPad/.test(userAgent))
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isIOS only checks appOSName === 'iOS' or /iPhone|iPad/, which will miss some iOS/iPadOS user agents (e.g. iPod, and iPadOS in “desktop mode” where UA can look like macOS). If this composable is used to drive iOS-specific behavior, consider expanding the UA checks (e.g. include iPod, and a client-only iPadOS heuristic such as platform === 'MacIntel' && maxTouchPoints > 1).

Suggested change
const isIOS = computed(() => appOSName === 'iOS' || /iPhone|iPad/.test(userAgent))
const isIOS = computed(() => {
if (appOSName === 'iOS') {
return true
}
// Match common iOS devices by user agent
if (/iPhone|iPad|iPod/.test(userAgent)) {
return true
}
// Detect iPadOS in "desktop mode" where UA may look like macOS
if (import.meta.client) {
const nav = window.navigator
if (nav && nav.platform === 'MacIntel' && (nav as any).maxTouchPoints > 1) {
return true
}
}
return false
})

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ignore for now

const isAndroid = computed(() => appOSName === 'Android' || /Android/.test(userAgent))

return {
isApp,
isIOS,
isAndroid,
}
}
65 changes: 64 additions & 1 deletion pages/reader/epub.vue
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,8 @@ const isMobileTocOpen = computed({
},
})

const { isIOS, isAndroid } = useAppDetection()

const isPageLoading = ref(false)

const isAnnotationMenuVisible = ref(false)
Expand Down Expand Up @@ -583,6 +585,7 @@ const FONT_SIZE_OPTIONS = [
]
const DEFAULT_FONT_SIZE_INDEX = 8 // Default to 24px
const COPY_CHAR_LIMIT = 100
const SELECTION_CHANGE_DEBOUNCE_MS = 300
const fontSize = useSyncedBookSettings({
nftClassId: nftClassId.value,
key: 'fontSize',
Expand Down Expand Up @@ -618,6 +621,9 @@ function applyTheme() {
textCSS.color = bodyCSS.color as string
textCSS['background-color'] = 'transparent !important'
}
if (isIOS.value || isAndroid.value) {
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-webkit-touch-callout: none is being applied for all iOS/Android user agents (including mobile browsers), which disables the native selection/copy callout. If the intent is to avoid conflicts only inside the RN app, consider gating this by isApp (e.g., isApp && (isIOS || isAndroid)) so the web reader keeps expected copy/share behavior.

Suggested change
if (isIOS.value || isAndroid.value) {
const isRNWebView =
typeof window !== 'undefined' && 'ReactNativeWebView' in window
if ((isIOS.value || isAndroid.value) && isRNWebView) {

Copilot uses AI. Check for mistakes.
textCSS['-webkit-touch-callout'] = 'none'
}
Comment on lines +624 to +626
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applyTheme() sets -webkit-touch-callout: none for all iOS/Android user agents. Combined with the global contextmenu suppression, this effectively disables the native long-press callout across the whole reader (including mobile web), not only while the annotation menu is shown. If the goal is just to avoid overlapping menus, consider scoping this behavior to app/webview contexts and/or toggling it only when the annotation menu is visible.

Suggested change
if (isIOS.value || isAndroid.value) {
textCSS['-webkit-touch-callout'] = 'none'
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is intended

const anchorCSS: Record<string, string> = {
color: isDarkMode ? '#9ecfff !important' : '#0066cc',
}
Expand Down Expand Up @@ -647,6 +653,7 @@ let removeSelectAllByHotkeyListener: (() => void) | undefined
let removeCopyListener: (() => void) | undefined
let removeMouseUpListener: (() => void) | undefined
let removeSelectionChangeListener: (() => void) | undefined
let removeContextMenuListener: (() => void) | undefined
const renditionElement = useTemplateRef<HTMLDivElement>('reader')
const renditionViewWindow = ref<Window | undefined>(undefined)

Expand Down Expand Up @@ -881,9 +888,18 @@ async function loadEPub() {
}
const debouncedSelectionChange = useDebounceFn(() => {
handleTextSelection(view.window)
}, 300)
}, SELECTION_CHANGE_DEBOUNCE_MS)
removeSelectionChangeListener = useEventListener(view.window.document, 'selectionchange', debouncedSelectionChange)

if (removeContextMenuListener) {
removeContextMenuListener()
}
if (isIOS.value || isAndroid.value) {
removeContextMenuListener = useEventListener(view.window, 'contextmenu', (event: Event) => {
event.preventDefault()
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contextmenu handler currently calls preventDefault() unconditionally on iOS/Android, which can block long-press actions (copy, lookup, open-in-new-tab) even when the annotation menu isn’t involved. Consider preventing the context menu only while text selection/annotation menu is active, or scope this to app-only (isApp) to avoid a web UX regression.

Suggested change
event.preventDefault()
const selection = view.window.getSelection?.()
const selectedText = selection?.toString() || ''
if (selectedText.length > 0) {
event.preventDefault()
}

Copilot uses AI. Check for mistakes.
})
}

renderAnnotations()
})

Expand Down Expand Up @@ -1212,6 +1228,38 @@ function removeAnnotationHighlight(cfi: string) {
renderedHighlights.delete(cfi)
}

let tempHighlightCfi: string | null = null

function removeTempHighlight() {
if (!tempHighlightCfi || !rendition.value) return
try {
rendition.value.annotations.remove(tempHighlightCfi, 'underline')
}
catch {
// Ignore
}
tempHighlightCfi = null
}

function addTempHighlight(cfi: string) {
removeTempHighlight()
if (!rendition.value) return
try {
rendition.value.annotations.underline(cfi, {}, undefined, undefined, {
'stroke': '#50e3c2',
'stroke-width': '3px',
})
tempHighlightCfi = cfi
}
catch {
// Ignore
}
}

watch(isAnnotationMenuVisible, (visible) => {
if (!visible) removeTempHighlight()
})

function renderAnnotations() {
if (!rendition.value) return

Expand Down Expand Up @@ -1241,7 +1289,11 @@ function handleAnnotationClick(annotationId: string) {
}
}

let isClearingSelection = false

function handleTextSelection(viewWindow: Window) {
if (isClearingSelection) return

const selection = viewWindow.getSelection()
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
isAnnotationMenuVisible.value = false
Expand Down Expand Up @@ -1293,6 +1345,16 @@ function handleTextSelection(viewWindow: Window) {
}
}

// On iOS, clear selection to dismiss native callout menu and add temp highlight
if (isIOS.value) {
isClearingSelection = true
selection.removeAllRanges()
addTempHighlight(cfiRange)
setTimeout(() => {
Comment on lines +1348 to +1353
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On iOS, selection.removeAllRanges() clears the selection immediately after it’s made. Since the AnnotationMenu currently doesn’t offer a Copy action, this effectively removes the user’s ability to copy selected text on iOS browsers. If suppressing the native callout is required, consider adding an explicit Copy action to the annotation menu (and/or only clearing selection in app mode) so copy remains possible.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

intended

isClearingSelection = false
}, SELECTION_CHANGE_DEBOUNCE_MS + 100)
}

isAnnotationMenuVisible.value = true
}
catch (error) {
Expand Down Expand Up @@ -1537,6 +1599,7 @@ onBeforeUnmount(() => {
removeCopyListener?.()
removeMouseUpListener?.()
removeSelectionChangeListener?.()
removeContextMenuListener?.()
renderedHighlights.clear()
renditionViewWindow.value = undefined
rendition.value?.destroy()
Expand Down
Loading