Files
omnivore/packages/web/components/templates/article/HighlightsLayer.tsx
Jackson Harper da6103b444 Fix issues with merging highlights
highlightLocations can't be a prop or they wont be updated
properly when highlights are created and merged.

This fixes issues with merging highlights. It changes how we
scroll to an initial highlight in the case of deep links too.
2022-06-23 16:09:28 -07:00

506 lines
15 KiB
TypeScript

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'
import type { Highlight } from '../../../lib/networking/fragments/highlightFragment'
import {
highlightIdAttribute,
highlightNoteIdAttribute,
SelectionAttributes,
} from '../../../lib/highlights/highlightHelpers'
import { HighlightBar, HighlightAction } from '../../patterns/HighlightBar'
import { removeHighlights } from '../../../lib/highlights/deleteHighlight'
import { createHighlight } from '../../../lib/highlights/createHighlight'
import { HighlightNoteModal } from './HighlightNoteModal'
import { ShareHighlightModal } from './ShareHighlightModal'
import { HighlightsModal } from './HighlightsModal'
import { useCanShareNative } from '../../../lib/hooks/useCanShareNative'
import { showErrorToast } from '../../../lib/toastHelpers'
import { ArticleMutations } from '../../../lib/articleActions'
type HighlightsLayerProps = {
highlights: Highlight[]
articleId: string
articleTitle: string
articleAuthor: string
isAppleAppEmbed: boolean
highlightBarDisabled: boolean
showHighlightsModal: boolean
highlightsBaseURL: string
scrollToHighlight: MutableRefObject<string | null>
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
articleMutations: ArticleMutations
}
type HighlightModalAction = 'none' | 'addComment' | 'share'
type HighlightActionProps = {
highlight?: Highlight
selectionData?: SelectionAttributes
highlightModalAction: HighlightModalAction
createHighlightForNote?: (note?: string) => Promise<Highlight | undefined>
}
interface AnnotationEvent extends Event {
annotation?: string
}
export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
const [highlights, setHighlights] = useState(props.highlights)
const [highlightModalAction, setHighlightModalAction] =
useState<HighlightActionProps>({ highlightModalAction: 'none' })
const [highlightLocations, setHighlightLocations] = useState<
HighlightLocation[]
>([])
const focusedHighlightMousePos = useRef({ pageX: 0, pageY: 0 })
const [focusedHighlight, setFocusedHighlight] = useState<
Highlight | undefined
>(undefined)
const [selectionData, setSelectionData] = useSelection(
highlightLocations,
false //noteModal.open,
)
const canShareNative = useCanShareNative()
// Load the highlights
useEffect(() => {
const res: HighlightLocation[] = []
highlights.forEach((highlight) => {
try {
const offset = makeHighlightStartEndOffset(highlight)
res.push(offset)
} catch (err) {
console.error(err)
}
})
setHighlightLocations(res)
// If we were given an initial highlight to scroll to we do
// 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}"]`)
if (anchorElement) {
anchorElement.scrollIntoView({ behavior: 'auto' })
}
}
}, [highlights, setHighlightLocations])
const removeHighlightCallback = useCallback(
async (id?: string) => {
const highlightId = id || focusedHighlight?.id
if (!highlightId) return
const didDeleteHighlight = await props.articleMutations.deleteHighlightMutation(highlightId)
if (didDeleteHighlight) {
removeHighlights(
highlights.map(($0) => $0.id),
highlightLocations
)
setHighlights(highlights.filter(($0) => $0.id !== highlightId))
setFocusedHighlight(undefined)
} else {
console.error('Failed to delete highlight')
}
},
[focusedHighlight, highlights, highlightLocations]
)
const updateHighlightsCallback = useCallback(
(highlight: Highlight) => {
removeHighlights([highlight.id], highlightLocations)
const keptHighlights = highlights.filter(($0) => $0.id !== highlight.id)
setHighlights([...keptHighlights, highlight])
},
[highlights, highlightLocations]
)
const handleNativeShare = useCallback(
(highlightID: string) => {
navigator
?.share({
title: props.articleTitle,
url: `${props.highlightsBaseURL}/${highlightID}`,
})
.then(() => {
setFocusedHighlight(undefined)
})
.catch((error) => {
console.log(error)
setFocusedHighlight(undefined)
})
},
[props.articleTitle, props.highlightsBaseURL]
)
const openNoteModal = useCallback(
(inputs: HighlightActionProps) => {
// First try to send a signal to the ios app
if (
typeof window?.webkit?.messageHandlers.highlightAction != 'undefined' &&
props.highlightBarDisabled
) {
window?.webkit?.messageHandlers.highlightAction?.postMessage({
actionID: 'annotate',
annotation: inputs.highlight?.annotation ?? '',
})
} else {
inputs.createHighlightForNote = async (note?: string) => {
if (!inputs.selectionData) {
return undefined
}
return await createHighlightFromSelection(inputs.selectionData, note)
}
setHighlightModalAction(inputs)
}
},
[props.highlightBarDisabled]
)
const createHighlightFromSelection = async (
selection: SelectionAttributes,
note?: string
): Promise<Highlight | undefined> => {
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
console.error('Failed to create highlight')
return undefined
}
setSelectionData(null)
setHighlights(result.highlights)
if (result.newHighlightIndex === undefined) {
setHighlightModalAction({ highlightModalAction: 'none' })
return undefined
}
return result.highlights[result.newHighlightIndex]
}
const createHighlightCallback = useCallback(
async (successAction: HighlightModalAction, annotation?: string) => {
if (!selectionData) {
return
}
const result = await createHighlightFromSelection(
selectionData,
annotation
)
if (!result) {
showErrorToast('Error saving highlight', { position: 'bottom-right' })
}
// if (successAction === 'share' && canShareNative) {
// handleNativeShare(highlight.shortId)
// return
// } else {
// setFocusedHighlight(undefined)
// }
// if (successAction === 'addComment') {
// openNoteModal({
// highlightModalAction: 'addComment',
// highlight,
// })
// }
},
[
handleNativeShare,
highlights,
openNoteModal,
props.articleId,
selectionData,
setSelectionData,
canShareNative,
highlightLocations,
]
)
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)
}
}
// Detect mouseclick on a highlight -- call `setFocusedHighlight` when highlight detected
const handleClickHighlight = useCallback(
(event: MouseEvent) => {
const { target, pageX, pageY } = event
if (!target || (target as Node)?.nodeType !== Node.ELEMENT_NODE) {
return
}
focusedHighlightMousePos.current = { pageX, pageY }
if ((target as Element).hasAttribute(highlightIdAttribute)) {
const id = (target as HTMLSpanElement).getAttribute(
highlightIdAttribute
)
const highlight = highlights.find(($0) => $0.id === id)
// FIXME: Apply note preview opening on the note icon click only
if (highlight) {
// 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',
rectX: rect.x,
rectY: rect.y,
rectWidth: rect.width,
rectHeight: rect.height,
})
setFocusedHighlight(highlight)
}
} else if ((target as Element).hasAttribute(highlightNoteIdAttribute)) {
const id = (target as HTMLSpanElement).getAttribute(
highlightNoteIdAttribute
)
const highlight = highlights.find(($0) => $0.id === id)
openNoteModal({
highlight: highlight,
highlightModalAction: 'addComment',
})
} else setFocusedHighlight(undefined)
},
[highlights, highlightLocations]
)
useEffect(() => {
if (typeof window === 'undefined') {
return
}
document.addEventListener('click', handleClickHighlight)
return () => document.removeEventListener('click', handleClickHighlight)
}, [handleClickHighlight])
const handleAction = useCallback(
(action: HighlightAction) => {
switch (action) {
case 'delete':
removeHighlightCallback()
break
case 'create':
createHighlightCallback('none')
break
case 'comment':
if (props.highlightBarDisabled || focusedHighlight) {
openNoteModal({
highlight: focusedHighlight,
highlightModalAction: 'addComment',
})
} else {
openNoteModal({
highlight: undefined,
selectionData: selectionData || undefined,
highlightModalAction: 'addComment',
})
}
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,
})
}
if (focusedHighlight) {
if (canShareNative) {
handleNativeShare(focusedHighlight.shortId)
} else {
setHighlightModalAction({
highlight: focusedHighlight,
highlightModalAction: 'share',
})
}
} else {
createHighlightCallback('share')
}
break
case 'unshare':
console.log('unshare')
break // TODO: implement -- need to show confirmation dialog
}
},
[
createHighlightCallback,
focusedHighlight,
handleNativeShare,
openNoteModal,
props.highlightBarDisabled,
props.isAppleAppEmbed,
removeHighlightCallback,
canShareNative,
]
)
useEffect(() => {
const annotate = () => {
handleAction('comment')
}
const highlight = () => {
handleAction('create')
}
const share = () => {
handleAction('share')
}
const remove = () => {
handleAction('delete')
}
const dismissHighlight = () => {
setFocusedHighlight(undefined)
}
const copy = async () => {
if (focusedHighlight) {
await navigator.clipboard.writeText(focusedHighlight.quote)
setFocusedHighlight(undefined)
}
}
const saveAnnotation = async (event: AnnotationEvent) => {
if (focusedHighlight) {
const annotation = event.annotation ?? ''
const result = await props.articleMutations.updateHighlightMutation({
highlightId: focusedHighlight.id,
annotation: event.annotation ?? '',
})
if (result) {
updateHighlightsCallback({ ...focusedHighlight, annotation })
} else {
console.log(
'failed to change annotation for highlight with id',
focusedHighlight.id
)
}
setFocusedHighlight(undefined)
} else {
createHighlightCallback('none', event.annotation)
}
}
document.addEventListener('annotate', annotate)
document.addEventListener('highlight', highlight)
document.addEventListener('share', share)
document.addEventListener('remove', remove)
document.addEventListener('copyHighlight', copy)
document.addEventListener('dismissHighlight', dismissHighlight)
document.addEventListener('saveAnnotation', saveAnnotation)
return () => {
document.removeEventListener('annotate', annotate)
document.removeEventListener('highlight', highlight)
document.removeEventListener('share', share)
document.removeEventListener('remove', remove)
document.removeEventListener('copyHighlight', copy)
document.removeEventListener('dismissHighlight', dismissHighlight)
document.removeEventListener('saveAnnotation', saveAnnotation)
}
})
if (highlightModalAction?.highlightModalAction == 'addComment') {
return (
<HighlightNoteModal
highlight={highlightModalAction.highlight}
author={props.articleAuthor}
title={props.articleTitle}
onUpdate={updateHighlightsCallback}
onOpenChange={() =>
setHighlightModalAction({ highlightModalAction: 'none' })
}
createHighlightForNote={highlightModalAction?.createHighlightForNote}
/>
)
}
if (
highlightModalAction?.highlightModalAction == 'share' &&
highlightModalAction.highlight
) {
return (
<ShareHighlightModal
url={`${props.highlightsBaseURL}/${highlightModalAction.highlight.shortId}`}
title={props.articleTitle}
author={props.articleAuthor}
highlight={highlightModalAction.highlight}
onOpenChange={() => {
setHighlightModalAction({ highlightModalAction: 'none' })
}}
/>
)
}
// Display the button bar if we are not in the native app and there
// is a focused highlight or selection data
if (!props.highlightBarDisabled && (focusedHighlight || selectionData)) {
const anchorCoordinates = () => {
return {
pageX:
focusedHighlightMousePos.current?.pageX ??
selectionData?.focusPosition.x ??
0,
pageY:
focusedHighlightMousePos.current?.pageY ??
selectionData?.focusPosition.y ??
0,
}
}
return (
<>
<HighlightBar
anchorCoordinates={anchorCoordinates()}
isNewHighlight={!!selectionData}
handleButtonClick={handleAction}
isSharedToFeed={focusedHighlight?.sharedAt != undefined}
isTouchscreenDevice={true /* isTouchScreenDevice() */}
/>
</>
)
}
if (props.showHighlightsModal) {
return (
<HighlightsModal
highlights={highlights}
onOpenChange={() => props.setShowHighlightsModal(false)}
deleteHighlightAction={(highlightId: string) => {
removeHighlightCallback(highlightId)
}}
/>
)
}
return <></>
}