321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
import {
|
|
ModalRoot,
|
|
ModalOverlay,
|
|
ModalContent,
|
|
ModalTitleBar,
|
|
} from '../../elements/ModalPrimitives'
|
|
import { Box, HStack, VStack, SpanBox } from '../../elements/LayoutPrimitives'
|
|
import { Button } from '../../elements/Button'
|
|
import { StyledText } from '../../elements/StyledText'
|
|
import { TrashIcon } from '../../elements/images/TrashIcon'
|
|
import { theme } from '../../tokens/stitches.config'
|
|
import type { Highlight } from '../../../lib/networking/fragments/highlightFragment'
|
|
import { HighlightView } from '../../patterns/HighlightView'
|
|
import { useCallback, useMemo, useState } from 'react'
|
|
import { StyledTextArea } from '../../elements/StyledTextArea'
|
|
import { ConfirmationModal } from '../../patterns/ConfirmationModal'
|
|
import { DotsThree } from 'phosphor-react'
|
|
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
|
|
import { SetLabelsModal } from './SetLabelsModal'
|
|
import { Label } from '../../../lib/networking/fragments/labelFragment'
|
|
import { setLabelsForHighlight } from '../../../lib/networking/mutations/setLabelsForHighlight'
|
|
import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation'
|
|
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
|
|
import { diff_match_patch } from 'diff-match-patch'
|
|
|
|
type NotebookModalProps = {
|
|
highlights: Highlight[]
|
|
scrollToHighlight?: (arg: string) => void
|
|
updateHighlight: (highlight: Highlight) => void
|
|
deleteHighlightAction?: (highlightId: string) => void
|
|
onOpenChange: (open: boolean) => void
|
|
}
|
|
|
|
export const getHighlightLocation = (patch: string): number | undefined => {
|
|
const dmp = new diff_match_patch()
|
|
const patches = dmp.patch_fromText(patch)
|
|
return patches[0].start1 || undefined
|
|
}
|
|
|
|
export function NotebookModal(props: NotebookModalProps): JSX.Element {
|
|
const [showConfirmDeleteHighlightId, setShowConfirmDeleteHighlightId] =
|
|
useState<undefined | string>(undefined)
|
|
const [labelsTarget, setLabelsTarget] = useState<Highlight | undefined>(
|
|
undefined
|
|
)
|
|
const [, updateState] = useState({})
|
|
|
|
const sortedHighlights = useMemo(() => {
|
|
const sorted = (a: number, b: number) => {
|
|
if (a < b) {
|
|
return -1
|
|
}
|
|
if (a > b) {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
return props.highlights.sort((a: Highlight, b: Highlight) => {
|
|
if (a.highlightPositionPercent && b.highlightPositionPercent) {
|
|
return sorted(a.highlightPositionPercent, b.highlightPositionPercent)
|
|
}
|
|
// We do this in a try/catch because it might be an invalid diff
|
|
// With PDF it will definitely be an invalid diff.
|
|
try {
|
|
const aPos = getHighlightLocation(a.patch)
|
|
const bPos = getHighlightLocation(b.patch)
|
|
if (aPos && bPos) {
|
|
return sorted(aPos, bPos)
|
|
}
|
|
} catch {}
|
|
return a.createdAt.localeCompare(b.createdAt)
|
|
})
|
|
}, [props.highlights])
|
|
|
|
return (
|
|
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
|
|
<ModalOverlay />
|
|
<ModalContent
|
|
onPointerDownOutside={(event) => {
|
|
event.preventDefault()
|
|
props.onOpenChange(false)
|
|
}}
|
|
css={{ overflow: 'auto', px: '24px' }}
|
|
>
|
|
<VStack distribution="start" css={{ height: '100%' }}>
|
|
<ModalTitleBar title="Notebook" onOpenChange={props.onOpenChange} />
|
|
<Box css={{ overflow: 'auto', width: '100%' }}>
|
|
{sortedHighlights.map((highlight) => (
|
|
<ModalHighlightView
|
|
key={highlight.id}
|
|
highlight={highlight}
|
|
showDelete={!!props.deleteHighlightAction}
|
|
scrollToHighlight={props.scrollToHighlight}
|
|
setSetLabelsTarget={setLabelsTarget}
|
|
setShowConfirmDeleteHighlightId={
|
|
setShowConfirmDeleteHighlightId
|
|
}
|
|
deleteHighlightAction={() => {
|
|
if (props.deleteHighlightAction) {
|
|
props.deleteHighlightAction(highlight.id)
|
|
}
|
|
}}
|
|
updateHighlight={props.updateHighlight}
|
|
/>
|
|
))}
|
|
{sortedHighlights.length === 0 && (
|
|
<SpanBox css={{ textAlign: 'center', width: '100%' }}>
|
|
<StyledText css={{ mb: '40px' }}>
|
|
You have not added any highlights or notes to this document
|
|
</StyledText>
|
|
</SpanBox>
|
|
)}
|
|
</Box>
|
|
</VStack>
|
|
</ModalContent>
|
|
{showConfirmDeleteHighlightId && (
|
|
<ConfirmationModal
|
|
message={'Are you sure you want to delete this highlight?'}
|
|
onAccept={() => {
|
|
if (props.deleteHighlightAction) {
|
|
props.deleteHighlightAction(showConfirmDeleteHighlightId)
|
|
}
|
|
setShowConfirmDeleteHighlightId(undefined)
|
|
}}
|
|
onOpenChange={() => setShowConfirmDeleteHighlightId(undefined)}
|
|
icon={
|
|
<TrashIcon
|
|
size={40}
|
|
strokeColor={theme.colors.grayTextContrast.toString()}
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
{labelsTarget && (
|
|
<SetLabelsModal
|
|
provider={labelsTarget}
|
|
onOpenChange={function (open: boolean): void {
|
|
setLabelsTarget(undefined)
|
|
}}
|
|
onLabelsUpdated={function (labels: Label[]): void {
|
|
updateState({})
|
|
}}
|
|
save={function (labels: Label[]): Promise<Label[] | undefined> {
|
|
const result = setLabelsForHighlight(
|
|
labelsTarget.id,
|
|
labels.map((label) => label.id)
|
|
)
|
|
return result
|
|
}}
|
|
/>
|
|
)}
|
|
</ModalRoot>
|
|
)
|
|
}
|
|
|
|
type ModalHighlightViewProps = {
|
|
highlight: Highlight
|
|
showDelete: boolean
|
|
scrollToHighlight?: (arg: string) => void
|
|
deleteHighlightAction: () => void
|
|
updateHighlight: (highlight: Highlight) => void
|
|
|
|
setSetLabelsTarget: (highlight: Highlight) => void
|
|
setShowConfirmDeleteHighlightId: (id: string | undefined) => void
|
|
}
|
|
|
|
function ModalHighlightView(props: ModalHighlightViewProps): JSX.Element {
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
|
|
const copyHighlight = useCallback(async () => {
|
|
await navigator.clipboard.writeText(props.highlight.quote)
|
|
}, [props.highlight])
|
|
|
|
return (
|
|
<>
|
|
<VStack>
|
|
<SpanBox css={{ marginLeft: 'auto' }}>
|
|
<Dropdown
|
|
triggerElement={
|
|
<DotsThree size={24} color={theme.colors.readerFont.toString()} />
|
|
}
|
|
>
|
|
<DropdownOption
|
|
onSelect={async () => {
|
|
await copyHighlight()
|
|
}}
|
|
title="Copy"
|
|
/>
|
|
<DropdownOption
|
|
onSelect={() => {
|
|
props.setSetLabelsTarget(props.highlight)
|
|
}}
|
|
title="Labels"
|
|
/>
|
|
<DropdownOption
|
|
onSelect={() => {
|
|
props.setShowConfirmDeleteHighlightId(props.highlight.id)
|
|
}}
|
|
title="Delete"
|
|
/>
|
|
</Dropdown>
|
|
</SpanBox>
|
|
|
|
<HighlightView
|
|
scrollToHighlight={props.scrollToHighlight}
|
|
highlight={props.highlight}
|
|
/>
|
|
{!isEditing ? (
|
|
<StyledText
|
|
css={{
|
|
borderRadius: '6px',
|
|
bg: '$grayBase',
|
|
p: '16px',
|
|
width: '100%',
|
|
marginTop: '24px',
|
|
color: '$grayText',
|
|
}}
|
|
onClick={() => setIsEditing(true)}
|
|
>
|
|
{props.highlight.annotation
|
|
? props.highlight.annotation
|
|
: 'Add your notes...'}
|
|
</StyledText>
|
|
) : null}
|
|
{isEditing && (
|
|
<TextEditArea
|
|
setIsEditing={setIsEditing}
|
|
highlight={props.highlight}
|
|
updateHighlight={props.updateHighlight}
|
|
/>
|
|
)}
|
|
<SpanBox css={{ mt: '$2', mb: '$4' }} />
|
|
</VStack>
|
|
</>
|
|
)
|
|
}
|
|
|
|
type TextEditAreaProps = {
|
|
setIsEditing: (editing: boolean) => void
|
|
highlight: Highlight
|
|
updateHighlight: (highlight: Highlight) => void
|
|
}
|
|
|
|
const TextEditArea = (props: TextEditAreaProps): JSX.Element => {
|
|
const [noteContent, setNoteContent] = useState(
|
|
props.highlight.annotation ?? ''
|
|
)
|
|
|
|
const handleNoteContentChange = useCallback(
|
|
(event: React.ChangeEvent<HTMLTextAreaElement>): void => {
|
|
setNoteContent(event.target.value)
|
|
},
|
|
[setNoteContent]
|
|
)
|
|
|
|
return (
|
|
<VStack css={{ width: '100%' }} key="textEditor">
|
|
<StyledTextArea
|
|
css={{
|
|
my: '$3',
|
|
minHeight: '$6',
|
|
borderRadius: '6px',
|
|
bg: '$grayBase',
|
|
p: '16px',
|
|
width: '100%',
|
|
marginTop: '16px',
|
|
resize: 'vertical',
|
|
}}
|
|
autoFocus
|
|
maxLength={4000}
|
|
value={noteContent}
|
|
placeholder={'Add your notes...'}
|
|
onChange={handleNoteContentChange}
|
|
/>
|
|
<HStack alignment="center" distribution="end" css={{ width: '100%' }}>
|
|
<Button
|
|
style="ctaPill"
|
|
css={{ mr: '$2' }}
|
|
onClick={() => {
|
|
props.setIsEditing(false)
|
|
setNoteContent(props.highlight.annotation ?? '')
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
style="ctaDarkYellow"
|
|
onClick={async (e) => {
|
|
e.preventDefault()
|
|
|
|
console.log('updating highlight')
|
|
try {
|
|
const result = await updateHighlightMutation({
|
|
highlightId: props.highlight.id,
|
|
annotation: noteContent,
|
|
})
|
|
console.log('result: ' + result)
|
|
|
|
if (!result) {
|
|
showErrorToast('There was an error updating your highlight.')
|
|
} else {
|
|
showSuccessToast('Note saved')
|
|
props.highlight.annotation = noteContent
|
|
props.updateHighlight(props.highlight)
|
|
}
|
|
} catch (err) {
|
|
console.log('error updating annoation', err)
|
|
showErrorToast('There was an error updating your highlight.')
|
|
}
|
|
|
|
props.setIsEditing(false)
|
|
}}
|
|
>
|
|
Save
|
|
</Button>
|
|
</HStack>
|
|
</VStack>
|
|
)
|
|
}
|