Files
omnivore/packages/web/components/templates/article/HighlightsLayer.tsx
2022-03-25 14:17:02 -07:00

483 lines
14 KiB
TypeScript

import { useEffect, useRef, useCallback, useState } 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
showNotesSidebar: boolean
highlightsBaseURL: string
setShowNotesSidebar: 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)
}, [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,
]
)
// 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.showNotesSidebar) {
return (
<HighlightsModal
highlights={highlights}
onOpenChange={() => props.setShowNotesSidebar(false)}
deleteHighlightAction={(highlightId: string) => {
removeHighlightCallback(highlightId)
}}
/>
)
}
return <></>
}