diff --git a/src/components/NoteManager/NoteManager.tsx b/src/components/NoteManager/NoteManager.tsx index 8a643bd2..660411a8 100644 --- a/src/components/NoteManager/NoteManager.tsx +++ b/src/components/NoteManager/NoteManager.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useContext, useEffect, useRef } from "react"; import "./NoteManager.css"; import type { ISynctexBlockId } from "@fluffylabs/links-metadata"; -import { cn } from "@fluffylabs/shared-ui"; +import { Alert, Button, cn } from "@fluffylabs/shared-ui"; import { twMerge } from "tailwind-merge"; import { useLatestCallback } from "../../hooks/useLatestCallback"; import { type ILocationContext, LocationContext } from "../LocationProvider/LocationProvider"; @@ -12,6 +12,7 @@ import { type ISelectionContext, SelectionContext } from "../SelectionProvider/S import { InactiveNoteSkeleton } from "./components/InactiveNoteSkeleton"; import { NewNote } from "./components/NewNote"; import { NotesList } from "./components/NotesList"; +import { useFilteredNoteAlert } from "./hooks/useFilteredNoteAlert"; import { useNoteManagerNotes } from "./useNoteManagerNotes"; const DEFAULT_AUTHOR = ""; @@ -31,7 +32,7 @@ function Notes() { const { notesManagerNotes: notes, activeNotes, - latestHandleAddNote, + addNote, sectionTitlesLoaded, notesReady, deleteNote, @@ -40,6 +41,7 @@ function Notes() { const { selectedBlocks, pageNumber, handleClearSelection } = useContext(SelectionContext) as ISelectionContext; const keepShowingNewNote = useRef<{ selectionEnd: ISynctexBlockId; selectionStart: ISynctexBlockId }>(undefined); const latestHandleClearSelection = useLatestCallback(handleClearSelection); + const { noteAlertVisibilityState, triggerFilteredNoteAlert, closeNoteAlert } = useFilteredNoteAlert(); const memoizedHandleDeleteNote = useCallback( (note: IDecoratedNote) => { @@ -53,9 +55,14 @@ function Notes() { (note: IDecoratedNote, newNote: IStorageNote) => { // NOTE(optimistic): intentional mutation for immediate UI feedback; be aware this bypasses immutability note.original.content = newNote.content; - updateNote(note, newNote); + note.original.labels = newNote.labels; + const { isVisible } = updateNote(note, newNote); + if (!isVisible) { + handleClearSelection(); + triggerFilteredNoteAlert("visibleForUpdated"); + } }, - [updateNote], + [updateNote, handleClearSelection, triggerFilteredNoteAlert], ); const handleNewNoteCancel = useCallback(() => { @@ -84,13 +91,19 @@ function Notes() { labels, }; - latestHandleAddNote.current(newNote); + const { isVisible } = addNote(newNote); + + if (!isVisible) { + triggerFilteredNoteAlert("visibleForCreated"); + handleClearSelection(); + } + keepShowingNewNote.current = { selectionStart: locationParams.selectionStart, selectionEnd: locationParams.selectionEnd, }; }, - [pageNumber, selectedBlocks, locationParams, latestHandleAddNote], + [pageNumber, selectedBlocks, locationParams, addNote, handleClearSelection, triggerFilteredNoteAlert], ); const locationRef = useRef({ locationParams, setLocationParams }); @@ -133,44 +146,65 @@ function Notes() { }, [readyAndLoaded]); return ( -
- {locationParams.selectionEnd && - locationParams.selectionStart && - pageNumber !== null && - selectedBlocks.length > 0 && - !isActiveNotes && - (readyAndLoaded || areSelectionsEqual(locationParams, keepShowingNewNote.current)) && ( - - )} - - {!readyAndLoaded && notes.length === 0 && ( - <> - - - - - + <> + {noteAlertVisibilityState !== "hidden" && ( + + + {noteAlertVisibilityState === "visibleForUpdated" + ? "Note hidden after update" + : "Note hidden by label filter"} + +
+ + {noteAlertVisibilityState === "visibleForUpdated" + ? "Updated note doesn't match active labels." + : "Created note doesn't match active labels."} + + +
+
)} +
+ {locationParams.selectionEnd && + locationParams.selectionStart && + pageNumber !== null && + selectedBlocks.length > 0 && + !isActiveNotes && + (readyAndLoaded || areSelectionsEqual(locationParams, keepShowingNewNote.current)) && ( + + )} + + {!readyAndLoaded && notes.length === 0 && ( + <> + + + + + + )} - {readyAndLoaded && notes.length === 0 && ( -
No notes available
- )} + {readyAndLoaded && notes.length === 0 && ( +
No notes available
+ )} - {notes.length > 0 && ( - - )} -
+ {notes.length > 0 && ( + + )} +
+ ); } diff --git a/src/components/NoteManager/components/Note.tsx b/src/components/NoteManager/components/Note.tsx index e9cfba98..20c6146a 100644 --- a/src/components/NoteManager/components/Note.tsx +++ b/src/components/NoteManager/components/Note.tsx @@ -22,7 +22,7 @@ type NoteProps = { note: IDecoratedNote; sectionTitles: { sectionTitle: string; subSectionTitle: string }; active: boolean; - onEditNote: INotesContext["handleUpdateNote"]; + onEditNote(noteToReplace: IDecoratedNote, newNote: IStorageNote): void; onDeleteNote: INotesContext["handleDeleteNote"]; onSelectNote: (note: IDecoratedNote, opts: { type: "currentVersion" | "originalVersion" | "close" }) => void; }; diff --git a/src/components/NoteManager/components/NoteContext.tsx b/src/components/NoteManager/components/NoteContext.tsx index 76807cdc..965b1e76 100644 --- a/src/components/NoteManager/components/NoteContext.tsx +++ b/src/components/NoteManager/components/NoteContext.tsx @@ -1,5 +1,4 @@ import { createContext, useContext } from "react"; -import type { INotesContext } from "../../NotesProvider/NotesProvider"; import type { IDecoratedNote } from "../../NotesProvider/types/DecoratedNote"; import type { IStorageNote } from "../../NotesProvider/types/StorageNote"; @@ -13,7 +12,7 @@ export type ISingleNoteContext = { handleCancelClick: () => void; handleNoteLabelsChange: (labels: string[]) => void; noteDirty: IStorageNote; - onEditNote: INotesContext["handleUpdateNote"]; + onEditNote: (noteToReplace: IDecoratedNote, newNote: IStorageNote) => void; isEditing: boolean; noteOriginalVersionShort: string | undefined; originalVersionLink: string | undefined; diff --git a/src/components/NoteManager/hooks/useFilteredNoteAlert.ts b/src/components/NoteManager/hooks/useFilteredNoteAlert.ts new file mode 100644 index 00000000..75fc1fa1 --- /dev/null +++ b/src/components/NoteManager/hooks/useFilteredNoteAlert.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +const FILTERED_NOTE_ALERT_MS = 6_000; + +export const useFilteredNoteAlert = () => { + const [noteAlertVisibilityState, setNoteAlertVisibilityState] = useState< + "hidden" | "visibleForCreated" | "visibleForUpdated" + >("hidden"); + const alertTimeoutRef = useRef(null); + + const triggerFilteredNoteAlert = useCallback((type: "visibleForCreated" | "visibleForUpdated") => { + setNoteAlertVisibilityState(type); + if (alertTimeoutRef.current) { + window.clearTimeout(alertTimeoutRef.current); + } + alertTimeoutRef.current = window.setTimeout(() => { + setNoteAlertVisibilityState("hidden"); + }, FILTERED_NOTE_ALERT_MS); + }, []); + + useEffect(() => { + return () => { + if (alertTimeoutRef.current) { + window.clearTimeout(alertTimeoutRef.current); + } + }; + }, []); + + const closeNoteAlert = useCallback(() => { + setNoteAlertVisibilityState("hidden"); + if (alertTimeoutRef.current) { + window.clearTimeout(alertTimeoutRef.current); + } + }, []); + + return { noteAlertVisibilityState, triggerFilteredNoteAlert, closeNoteAlert }; +}; diff --git a/src/components/NoteManager/useNoteManagerNotes.ts b/src/components/NoteManager/useNoteManagerNotes.ts index 71e9d685..a97d48bb 100644 --- a/src/components/NoteManager/useNoteManagerNotes.ts +++ b/src/components/NoteManager/useNoteManagerNotes.ts @@ -27,10 +27,19 @@ export const useNoteManagerNotes = () => { const latestDeleteNote = useLatestCallback(handleDeleteNote); const latestUpdateNote = useLatestCallback(handleUpdateNote); + const addNote = useCallback( + (noteToAdd) => { + const { isVisible } = latestHandleAddNote.current(noteToAdd); + + return { isVisible }; + }, + [latestHandleAddNote], + ); + const updateNote = useCallback( (noteToReplace, newNote) => { metadataCacheByKey.current.delete(noteToReplace.key); - latestUpdateNote.current(noteToReplace, newNote); + return latestUpdateNote.current(noteToReplace, newNote); }, [latestUpdateNote], ); @@ -106,7 +115,7 @@ export const useNoteManagerNotes = () => { sectionTitlesLoaded, notes, notesManagerNotes, - latestHandleAddNote, + addNote, deleteNote, updateNote, }; diff --git a/src/components/NotesProvider/NotesProvider.tsx b/src/components/NotesProvider/NotesProvider.tsx index 8e8b2562..126de153 100644 --- a/src/components/NotesProvider/NotesProvider.tsx +++ b/src/components/NotesProvider/NotesProvider.tsx @@ -28,8 +28,8 @@ export interface INotesContext { canRedo: boolean; remoteSources: IRemoteSource[]; handleSetRemoteSources(r: IRemoteSource, remove?: true): void; - handleAddNote(note: IStorageNote): void; - handleUpdateNote(noteToReplace: IDecoratedNote, newNote: IStorageNote): void; + handleAddNote(note: IStorageNote): { isVisible: boolean }; + handleUpdateNote(noteToReplace: IDecoratedNote, newNote: IStorageNote): { isVisible: boolean }; handleDeleteNote(note: IDecoratedNote): void; handleUndo(): void; handleRedo(): void; @@ -105,7 +105,7 @@ export function NotesProvider({ children }: INotesProviderProps) { const allNotesReady = useMemo(() => localNotesReady && remoteNotesReady, [localNotesReady, remoteNotesReady]); - const { filteredNotes, labels, toggleLabel: handleToggleLabel } = useLabels(allNotes); + const { filteredNotes, labels, toggleLabel: handleToggleLabel, isVisibleByActiveLabelsLatest } = useLabels(allNotes); const handleSetRemoteSources = useCallback((newSource: IRemoteSource, remove?: true) => { setRemoteSources((prevRemoteSources) => { @@ -148,27 +148,37 @@ export function NotesProvider({ children }: INotesProviderProps) { handleSetRemoteSources, handleToggleLabel, handleAddNote: useCallback( - (note) => + (note) => { + const isVisible = isVisibleByActiveLabelsLatest.current(note); + updateLocalNotes(localNotes, { ...localNotes, notes: [note, ...localNotes.notes], - }), - [localNotes, updateLocalNotes], + }); + + return { isVisible }; + }, + [localNotes, updateLocalNotes, isVisibleByActiveLabelsLatest], ), handleUpdateNote: useCallback( (noteToReplace, newNote) => { if (noteToReplace.source === NoteSource.Remote) { console.warn("Refusing to edit remote note.", noteToReplace); - return; + return { isVisible: true }; } + + const isVisible = isVisibleByActiveLabelsLatest.current(newNote); + const updateIdx = localNotesDecorated.indexOf(noteToReplace); const newNotes = localNotes.notes.map((note, idx) => (updateIdx === idx ? newNote : note)); updateLocalNotes(localNotes, { ...localNotes, notes: newNotes, }); + + return { isVisible }; }, - [localNotes, localNotesDecorated, updateLocalNotes], + [localNotes, localNotesDecorated, updateLocalNotes, isVisibleByActiveLabelsLatest], ), handleDeleteNote: useCallback( (noteToDelete) => { diff --git a/src/components/NotesProvider/hooks/useLabels.ts b/src/components/NotesProvider/hooks/useLabels.ts index 2b0efb0e..5abd4761 100644 --- a/src/components/NotesProvider/hooks/useLabels.ts +++ b/src/components/NotesProvider/hooks/useLabels.ts @@ -1,5 +1,6 @@ import type { UnPrefixedLabel } from "@fluffylabs/links-metadata"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { type RefObject, useCallback, useEffect, useMemo, useState } from "react"; +import { useLatestCallback } from "../../../hooks/useLatestCallback"; import { LABEL_LOCAL, LABEL_REMOTE } from "../consts/labels"; import { type IDecoratedNote, NoteSource, isDecoratedNote } from "../types/DecoratedNote"; import type { IStorageNote } from "../types/StorageNote"; @@ -119,6 +120,7 @@ export function useLabels(allNotes: IDecoratedNote[]): { filteredNotes: IDecoratedNote[]; labels: ILabelTreeNode[]; toggleLabel: (label: ILabelTreeNode) => void; + isVisibleByActiveLabelsLatest: RefObject<(note: IDecoratedNote | IStorageNote) => boolean>; } { const [storageLabels, setStorageLabels] = useState([]); const [labels, setLabels] = useState(initialEmptyArray as ILabelTreeNode[]); @@ -226,12 +228,20 @@ export function useLabels(allNotes: IDecoratedNote[]): { }); }, [allNotes, storageActivity]); + const activeLabels = useMemo(() => { + return labels.filter((label) => label.isActive).map((label) => label.prefixedLabel); + }, [labels]); + // filter notes when labels are changing const filteredNotes = useMemo(() => { - const activeLabels = labels.filter((label) => label.isActive).map((label) => label.prefixedLabel); // filter out notes return getFilteredNotes(allNotes, activeLabels); - }, [allNotes, labels]); + }, [allNotes, activeLabels]); + + const isVisibleByActiveLabelsLatest = useLatestCallback((note: IStorageNote | IDecoratedNote) => { + const filteringResult = getFilteredNotes([note], activeLabels); + return filteringResult.length > 0; + }); - return { filteredNotes, labels, toggleLabel }; + return { filteredNotes, labels, toggleLabel, isVisibleByActiveLabelsLatest }; }