import { Box, HStack, VStack, SpanBox } from '../../elements/LayoutPrimitives' import { theme } from '../../tokens/stitches.config' import type { Highlight } from '../../../lib/networking/fragments/highlightFragment' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation' import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers' import { diff_match_patch } from 'diff-match-patch' import 'react-markdown-editor-lite/lib/index.css' import { createHighlightMutation } from '../../../lib/networking/mutations/createHighlightMutation' import { v4 as uuidv4 } from 'uuid' import { nanoid } from 'nanoid' import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation' import { HighlightViewItem } from './HighlightViewItem' import { ConfirmationModal } from '../../patterns/ConfirmationModal' import { TrashIcon } from '../../elements/icons/TrashIcon' import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery' import { ReadableItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery' import { SetHighlightLabelsModalPresenter } from './SetLabelsModalPresenter' import { ArticleNotes } from '../../patterns/ArticleNotes' import { useGetArticleQuery } from '../../../lib/networking/queries/useGetArticleQuery' import { formattedShortTime } from '../../../lib/dateFormatting' import { isDarkTheme } from '../../../lib/themeUpdater' type NotebookContentProps = { viewer: UserBasicData item: ReadableItem viewInReader: (highlightId: string) => void onAnnotationsChanged?: (highlights: Highlight[]) => void showConfirmDeleteNote?: boolean setShowConfirmDeleteNote?: (show: 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 } type NoteState = { isCreating: boolean note: Highlight | undefined createStarted: Date | undefined } export function NotebookContent(props: NotebookContentProps): JSX.Element { const isDark = isDarkTheme() const { articleData, mutate } = useGetArticleQuery({ slug: props.item.slug, username: props.viewer.profile.username, includeFriendsHighlights: false, }) const [noteText, setNoteText] = useState('') const [showConfirmDeleteHighlightId, setShowConfirmDeleteHighlightId] = useState(undefined) const [labelsTarget, setLabelsTarget] = useState( undefined ) const noteState = useRef({ isCreating: false, note: undefined, createStarted: undefined, }) const newNoteId = useMemo(() => { return uuidv4() }, []) const updateNote = useCallback( (note: Highlight, text: string, startTime: Date) => { ;(async () => { const result = await updateHighlightMutation({ libraryItemId: props.item.id, highlightId: note.id, annotation: text, }) if (result) { setLastSaved(startTime) } else { setErrorSaving('Error saving') } })() }, [props] ) const createNote = useCallback( (text: string) => { noteState.current.isCreating = true noteState.current.createStarted = new Date() ;(async () => { try { const success = await createHighlightMutation({ id: newNoteId, shortId: nanoid(8), type: 'NOTE', articleId: props.item.id, annotation: text, }) if (success) { noteState.current.note = success noteState.current.isCreating = false } else { setErrorSaving('Error creating note') } } catch (error) { console.error('error creating note: ', error) noteState.current.isCreating = false setErrorSaving('Error creating note') } })() }, [props, newNoteId] ) const highlights = useMemo(() => { const result = articleData?.article.article.highlights const note = result?.find((h) => h.type === 'NOTE') if (note) { noteState.current.note = note noteState.current.isCreating = false setNoteText(note.annotation || '') } else { setNoteText('') } return result }, [articleData]) useEffect(() => { if (highlights && props.onAnnotationsChanged) { props.onAnnotationsChanged(highlights) } }, [props, highlights]) const sortedHighlights = useMemo(() => { const sorted = (a: number, b: number) => { if (a < b) { return -1 } if (a > b) { return 1 } return 0 } return (highlights ?? []) .filter((h) => h.type === 'HIGHLIGHT') .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) }) }, [highlights]) const handleSaveNoteText = useCallback( (text: string) => { const changeTime = new Date() setLastChanged(changeTime) if (noteState.current.note) { updateNote(noteState.current.note, text, changeTime) return } if (noteState.current.isCreating) { if (noteState.current.createStarted) { const timeSinceStart = new Date().getTime() - noteState.current.createStarted.getTime() if (timeSinceStart > 4000) { createNote(text) return } } return } createNote(text) }, [noteState, createNote, updateNote] ) const deleteDocumentNote = useCallback(() => { ;(async () => { highlights ?.filter((h) => h.type === 'NOTE') .forEach(async (h) => { const result = await deleteHighlightMutation(props.item.id, h.id) if (!result) { showErrorToast('Error deleting note') } }) noteState.current.note = undefined })() setNoteText('') }, [props, noteState, highlights]) const [errorSaving, setErrorSaving] = useState(undefined) const [lastChanged, setLastChanged] = useState(undefined) const [lastSaved, setLastSaved] = useState(undefined) useEffect(() => { const highlightsUpdated = () => { mutate() } document.addEventListener('highlightsUpdated', highlightsUpdated) return () => { document.removeEventListener('highlightsUpdated', highlightsUpdated) } }, [mutate]) return ( <> {errorSaving && ( {errorSaving} )} {lastSaved !== undefined ? ( <> {lastChanged === lastSaved ? 'Saved' : `Last saved ${formattedShortTime(lastSaved.toISOString())}`} ) : null} {sortedHighlights.map((highlight) => ( { mutate() }} /> ))} {sortedHighlights.length === 0 && ( You have not added any highlights to this document. )} {showConfirmDeleteHighlightId && ( { ;(async () => { const highlightId = showConfirmDeleteHighlightId const success = await deleteHighlightMutation( props.item.id, showConfirmDeleteHighlightId ) mutate() if (success) { showSuccessToast('Highlight deleted.', { position: 'bottom-right', }) const event = new CustomEvent('deleteHighlightbyId', { detail: highlightId, }) document.dispatchEvent(event) } else { showErrorToast('Error deleting highlight', { position: 'bottom-right', }) } })() setShowConfirmDeleteHighlightId(undefined) }} onOpenChange={() => setShowConfirmDeleteHighlightId(undefined)} icon={ } /> )} {labelsTarget && ( { // Don't actually need to do something here console.log('update highlight: ', highlight) }} onOpenChange={() => { mutate() setLabelsTarget(undefined) }} /> )} {props.showConfirmDeleteNote && ( { deleteDocumentNote() if (props.setShowConfirmDeleteNote) { props.setShowConfirmDeleteNote(false) } }} onOpenChange={() => { if (props.setShowConfirmDeleteNote) { props.setShowConfirmDeleteNote(false) } }} /> )} ) }