Merge pull request #1222 from omnivore-app/feature/android-web-context-menus

WebView Text Selection Menu - Android
This commit is contained in:
Satindar Dhillon 2022-09-26 20:08:19 -07:00 committed by GitHub
commit ea7edc087c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 443 additions and 224 deletions

View file

@ -10,6 +10,7 @@ apple_extension_gen:
droid:
studio android/Omnivore
apple_webview_gen:
webview_gen:
yarn workspace @omnivore/appreader build
cp packages/appreader/build/bundle.js apple/OmnivoreKit/Sources/Views/Resources/bundle.js
cp packages/appreader/build/bundle.js android/Omnivore/app/src/main/assets/bundle.js

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,7 @@
package app.omnivore.omnivore
import android.content.Context
import app.omnivore.omnivore.networking.Networker
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -18,4 +19,7 @@ object AppModule {
@ApplicationContext app: Context
): DatastoreRepository = OmnivoreDatastore(app)
@Singleton
@Provides
fun provideNetworker(datastore: DatastoreRepository) = Networker(datastore)
}

View file

@ -0,0 +1,80 @@
package app.omnivore.omnivore.networking
import app.omnivore.omnivore.graphql.generated.GetArticleQuery
import app.omnivore.omnivore.models.Highlight
import app.omnivore.omnivore.models.LinkedItem
import app.omnivore.omnivore.models.LinkedItemLabel
data class LinkedItemQueryResponse(
val item: LinkedItem?,
val highlights: List<Highlight>,
val labels: List<LinkedItemLabel>
) {
companion object {
fun emptyResponse(): LinkedItemQueryResponse {
return LinkedItemQueryResponse(null, listOf(), listOf())
}
}
}
suspend fun Networker.linkedItem(slug: String): LinkedItemQueryResponse {
val result = authenticatedApolloClient().query(
GetArticleQuery(slug = slug)
).execute()
val article = result.data?.article?.onArticleSuccess?.article
?: return LinkedItemQueryResponse.emptyResponse()
val labels = article.labels ?: listOf()
val linkedItemLabels = labels.map {
LinkedItemLabel(
id = it.labelFields.id,
name = it.labelFields.name,
color = it.labelFields.color,
createdAt = it.labelFields.createdAt,
labelDescription = it.labelFields.description
)
}
val highlights = article.highlights.map {
Highlight(
id = it.highlightFields.id,
shortId = it.highlightFields.shortId,
quote = it.highlightFields.quote,
prefix = it.highlightFields.prefix,
suffix = it.highlightFields.suffix,
patch = it.highlightFields.patch,
annotation = it.highlightFields.annotation,
createdAt = null, // TODO: update gql query to get this
updatedAt = it.highlightFields.updatedAt,
createdByMe = it.highlightFields.createdByMe,
)
}
// TODO: handle errors
val linkedItem = LinkedItem(
id = article.articleFields.id,
title = article.articleFields.title,
createdAt = article.articleFields.createdAt,
savedAt = article.articleFields.savedAt,
readAt = article.articleFields.readAt,
updatedAt = article.articleFields.updatedAt,
readingProgress = article.articleFields.readingProgressPercent,
readingProgressAnchor = article.articleFields.readingProgressAnchorIndex,
imageURLString = article.articleFields.image,
pageURLString = article.articleFields.url,
descriptionText = article.articleFields.description,
publisherURLString = article.articleFields.originalArticleUrl,
siteName = article.articleFields.siteName,
author = article.articleFields.author,
publishDate = article.articleFields.publishedAt,
slug = article.articleFields.slug,
isArchived = article.articleFields.isArchived,
contentReader = article.articleFields.contentReader.rawValue,
content = article.articleFields.content
)
return LinkedItemQueryResponse(item = linkedItem, highlights, labels = linkedItemLabels)
}

View file

@ -0,0 +1,23 @@
package app.omnivore.omnivore.networking
import app.omnivore.omnivore.Constants
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import com.apollographql.apollo3.ApolloClient
import javax.inject.Inject
class Networker @Inject constructor(
private val datastoreRepo: DatastoreRepository
) {
private val serverUrl = "${Constants.apiURL}/api/graphql"
private suspend fun authToken() = datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken) ?: ""
suspend fun publicApolloClient() = ApolloClient.Builder()
.serverUrl(serverUrl)
.build()
suspend fun authenticatedApolloClient() = ApolloClient.Builder()
.serverUrl(serverUrl)
.addHttpHeader("Authorization", value = authToken())
.build()
}

View file

@ -0,0 +1,53 @@
package app.omnivore.omnivore.networking
import app.omnivore.omnivore.graphql.generated.SearchQuery
import app.omnivore.omnivore.models.LinkedItem
import com.apollographql.apollo3.api.Optional
data class SearchQueryResponse(
val cursor: String?,
val items: List<LinkedItem>
)
suspend fun Networker.search(
cursor: String? = null,
limit: Int = 15,
query: String
): SearchQueryResponse {
val result = authenticatedApolloClient().query(
SearchQuery(
after = Optional.presentIfNotNull(cursor),
first = Optional.presentIfNotNull(limit),
query = Optional.presentIfNotNull(query)
)
).execute()
val cursor = result.data?.search?.onSearchSuccess?.pageInfo?.endCursor
val itemList = result.data?.search?.onSearchSuccess?.edges ?: listOf()
val items = itemList.map {
LinkedItem(
id = it.node.id,
title = it.node.title,
createdAt = it.node.createdAt,
savedAt = it.node.savedAt,
readAt = it.node.readAt,
updatedAt = it.node.updatedAt,
readingProgress = it.node.readingProgressPercent,
readingProgressAnchor = it.node.readingProgressAnchorIndex,
imageURLString = it.node.image,
pageURLString = it.node.url,
descriptionText = it.node.description,
publisherURLString = it.node.originalArticleUrl,
siteName = it.node.siteName,
author = it.node.author,
publishDate = it.node.publishedAt,
slug = it.node.slug,
isArchived = it.node.isArchived,
contentReader = it.node.contentReader.rawValue,
content = null
)
}
return SearchQueryResponse(cursor, items)
}

View file

@ -3,21 +3,16 @@ package app.omnivore.omnivore.ui.home
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.Constants
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.graphql.generated.SearchQuery
import app.omnivore.omnivore.models.LinkedItem
import com.apollographql.apollo3.ApolloClient
import com.apollographql.apollo3.api.Optional
import app.omnivore.omnivore.networking.Networker
import app.omnivore.omnivore.networking.search
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository
private val networker: Networker
): ViewModel() {
private var cursor: String? = null
private var items: List<LinkedItem> = listOf()
@ -32,10 +27,6 @@ class HomeViewModel @Inject constructor(
val searchTextLiveData = MutableLiveData<String>("")
val itemsLiveData = MutableLiveData<List<LinkedItem>>(listOf())
private fun getAuthToken(): String? = runBlocking {
datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken)
}
fun updateSearchText(text: String) {
searchTextLiveData.value = text
@ -54,21 +45,9 @@ class HomeViewModel @Inject constructor(
viewModelScope.launch {
val thisSearchIdx = searchIdx
searchIdx += 1
val authToken = getAuthToken()
val apolloClient = ApolloClient.Builder()
.serverUrl("${Constants.apiURL}/api/graphql")
.addHttpHeader("Authorization", value = authToken ?: "")
.build()
val response = apolloClient.query(
SearchQuery(
after = Optional.presentIfNotNull(cursor),
first = Optional.presentIfNotNull(15),
query = Optional.presentIfNotNull(searchQuery())
)
).execute()
// Execute the search
val searchResult = networker.search(cursor = cursor, query = searchQuery())
// Search results aren't guaranteed to return in order so this
// will discard old results that are returned while a user is typing.
@ -79,40 +58,15 @@ class HomeViewModel @Inject constructor(
return@launch
}
cursor = response.data?.search?.onSearchSuccess?.pageInfo?.endCursor
receivedIdx = thisSearchIdx
val itemList = response.data?.search?.onSearchSuccess?.edges ?: listOf()
val newItems = itemList.map {
LinkedItem(
id = it.node.id,
title = it.node.title,
createdAt = it.node.createdAt,
savedAt = it.node.savedAt,
readAt = it.node.readAt,
updatedAt = it.node.updatedAt,
readingProgress = it.node.readingProgressPercent,
readingProgressAnchor = it.node.readingProgressAnchorIndex,
imageURLString = it.node.image,
pageURLString = it.node.url,
descriptionText = it.node.description,
publisherURLString = it.node.originalArticleUrl,
siteName = it.node.siteName,
author = it.node.author,
publishDate = it.node.publishedAt,
slug = it.node.slug,
isArchived = it.node.isArchived,
contentReader = it.node.contentReader.rawValue,
content = null
)
}
cursor = searchResult.cursor
if (searchTextLiveData.value != "") {
val previousItems = if (clearPreviousSearch) listOf() else searchedItems
searchedItems = previousItems.plus(newItems)
searchedItems = previousItems.plus(searchResult.items)
itemsLiveData.value = searchedItems
} else {
items = items.plus(newItems)
items = items.plus(searchResult.items)
itemsLiveData.value = items
}
}
@ -128,4 +82,3 @@ class HomeViewModel @Inject constructor(
return query
}
}

View file

@ -1,7 +1,11 @@
package app.omnivore.omnivore.ui.reader
import android.annotation.SuppressLint
import android.view.ViewGroup
import android.content.Context
import android.graphics.Rect
import android.util.Log
import android.view.*
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.material3.Text
@ -9,6 +13,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.viewinterop.AndroidView
import app.omnivore.omnivore.R
import org.json.JSONObject
@Composable
fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewModel) {
@ -19,7 +26,7 @@ fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewMod
}
if (webReaderParams != null) {
WebReader(webReaderParams!!)
WebReader(webReaderParams!!, webReaderViewModel)
} else {
// TODO: add a proper loading view
Text("Loading...")
@ -28,7 +35,7 @@ fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewMod
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun WebReader(params: WebReaderParams) {
fun WebReader(params: WebReaderParams, webReaderViewModel: WebReaderViewModel) {
WebView.setWebContentsDebuggingEnabled(true)
val webReaderContent = WebReaderContent(
@ -45,7 +52,7 @@ fun WebReader(params: WebReaderParams) {
val styledContent = webReaderContent.styledContent()
AndroidView(factory = {
WebView(it).apply {
OmnivoreWebView(it).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
@ -59,6 +66,11 @@ fun WebReader(params: WebReaderParams) {
webViewClient = object : WebViewClient() {
}
val javascriptInterface = AndroidWebKitMessenger { actionID, json ->
webReaderViewModel.handleIncomingWebMessage(actionID, json)
}
addJavascriptInterface(javascriptInterface, "AndroidWebKitMessenger")
loadDataWithBaseURL("file:///android_asset/", styledContent, "text/html; charset=utf-8", "utf-8", null);
}
@ -66,3 +78,74 @@ fun WebReader(params: WebReaderParams) {
it.loadDataWithBaseURL("file:///android_asset/", styledContent, "text/html; charset=utf-8", "utf-8", null);
})
}
class OmnivoreWebView(context: Context) : WebView(context) {
private val actionModeCallback = object : ActionMode.Callback2() {
// Called when the action mode is created; startActionMode() was called
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.text_selection_menu, menu)
return true
}
// Called each time the action mode is shown. Always called after onCreateActionMode, but
// may be called multiple times if the mode is invalidated.
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
return false // Return false if nothing is done
}
// Called when the user selects a contextual menu item
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.annotate -> {
Log.d("Loggo", "Annotate action selected")
mode.finish()
true
}
R.id.highlight -> {
val script = "var event = new Event('highlight');document.dispatchEvent(event);"
evaluateJavascript(script, null)
clearFocus()
mode.finish()
true
}
else -> {
Log.d("Loggo", "${item.itemId} selected")
false
}
}
}
// Called when the user exits the action mode
override fun onDestroyActionMode(mode: ActionMode) {
// actionMode = null
}
override fun onGetContentRect(mode: ActionMode?, view: View?, outRect: Rect?) {
outRect?.set(left, top, right, bottom)
}
}
private var currentActionModeCallback: ActionMode.Callback? = actionModeCallback
override fun startActionMode(callback: ActionMode.Callback?): ActionMode {
return super.startActionMode(currentActionModeCallback)
}
override fun startActionModeForChild(
originalView: View?,
callback: ActionMode.Callback?
): ActionMode {
return super.startActionModeForChild(originalView, currentActionModeCallback)
}
override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode {
return super.startActionMode(currentActionModeCallback, type)
}
}
class AndroidWebKitMessenger(val messageHandler: (String, JSONObject) -> Unit) {
@JavascriptInterface
fun handleIdentifiableMessage(actionID: String, jsonString: String) {
messageHandler(actionID, JSONObject(jsonString))
}
}

View file

@ -104,8 +104,6 @@ data class WebReaderContent(
</html>
"""
Log.d("Loggo", content)
return content
}
}

View file

@ -1,20 +1,17 @@
package app.omnivore.omnivore.ui.reader
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.Constants
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.graphql.generated.GetArticleQuery
import app.omnivore.omnivore.models.Highlight
import app.omnivore.omnivore.models.LinkedItem
import app.omnivore.omnivore.models.LinkedItemLabel
import com.apollographql.apollo3.ApolloClient
import app.omnivore.omnivore.networking.Networker
import app.omnivore.omnivore.networking.linkedItem
import com.google.gson.Gson
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.json.JSONObject
import javax.inject.Inject
data class WebReaderParams(
@ -24,90 +21,57 @@ data class WebReaderParams(
@HiltViewModel
class WebReaderViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository
private val datastoreRepo: DatastoreRepository,
private val networker: Networker
): ViewModel() {
val webReaderParamsLiveData = MutableLiveData<WebReaderParams?>(null)
private fun getAuthToken(): String? = runBlocking {
datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken)
}
fun loadItem(slug: String) {
viewModelScope.launch {
val authToken = getAuthToken()
val articleQueryResult = networker.linkedItem(slug)
val apolloClient = ApolloClient.Builder()
.serverUrl("${Constants.apiURL}/api/graphql")
.addHttpHeader("Authorization", value = authToken ?: "")
.build()
val response = apolloClient.query(
GetArticleQuery(slug = slug)
).execute()
val article = response.data?.article?.onArticleSuccess?.article ?: return@launch
val labels = article.labels ?: listOf()
val linkedItemLabels = labels.map {
LinkedItemLabel(
id = it.labelFields.id,
name = it.labelFields.name,
color = it.labelFields.color,
createdAt = it.labelFields.createdAt,
labelDescription = it.labelFields.description
)
}
val highlights = article.highlights.map {
Highlight(
id = it.highlightFields.id,
shortId = it.highlightFields.shortId,
quote = it.highlightFields.quote,
prefix = it.highlightFields.prefix,
suffix = it.highlightFields.suffix,
patch = it.highlightFields.patch,
annotation = it.highlightFields.annotation,
createdAt = null,
updatedAt = it.highlightFields.updatedAt,
createdByMe = it.highlightFields.createdByMe,
)
}
// TODO: handle errors
val linkedItem = LinkedItem(
id = article.articleFields.id,
title = article.articleFields.title,
createdAt = article.articleFields.createdAt,
savedAt = article.articleFields.savedAt,
readAt = article.articleFields.readAt,
updatedAt = article.articleFields.updatedAt,
readingProgress = article.articleFields.readingProgressPercent,
readingProgressAnchor = article.articleFields.readingProgressAnchorIndex,
imageURLString = article.articleFields.image,
pageURLString = article.articleFields.url,
descriptionText = article.articleFields.description,
publisherURLString = article.articleFields.originalArticleUrl,
siteName = article.articleFields.siteName,
author = article.articleFields.author,
publishDate = article.articleFields.publishedAt,
slug = article.articleFields.slug,
isArchived = article.articleFields.isArchived,
contentReader = article.articleFields.contentReader.rawValue,
content = article.articleFields.content
)
val article = articleQueryResult.item ?: return@launch
val articleContent = ArticleContent(
title = article.articleFields.title,
htmlContent = article.articleFields.content ?: "",
highlightsJSONString = Gson().toJson(highlights),
title = article.title,
htmlContent = article.content ?: "",
highlightsJSONString = Gson().toJson(articleQueryResult.highlights),
contentStatus = "SUCCEEDED",
objectID = "",
labelsJSONString = Gson().toJson(linkedItemLabels)
labelsJSONString = Gson().toJson(articleQueryResult.labels)
)
webReaderParamsLiveData.value = WebReaderParams(linkedItem, articleContent)
webReaderParamsLiveData.value = WebReaderParams(article, articleContent)
}
}
fun handleIncomingWebMessage(actionID: String, json: JSONObject) {
when (actionID) {
"createHighlight" -> {
Log.d("Loggo", "receive create highlight action: $json")
}
"deleteHighlight" -> {
// { highlightId }
Log.d("Loggo", "receive delete highlight action: $json")
}
"updateHighlight" -> {
Log.d("Loggo", "receive update highlight action: $json")
}
"articleReadingProgress" -> {
Log.d("Loggo", "received article reading progress action: $json")
}
"annotate" -> {
Log.d("Loggo", "received annotate action: $json")
}
"existingHighlightTap" -> {
Log.d("Loggo", "receive existing highlight tap action: $json")
}
"shareHighlight" -> {
// unimplemented
}
else -> {
Log.d("Loggo", "receive unrecognized action of $actionID with json: $json")
}
}
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/highlight"
android:title="@string/highlight_menu_action"
app:showAsAction="always">
</item>
<item
android:id="@+id/annotate"
android:title="@string/annotate_menu_action"
app:showAsAction="always">
</item>
</menu>

View file

@ -1,8 +1,8 @@
<resources>
<string name="app_name">Omnivore</string>
<string name="gcp_id">590528454690-mpmbvcb4c3ifmnojmrultt37eo8f64at.apps.googleusercontent.com</string>
<!-- <string name="web_client_id">590528454690-g535h6sd708andjrvrmqgaj1sergtqi9.apps.googleusercontent.com</string>-->
<string name="welcome_title">Never miss a great read</string>
<string name="learn_more">Learn More</string>
<string name="welcome_subtitle">Save articles and read them later in our distraction-free reader.</string>
<string name="highlight_menu_action">Highlight</string>
<string name="annotate_menu_action">Annotate</string>
</resources>

File diff suppressed because one or more lines are too long

View file

@ -7,13 +7,23 @@ import '@omnivore/web/styles/globals.css'
import '@omnivore/web/styles/articleInnerStyling.css'
const mutation = async (name, input) => {
const result =
await window?.webkit?.messageHandlers.articleAction?.postMessage({
actionID: name,
...input,
})
console.log('action result', result, result.result)
return result.result
if (window.webkit) {
// Send iOS a message
const result =
await window?.webkit?.messageHandlers.articleAction?.postMessage({
actionID: name,
...input,
})
console.log('action result', result, result.result)
return result.result
} else {
// Send android a message
console.log('sending android a message', name, input)
AndroidWebKitMessenger.handleIdentifiableMessage(
name,
JSON.stringify(input)
)
}
}
const App = () => {

View file

@ -1,5 +1,10 @@
export {}
declare type AndroidWebKitMessenger = {
// 1st argument is an actionID value, 2nd is jsonString
handleIdentifiableMessage: (string, string) => void
}
declare global {
interface Window {
webkit?: Webkit
@ -9,6 +14,7 @@ declare global {
Intercom: Function
intercomSettings: IntercomSettings
analytics?: Analytics
AndroidWebKitMessenger?: AndroidWebKitMessenger
}
}

View file

@ -79,6 +79,7 @@ export function Article(props: ArticleProps): JSX.Element {
}, [props.articleId, readingProgress])
// Post message to webkit so apple app embeds get progress updates
// TODO: verify if ios still needs this code...seeems to be duplicated
useEffect(() => {
if (typeof window?.webkit != 'undefined') {
window.webkit.messageHandlers.readingProgressUpdate?.postMessage({
@ -87,18 +88,15 @@ export function Article(props: ArticleProps): JSX.Element {
}
}, [readingProgress])
useScrollWatcher(
(changeset: ScrollOffsetChangeset) => {
if (window && window.document.scrollingElement) {
const newReadingProgress =
window.scrollY / window.document.scrollingElement.scrollHeight
const adjustedReadingProgress =
newReadingProgress > 0.92 ? 1 : newReadingProgress
debouncedSetReadingProgress(adjustedReadingProgress * 100)
}
},
1000
)
useScrollWatcher((changeset: ScrollOffsetChangeset) => {
if (window && window.document.scrollingElement) {
const newReadingProgress =
window.scrollY / window.document.scrollingElement.scrollHeight
const adjustedReadingProgress =
newReadingProgress > 0.92 ? 1 : newReadingProgress
debouncedSetReadingProgress(adjustedReadingProgress * 100)
}
}, 1000)
const layoutImages = useCallback(
(image: HTMLImageElement, container: HTMLDivElement | null) => {
@ -127,46 +125,46 @@ export function Article(props: ArticleProps): JSX.Element {
if (typeof window === 'undefined') {
return
}
if (!shouldScrollToInitialPosition) {
return
}
if (!shouldScrollToInitialPosition) {
return
}
setShouldScrollToInitialPosition(false)
setShouldScrollToInitialPosition(false)
// If we are scrolling to a highlight, dont scroll to read position
if (props.highlightHref.current) {
return
}
// If we are scrolling to a highlight, dont scroll to read position
if (props.highlightHref.current) {
return
}
if (props.initialReadingProgress && props.initialReadingProgress >= 98) {
return
}
if (props.initialReadingProgress && props.initialReadingProgress >= 98) {
return
}
const anchorElement = props.highlightHref.current
? document.querySelector(
`[omnivore-highlight-id="${props.highlightHref.current}"]`
)
: document.querySelector(
`[data-omnivore-anchor-idx='${props.initialAnchorIndex.toString()}']`
)
const anchorElement = props.highlightHref.current
? document.querySelector(
`[omnivore-highlight-id="${props.highlightHref.current}"]`
)
: document.querySelector(
`[data-omnivore-anchor-idx='${props.initialAnchorIndex.toString()}']`
)
if (anchorElement) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const calculateOffset = (obj: any): number => {
let offset = 0
if (obj.offsetParent) {
do {
offset += obj.offsetTop
} while ((obj = obj.offsetParent))
return offset
}
return 0
if (anchorElement) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const calculateOffset = (obj: any): number => {
let offset = 0
if (obj.offsetParent) {
do {
offset += obj.offsetTop
} while ((obj = obj.offsetParent))
return offset
}
const calculatedOffset = calculateOffset(anchorElement)
window.document.documentElement.scroll(0, calculatedOffset - 100)
return 0
}
const calculatedOffset = calculateOffset(anchorElement)
window.document.documentElement.scroll(0, calculatedOffset - 100)
}
}, [
props.initialAnchorIndex,
props.initialReadingProgress,

View file

@ -1,4 +1,10 @@
import { useEffect, useRef, useCallback, useState, MutableRefObject } from 'react'
import {
useEffect,
useRef,
useCallback,
useState,
MutableRefObject,
} from 'react'
import { makeHighlightStartEndOffset } from '../../../lib/highlights/highlightGenerator'
import type { HighlightLocation } from '../../../lib/highlights/highlightGenerator'
import { useSelection } from '../../../lib/highlights/useSelection'
@ -30,7 +36,6 @@ type HighlightsLayerProps = {
scrollToHighlight: MutableRefObject<string | null>
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
articleMutations: ArticleMutations
}
type HighlightModalAction = 'none' | 'addComment' | 'share'
@ -88,7 +93,9 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
// that now that all the content has been injected into the
// page.
if (props.scrollToHighlight.current) {
const anchorElement = document.querySelector(`[omnivore-highlight-id="${props.scrollToHighlight.current}"]`)
const anchorElement = document.querySelector(
`[omnivore-highlight-id="${props.scrollToHighlight.current}"]`
)
if (anchorElement) {
anchorElement.scrollIntoView({ behavior: 'auto' })
}
@ -100,7 +107,8 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
const highlightId = id || focusedHighlight?.id
if (!highlightId) return
const didDeleteHighlight = await props.articleMutations.deleteHighlightMutation(highlightId)
const didDeleteHighlight =
await props.articleMutations.deleteHighlightMutation(highlightId)
if (didDeleteHighlight) {
removeHighlights(
@ -154,6 +162,11 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
actionID: 'annotate',
annotation: inputs.highlight?.annotation ?? '',
})
} else if (typeof window?.AndroidWebKitMessenger != 'undefined') {
window.AndroidWebKitMessenger.handleIdentifiableMessage(
'annotate',
JSON.stringify({ annotation: inputs.highlight?.annotation ?? '' })
)
} else {
inputs.createHighlightForNote = async (note?: string) => {
if (!inputs.selectionData) {
@ -171,13 +184,16 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
selection: SelectionAttributes,
note?: string
): Promise<Highlight | undefined> => {
const result = await createHighlight({
selection: selection,
articleId: props.articleId,
existingHighlights: highlights,
highlightStartEndOffsets: highlightLocations,
annotation: note,
}, props.articleMutations)
const result = await createHighlight(
{
selection: selection,
articleId: props.articleId,
existingHighlights: highlights,
highlightStartEndOffsets: highlightLocations,
annotation: note,
},
props.articleMutations
)
if (!result.highlights || result.highlights.length == 0) {
// TODO: show an error message
@ -235,16 +251,18 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
)
const scrollToHighlight = (id: string) => {
const foundElement = document.querySelector(`[omnivore-highlight-id="${id}"]`)
if(foundElement){
foundElement.scrollIntoView({
block: 'center',
behavior: 'smooth'
})
window.location.hash = `#${id}`
props.setShowHighlightsModal(false)
}
}
const foundElement = document.querySelector(
`[omnivore-highlight-id="${id}"]`
)
if (foundElement) {
foundElement.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
window.location.hash = `#${id}`
props.setShowHighlightsModal(false)
}
}
// Detect mouseclick on a highlight -- call `setFocusedHighlight` when highlight detected
const handleClickHighlight = useCallback(
@ -269,13 +287,20 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
// In the native app we post a message with the rect of the
// highlight, so the app can display a native menu
const rect = (target as Element).getBoundingClientRect()
window?.webkit?.messageHandlers.viewerAction?.postMessage({
actionID: 'showMenu',
const message = {
rectX: rect.x,
rectY: rect.y,
rectWidth: rect.width,
rectHeight: rect.height,
}
window?.webkit?.messageHandlers.viewerAction?.postMessage({
actionID: 'showMenu',
...message,
})
window?.AndroidWebKitMessenger?.handleIdentifiableMessage(
'existingHighlightTap',
JSON.stringify(message)
)
setFocusedHighlight(highlight)
}
} else if ((target as Element).hasAttribute(highlightNoteIdAttribute)) {
@ -326,13 +351,19 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
break
case 'share':
if (props.isAppleAppEmbed) {
// send action to native app (naive app doesn't handle this yet so it's a no-op)
window?.webkit?.messageHandlers.highlightAction?.postMessage({
actionID: 'share',
highlightID: focusedHighlight?.id,
})
}
window?.AndroidWebKitMessenger?.handleIdentifiableMessage(
'shareHighlight',
JSON.stringify({
highlightID: focusedHighlight?.id,
})
)
if (focusedHighlight) {
if (canShareNative) {
handleNativeShare(focusedHighlight.shortId)
@ -392,7 +423,9 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
}
const speakingSection = async (event: SpeakingSectionEvent) => {
const item = document.querySelector(`[data-omnivore-anchor-idx="${event.anchorIdx}"]`)
const item = document.querySelector(
`[data-omnivore-anchor-idx="${event.anchorIdx}"]`
)
const otherItems = document.querySelectorAll('.speakingSection')
otherItems.forEach((other) => {
if (other != item) {
@ -435,7 +468,6 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
document.addEventListener('saveAnnotation', saveAnnotation)
document.addEventListener('speakingSection', speakingSection)
return () => {
document.removeEventListener('annotate', annotate)
document.removeEventListener('highlight', highlight)
@ -445,7 +477,6 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
document.removeEventListener('dismissHighlight', dismissHighlight)
document.removeEventListener('saveAnnotation', saveAnnotation)
document.removeEventListener('speakingSection', speakingSection)
}
})
@ -523,4 +554,4 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
}
return <></>
}
}