diff --git a/packages/web/components/templates/HighlightNoteBox.tsx b/packages/web/components/patterns/HighlightNoteBox.tsx similarity index 54% rename from packages/web/components/templates/HighlightNoteBox.tsx rename to packages/web/components/patterns/HighlightNoteBox.tsx index 7f61c84f4..8ce55bc4c 100644 --- a/packages/web/components/templates/HighlightNoteBox.tsx +++ b/packages/web/components/patterns/HighlightNoteBox.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react/no-children-prop */ import { ChangeEvent, useCallback, @@ -7,19 +8,14 @@ import { useState, } from 'react' import { formattedShortTime } from '../../lib/dateFormatting' -import { createHighlightMutation } from '../../lib/networking/mutations/createHighlightMutation' -import { updateHighlightMutation } from '../../lib/networking/mutations/updateHighlightMutation' import { HStack, SpanBox, VStack } from '../elements/LayoutPrimitives' import MarkdownIt from 'markdown-it' import MdEditor from 'react-markdown-editor-lite' import 'react-markdown-editor-lite/lib/index.css' import ReactMarkdown from 'react-markdown' -import { v4 as uuidv4 } from 'uuid' -import { nanoid } from 'nanoid' import throttle from 'lodash/throttle' import { Highlight } from '../../lib/networking/fragments/highlightFragment' -import { StyledText } from '../elements/StyledText' const mdParser = new MarkdownIt() @@ -27,85 +23,34 @@ type NoteSectionProps = { placeHolder: string mode: 'edit' | 'preview' - pageId: string - highlight?: Highlight - sizeMode: 'normal' | 'maximized' setEditMode: (set: 'edit' | 'preview') => void - // dispatchList: Dispatch + + text: string | undefined + saveText: (text: string, completed: (success: boolean) => void) => void } export function HighlightNoteBox(props: NoteSectionProps): JSX.Element { - const [noteText, setNoteText] = useState('') const [lastSaved, setLastSaved] = useState(undefined) const [lastChanged, setLastChanged] = useState(undefined) const [errorSaving, setErrorSaving] = useState(undefined) - const [createStartTime, setCreateStartTime] = useState( - undefined - ) - - useEffect(() => { - setNoteText(props.highlight?.annotation ?? '') - }, [props.highlight?.annotation]) - - const highlightId = useMemo(() => { - console.log(' -- highlightId: ', props.highlight) - if (props.highlight) { - return { id: props.highlight.id, shortId: props.highlight.shortId } - } - return { id: uuidv4(), shortId: nanoid(8) } - }, [props.highlight]) - const saveText = useCallback( (text, updateTime) => { - ;(async () => { - console.log('calling save: ', props.highlight) - if (props.highlight) { - const success = await updateHighlightMutation({ - annotation: text, - highlightId: props.highlight?.id, - }) - if (success) { - setLastSaved(updateTime) - } else { - setErrorSaving('Error saving highlight.') - } - } else { - console.log('creating note highlight: ', highlightId) - if (!createStartTime) { - setCreateStartTime(new Date()) - - const created = await createHighlightMutation({ - type: 'NOTE', - id: highlightId.id, - articleId: props.pageId, - shortId: highlightId.shortId, - annotation: text, - }) - console.log('created highlight: ', created) - - if (created) { - setLastSaved(updateTime) - // props.dispatchList({ - // type: 'CREATE_NOTE', - // highlight: created, - // }) - } else { - console.log('unable to create note highlight') - } - } + props.saveText(text, (success) => { + if (success) { + setLastSaved(updateTime) } - })() + }) }, - [lastSaved, lastChanged, createStartTime] + [props] ) const saveRef = useRef(saveText) useEffect(() => { saveRef.current = saveText - }, [lastSaved, lastChanged, createStartTime]) + }, [lastSaved, lastChanged]) const debouncedSave = useMemo< (text: string, updateTime: Date) => void @@ -125,13 +70,11 @@ export function HighlightNoteBox(props: NoteSectionProps): JSX.Element { event.preventDefault() } - setNoteText(data.text) - const updateTime = new Date() setLastChanged(updateTime) debouncedSave(data.text, updateTime) }, - [lastSaved, lastChanged, createStartTime] + [lastSaved, lastChanged] ) return ( @@ -159,14 +102,10 @@ export function HighlightNoteBox(props: NoteSectionProps): JSX.Element { }} > { - console.log(' BLURRING ') - props.setEditMode('preview') - }} canView={{ menu: props.mode == 'edit', md: true, @@ -227,14 +166,8 @@ export function HighlightNoteBox(props: NoteSectionProps): JSX.Element { ) : ( - - + props.setEditMode('edit')} > - - + + + )} + + ) +} + +type MarkdownNote = { + placeHolder: string + mode: 'edit' | 'preview' + + sizeMode: 'normal' | 'maximized' + setEditMode: (set: 'edit' | 'preview') => void + + highlight: Highlight + + defaultText: string | undefined + handleEditorChange: (data: { text: string; html: string }) => void +} + +export function MarkdownNote(props: MarkdownNote): JSX.Element { + const [lastSaved, setLastSaved] = useState(undefined) + const [lastChanged, setLastChanged] = useState(undefined) + const [errorSaving, setErrorSaving] = useState(undefined) + + return ( + <> + {props.mode == 'edit' ? ( + .section': { + borderRight: 'unset', + }, + '.rc-md-editor .editor-container .sec-md .input': { + padding: '10px', + borderRadius: '5px', + }, + }} + > + mdParser.render(text)} + onChange={props.handleEditorChange} + /> + + {errorSaving && ( + + {errorSaving} + + )} + {lastSaved !== undefined ? ( + <> + {lastChanged === lastSaved + ? 'Saved' + : `Last saved ${formattedShortTime(lastSaved.toISOString())}`} + + ) : null} + + + ) : ( + <> + *': { + m: '0px', + }, + }} + onClick={() => props.setEditMode('edit')} + > + + + )} ) diff --git a/packages/web/components/patterns/HighlightView.tsx b/packages/web/components/patterns/HighlightView.tsx index dcb8f1657..50c42cbf2 100644 --- a/packages/web/components/patterns/HighlightView.tsx +++ b/packages/web/components/patterns/HighlightView.tsx @@ -17,6 +17,7 @@ type HighlightViewProps = { author?: string title?: string scrollToHighlight?: (arg: string) => void + updateHighlight: (highlight: Highlight) => void } const StyledQuote = styled(Blockquote, { @@ -97,11 +98,13 @@ export function HighlightView(props: HighlightViewProps): JSX.Element { ))} diff --git a/packages/web/components/patterns/HighlightViewNote.tsx b/packages/web/components/patterns/HighlightViewNote.tsx index 458254f3f..d0aaffa5e 100644 --- a/packages/web/components/patterns/HighlightViewNote.tsx +++ b/packages/web/components/patterns/HighlightViewNote.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react/no-children-prop */ import { ChangeEvent, useCallback, @@ -7,19 +8,15 @@ import { useState, } from 'react' import { formattedShortTime } from '../../lib/dateFormatting' -import { createHighlightMutation } from '../../lib/networking/mutations/createHighlightMutation' import { updateHighlightMutation } from '../../lib/networking/mutations/updateHighlightMutation' -import { Box, HStack, SpanBox, VStack } from '../elements/LayoutPrimitives' +import { HStack, SpanBox, VStack } from '../elements/LayoutPrimitives' import MarkdownIt from 'markdown-it' import MdEditor from 'react-markdown-editor-lite' import 'react-markdown-editor-lite/lib/index.css' import ReactMarkdown from 'react-markdown' -import { v4 as uuidv4 } from 'uuid' -import { nanoid } from 'nanoid' import throttle from 'lodash/throttle' import { Highlight } from '../../lib/networking/fragments/highlightFragment' -import { StyledText } from '../elements/StyledText' const mdParser = new MarkdownIt() @@ -27,83 +24,44 @@ type HighlightViewNoteProps = { placeHolder: string mode: 'edit' | 'preview' - highlight?: Highlight + highlight: Highlight sizeMode: 'normal' | 'maximized' setEditMode: (set: 'edit' | 'preview') => void + + text: string | undefined + updateHighlight: (highlight: Highlight) => void } export function HighlightViewNote(props: HighlightViewNoteProps): JSX.Element { - const [noteText, setNoteText] = useState('') const [lastSaved, setLastSaved] = useState(undefined) const [lastChanged, setLastChanged] = useState(undefined) const [errorSaving, setErrorSaving] = useState(undefined) - const [createStartTime, setCreateStartTime] = useState( - undefined - ) - - useEffect(() => { - setNoteText(props.highlight?.annotation ?? '') - }, [props.highlight?.annotation]) - - const highlightId = useMemo(() => { - console.log(' -- highlightId: ', props.highlight) - if (props.highlight) { - return { id: props.highlight.id, shortId: props.highlight.shortId } - } - return { id: uuidv4(), shortId: nanoid(8) } - }, [props.highlight]) - const saveText = useCallback( (text, updateTime) => { ;(async () => { - console.log('calling save: ', props.highlight) - if (props.highlight) { - const success = await updateHighlightMutation({ - annotation: text, - highlightId: props.highlight?.id, - }) - if (success) { - setLastSaved(updateTime) - } else { - setErrorSaving('Error saving highlight.') - } + const success = await updateHighlightMutation({ + annotation: text, + highlightId: props.highlight?.id, + }) + if (success) { + setLastSaved(updateTime) + props.highlight.annotation = text + props.updateHighlight(props.highlight) } else { - console.log('creating note highlight: ', highlightId) - if (!createStartTime) { - setCreateStartTime(new Date()) - - const created = await createHighlightMutation({ - type: 'NOTE', - id: highlightId.id, - articleId: props.pageId, - shortId: highlightId.shortId, - annotation: text, - }) - console.log('created highlight: ', created) - - if (created) { - setLastSaved(updateTime) - // props.dispatchList({ - // type: 'CREATE_NOTE', - // highlight: created, - // }) - } else { - console.log('unable to create note highlight') - } - } + setErrorSaving('Error saving highlight.') } })() }, - [lastSaved, lastChanged, createStartTime] + [props] ) const saveRef = useRef(saveText) useEffect(() => { saveRef.current = saveText - }, [lastSaved, lastChanged, createStartTime]) + }, [lastSaved, lastChanged, saveText]) const debouncedSave = useMemo< (text: string, updateTime: Date) => void @@ -123,16 +81,13 @@ export function HighlightViewNote(props: HighlightViewNoteProps): JSX.Element { event.preventDefault() } - setNoteText(data.text) - const updateTime = new Date() setLastChanged(updateTime) debouncedSave(data.text, updateTime) }, - [lastSaved, lastChanged, createStartTime] + [lastSaved, lastChanged, saveText] ) - console.log(' props: ', props) return ( <> {props.mode == 'edit' ? ( @@ -158,14 +113,10 @@ export function HighlightViewNote(props: HighlightViewNoteProps): JSX.Element { }} > { - console.log(' BLURRING ') - props.setEditMode('preview') - }} canView={{ menu: props.mode == 'edit', md: true, @@ -223,31 +174,11 @@ export function HighlightViewNote(props: HighlightViewNoteProps): JSX.Element { : `Last saved ${formattedShortTime(lastSaved.toISOString())}`} ) : null} - {/* { - props.setEditMode(!editMode) - event.preventDefault() - }} - > - {editMode ? Preview : Preview} - */} ) : ( <> - - + )} ) } - -// {!isEditing ? ( -// setIsEditing(true)} -// > -// {props.highlight.annotation -// ? props.highlight.annotation -// : 'Add notes to this highlight...'} -// -// ) : null} -// {isEditing && ( diff --git a/packages/web/components/templates/article/NotebookModal.tsx b/packages/web/components/templates/article/NotebookModal.tsx index c07242a26..875de63bf 100644 --- a/packages/web/components/templates/article/NotebookModal.tsx +++ b/packages/web/components/templates/article/NotebookModal.tsx @@ -10,16 +10,7 @@ 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 { - ChangeEvent, - Dispatch, - useCallback, - useEffect, - useMemo, - useReducer, - useRef, - useState, -} from 'react' +import { useCallback, useEffect, useMemo, useReducer, useState } from 'react' import { ConfirmationModal } from '../../patterns/ConfirmationModal' import { ArrowsIn, ArrowsOut, BookOpen, PencilLine, X } from 'phosphor-react' import { Dropdown, DropdownOption } from '../../elements/DropdownElements' @@ -31,20 +22,12 @@ import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers' import { diff_match_patch } from 'diff-match-patch' import { MenuTrigger } from '../../elements/MenuTrigger' import { highlightsAsMarkdown, HighlightsMenu } from '../homeFeed/HighlightItem' -import MarkdownIt from 'markdown-it' -import MdEditor from 'react-markdown-editor-lite' import 'react-markdown-editor-lite/lib/index.css' -import ReactMarkdown from 'react-markdown' -import { formattedShortTime } from '../../../lib/dateFormatting' import { createHighlightMutation } from '../../../lib/networking/mutations/createHighlightMutation' import { v4 as uuidv4 } from 'uuid' import { nanoid } from 'nanoid' -import throttle from 'lodash/throttle' import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation' -import { LibraryItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery' -import { HighlightNoteBox } from '../HighlightNoteBox' - -const mdParser = new MarkdownIt() +import { HighlightNoteBox } from '../../patterns/HighlightNoteBox' type NotebookModalProps = { pageId: string @@ -57,9 +40,10 @@ type NotebookModalProps = { type HighlightListReducerAction = { type: string - itemId?: string + highlightId?: string createId?: string removeId?: string + note?: string highlight?: Highlight highlights?: Highlight[] } @@ -70,6 +54,15 @@ export const getHighlightLocation = (patch: string): number | undefined => { return patches[0].start1 || undefined } +type AnnotationInfo = { + loaded: boolean + + note: Highlight | undefined + noteId: string + + allAnnotations: Highlight[] +} + export function NotebookModal(props: NotebookModalProps): JSX.Element { const [showConfirmDeleteHighlightId, setShowConfirmDeleteHighlightId] = useState(undefined) @@ -78,78 +71,145 @@ export function NotebookModal(props: NotebookModalProps): JSX.Element { ) const [sizeMode, setSizeMode] = useState<'normal' | 'maximized'>('normal') const [showConfirmDeleteNote, setShowConfirmDeleteNote] = useState(false) - const [notesEditMode, setNotesEditMode] = useState<'edit' | 'preview'>('edit') + const [notesEditMode, setNotesEditMode] = useState<'edit' | 'preview'>( + 'preview' + ) const [, updateState] = useState({}) - const listReducer = ( - state: Highlight[], - action: HighlightListReducerAction + const annotationsReducer = ( + state: AnnotationInfo, + action: { + type: string + allHighlights?: Highlight[] + note?: Highlight | undefined + + updateHighlight?: Highlight | undefined + deleteHighlightId?: string | undefined + } ) => { + console.log('annotationsReducer', action.type) switch (action.type) { - case 'RESET': - return action.highlights ?? [] - case 'CREATE_NOTE': - if (!action.highlight) { - throw new Error('Unable to create note') + case 'RESET': { + console.log(' -- reseting highlights: ', action.allHighlights) + const note = action.allHighlights?.find((h) => h.type == 'NOTE') + return { + ...state, + loaded: true, + note: note, + noteId: note?.id ?? state.noteId, + allAnnotations: action.allHighlights ?? [], } - return [...(action.highlights ?? []), action.highlight] - case 'UPDATE_NOTE': - return action.highlights ?? [] - case 'REMOVE_HIGHLIGHT': - // const item = state.find((li) => li.node.id === action.itemId) - // if (item && item.node.highlights) { - // item.node.highlights = item.node.highlights.filter( - // (h) => h.id !== action.highlightId - // ) - // } - // const result = state.filter( - // (item) => item.node.highlights && item.node.highlights.length > 0 - // ) - // return result + } + 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 } + } + return { + ...state, + 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: - throw new Error() + return state } } - const [highlights, dispatchList] = useReducer(listReducer, []) + const [annotations, dispatchAnnotations] = useReducer(annotationsReducer, { + loaded: false, + note: undefined, + noteId: uuidv4(), + allAnnotations: [], + }) useEffect(() => { - dispatchList({ + dispatchAnnotations({ type: 'RESET', - highlights: props.highlights, + allHighlights: props.highlights, }) }, [props.highlights]) const exportHighlights = useCallback(() => { ;(async () => { - if (!highlights) { + if (!annotations) { showErrorToast('No highlights to export') return } - const markdown = highlightsAsMarkdown(highlights) + const markdown = highlightsAsMarkdown(annotations.allAnnotations) await navigator.clipboard.writeText(markdown) showSuccessToast('Highlight copied') })() - }, [highlights]) + }, [annotations]) const deleteDocumentNote = useCallback(() => { + const note = annotations.note + if (!note) { + showErrorToast('No note found') + return + } ;(async () => { - const notes = highlights.filter((h) => h.type == 'NOTE') - - notes.forEach(async (n) => { - try { - const result = await deleteHighlightMutation(n.id) - if (!result) { - throw new Error() - } - showSuccessToast('Note deleted') - } catch (err) { - console.log('error deleting note', err) - showErrorToast('Error deleting note') + 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) => { @@ -162,8 +222,8 @@ export function NotebookModal(props: NotebookModalProps): JSX.Element { return 0 } - return highlights - .filter((h) => h.type === undefined || h.type === 'HIGHLIGHT') + return annotations.allAnnotations + .filter((h) => h.type === 'HIGHLIGHT') .sort((a: Highlight, b: Highlight) => { if (a.highlightPositionPercent && b.highlightPositionPercent) { return sorted(a.highlightPositionPercent, b.highlightPositionPercent) @@ -179,21 +239,80 @@ export function NotebookModal(props: NotebookModalProps): JSX.Element { } catch {} return a.createdAt.localeCompare(b.createdAt) }) - }, [highlights]) + }, [annotations]) + + const handleSaveNoteText = useCallback( + (text, cb: (success: boolean) => void) => { + console.log(' handleSaveNoteText: ', text, 'highlights', annotations) + 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.pageId, + 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.pageId] + ) return ( - + { + console.log('CLOSING DIALOG') + props.onOpenChange(false) + }} + > { + onInteractOutside={(event) => { event.preventDefault() - props.onOpenChange(false) }} css={{ overflow: 'auto', height: sizeMode === 'normal' ? 'unset' : '100%', maxWidth: sizeMode === 'normal' ? '640px' : '100%', - minHeight: '525px', + minHeight: sizeMode === 'normal' ? '525px' : 'unset', }} > setNotesEditMode(edit ? 'edit' : 'preview')} /> h.type == 'NOTE')} sizeMode={sizeMode} mode={notesEditMode} setEditMode={setNotesEditMode} - // dispatchList={dispatchList} + text={annotations.note?.annotation} + placeHolder="Add notes to this document..." + saveText={handleSaveNoteText} /> - {/* {props.highlights.map((highlight) => ( + {/* {annotations.allAnnotations.map((highlight) => ( {highlight.annotation}