import { Box, HStack, VStack, SpanBox } from '../../elements/LayoutPrimitives' import { StyledText } from '../../elements/StyledText' import { theme } from '../../tokens/stitches.config' import type { Highlight } from '../../../lib/networking/fragments/highlightFragment' import { useCallback, useEffect, useMemo, useReducer, useState } from 'react' import { BookOpen, PencilLine, X } from 'phosphor-react' 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' import { highlightsAsMarkdown } from '../homeFeed/HighlightItem' 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 { HighlightNoteBox, MarkdownNote } from '../../patterns/HighlightNotes' import { HighlightViewItem } from './HighlightViewItem' import { ConfirmationModal } from '../../patterns/ConfirmationModal' import { TrashIcon } from '../../elements/images/TrashIcon' import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery' import { LibraryItem, ReadableItem, } from '../../../lib/networking/queries/useGetLibraryItemsQuery' type NotebookProps = { viewer: UserBasicData item: ReadableItem highlights: Highlight[] sizeMode: 'normal' | 'maximized' viewInReader: (highlightId: string) => void onAnnotationsChanged?: ( highlights: Highlight[], deletedAnnotations: Highlight[] ) => 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 AnnotationInfo = { loaded: boolean note: Highlight | undefined noteId: string allAnnotations: Highlight[] deletedAnnotations: Highlight[] } export function Notebook(props: NotebookProps): JSX.Element { const [showConfirmDeleteHighlightId, setShowConfirmDeleteHighlightId] = useState(undefined) const [labelsTarget, setLabelsTarget] = useState(undefined) const [showConfirmDeleteNote, setShowConfirmDeleteNote] = useState(false) const [notesEditMode, setNotesEditMode] = useState<'edit' | 'preview'>('preview') const [, updateState] = useState({}) const annotationsReducer = ( state: AnnotationInfo, action: { type: string allHighlights?: Highlight[] note?: Highlight | undefined updateHighlight?: Highlight | undefined deleteHighlightId?: string | undefined } ) => { switch (action.type) { case 'RESET': { const note = action.allHighlights?.find((h) => h.type == 'NOTE') return { ...state, loaded: true, note: note, noteId: note?.id ?? state.noteId, allAnnotations: [...(action.allHighlights ?? [])], } } case 'CREATE_NOTE': { if (!action.note) { throw new Error('No note on CREATE_NOTE action') } return { ...state, note: action.note, noteId: action.note.id, allAnnotations: [...state.allAnnotations, action.note], } } case 'DELETE_NOTE': { // If there is no note to delete, just make sure we have cleared out the note const noteId = action.note?.id if (!action.note?.id) { return { ...state, node: undefined, noteId: uuidv4(), } } const idx = state.allAnnotations.findIndex((h) => h.id === noteId) return { ...state, note: undefined, noteId: uuidv4(), allAnnotations: state.allAnnotations.splice(idx, 1), } } case 'DELETE_HIGHLIGHT': { const highlightId = action.deleteHighlightId if (!highlightId) { throw new Error('No highlightId for delete action.') } const idx = state.allAnnotations.findIndex((h) => h.id === highlightId) if (idx < 0) { return { ...state } } const deleted = state.deletedAnnotations deleted.push(state.allAnnotations[idx]) return { ...state, deletedAnnotations: deleted, allAnnotations: state.allAnnotations.splice(idx, 1), } } case 'UPDATE_HIGHLIGHT': { const highlight = action.updateHighlight if (!highlight) { throw new Error('No highlightId for delete action.') } const idx = state.allAnnotations.findIndex((h) => h.id === highlight.id) if (idx !== -1) { state.allAnnotations[idx] = highlight } return { ...state, } } default: return state } } const [annotations, dispatchAnnotations] = useReducer(annotationsReducer, { loaded: false, note: undefined, noteId: uuidv4(), allAnnotations: [], deletedAnnotations: [], }) useEffect(() => { dispatchAnnotations({ type: 'RESET', allHighlights: props.highlights, }) }, [props.highlights]) useEffect(() => { if (props.onAnnotationsChanged) { props.onAnnotationsChanged( annotations.allAnnotations, annotations.deletedAnnotations ) } }, [annotations]) const deleteDocumentNote = useCallback(() => { const note = annotations.note if (!note) { showErrorToast('No note found') return } ;(async () => { try { const result = await deleteHighlightMutation(note.id) if (!result) { throw new Error() } showSuccessToast('Note deleted') dispatchAnnotations({ note, type: 'DELETE_NOTE', }) } catch (err) { console.log('error deleting note', err) showErrorToast('Error deleting note') } })() }, [annotations]) const sortedHighlights = useMemo(() => { const sorted = (a: number, b: number) => { if (a < b) { return -1 } if (a > b) { return 1 } return 0 } return annotations.allAnnotations .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) }) }, [annotations]) const handleSaveNoteText = useCallback( (text, cb: (success: boolean) => void) => { if (!annotations.loaded) { // We haven't loaded the user's annotations yet, so we can't // find or create their highlight note. return } if (!annotations.note) { const noteId = annotations.noteId ;(async () => { const success = await createHighlightMutation({ id: noteId, shortId: nanoid(8), type: 'NOTE', articleId: props.item.id, annotation: text, }) console.log('success creating annotation note: ', success) if (success) { dispatchAnnotations({ type: 'CREATE_NOTE', note: success, }) } cb(!!success) })() return } if (annotations.note) { const note = annotations.note ;(async () => { const success = await updateHighlightMutation({ highlightId: note.id, annotation: text, }) console.log('success updating annotation note: ', success) if (success) { note.annotation = text dispatchAnnotations({ type: 'UPDATE_NOTE', note: note, }) } cb(!!success) })() return } }, [annotations, props.item] ) return ( setNotesEditMode(edit ? 'edit' : 'preview')} /> {sortedHighlights.map((highlight) => ( { dispatchAnnotations({ type: 'UPDATE_HIGHLIGHT', updateHighlight: highlight, }) }} /> ))} {sortedHighlights.length === 0 && ( You have not added any highlights to this document. )} {showConfirmDeleteHighlightId && ( { ;(async () => { const success = await deleteHighlightMutation( showConfirmDeleteHighlightId ) console.log(' ConfirmationModal::DeleteHighlight', success) if (success) { dispatchAnnotations({ type: 'DELETE_HIGHLIGHT', deleteHighlightId: showConfirmDeleteHighlightId, }) showSuccessToast('Highlight deleted.') } else { showErrorToast('Error deleting highlight') } })() setShowConfirmDeleteHighlightId(undefined) }} onOpenChange={() => setShowConfirmDeleteHighlightId(undefined)} icon={ } /> )} {labelsTarget && ( { const result = setLabelsForHighlight( labelsTarget.id, labels.map((label) => label.id) ) return result }} /> )} {showConfirmDeleteNote && ( { deleteDocumentNote() setShowConfirmDeleteNote(false) }} onOpenChange={() => setShowConfirmDeleteNote(false)} /> )} ) } type TitledSectionProps = { title: string editMode?: boolean setEditMode?: (set: boolean) => void } function TitledSection(props: TitledSectionProps): JSX.Element { return ( <> {props.title} {props.setEditMode && ( { if (props.setEditMode) { props.setEditMode(!props.editMode) } event.preventDefault() }} > {props.editMode ? ( ) : ( )} )} ) }