From 2c1fa7ae2541eb173a2f15fe8257ffd8eb494526 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 28 Apr 2026 13:04:57 +0200 Subject: [PATCH 01/67] feat: add pinned field to note --- .../app/src/core/database/sqlite/migrations/index.ts | 4 ++++ .../sqlite/migrations/sql/2_add_notes_pinned.sql | 2 ++ .../core/features/notes/controller/NotesController.ts | 9 ++++++++- packages/app/src/core/features/notes/controller/index.ts | 1 + 4 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/core/database/sqlite/migrations/sql/2_add_notes_pinned.sql diff --git a/packages/app/src/core/database/sqlite/migrations/index.ts b/packages/app/src/core/database/sqlite/migrations/index.ts index bd9bb52c3..d2bf65bca 100644 --- a/packages/app/src/core/database/sqlite/migrations/index.ts +++ b/packages/app/src/core/database/sqlite/migrations/index.ts @@ -33,4 +33,8 @@ export async function convertSQLToMigrationObject( export const getMigrationsList = async (): Promise => Promise.all([ convertSQLToMigrationObject('1_init_db', import('./sql/1_init_db.sql')), + convertSQLToMigrationObject( + '2_add_notes_pinned', + import('./sql/2_add_notes_pinned.sql'), + ), ]); diff --git a/packages/app/src/core/database/sqlite/migrations/sql/2_add_notes_pinned.sql b/packages/app/src/core/database/sqlite/migrations/sql/2_add_notes_pinned.sql new file mode 100644 index 000000000..19a3f988e --- /dev/null +++ b/packages/app/src/core/database/sqlite/migrations/sql/2_add_notes_pinned.sql @@ -0,0 +1,2 @@ +ALTER TABLE notes +ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/packages/app/src/core/features/notes/controller/NotesController.ts b/packages/app/src/core/features/notes/controller/NotesController.ts index 69433b379..9febb43fa 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.ts @@ -29,6 +29,7 @@ const RowScheme = z deleted_at: z.coerce.date().nullable(), archived: z.coerce.boolean(), bookmarked: z.coerce.boolean(), + pinned: z.coerce.boolean(), }) .transform( ({ @@ -41,6 +42,7 @@ const RowScheme = z visible, deleted_at, archived, + pinned, bookmarked, }): INote => ({ id, @@ -52,6 +54,7 @@ const RowScheme = z deletedAt: deleted_at?.getTime(), isArchived: archived, isBookmarked: bookmarked, + isPinned: pinned, content: { title, text }, }), ); @@ -80,6 +83,9 @@ function formatNoteMeta(meta: Partial) { case 'isBookmarked': fields['bookmarked'] = Number(value); break; + case 'isPinned': + fields['pinned'] = Number(value); + break; } } @@ -162,6 +168,7 @@ function getFetchQuery( } // Sort + orderQuery.push(qb.line('pinned DESC')); if (sort) { orderQuery.push( qb.line(sortFieldMap[sort.by], sort.order === 'desc' ? 'DESC' : 'ASC'), @@ -190,7 +197,7 @@ function getFetchQuery( ), ), ), - orderQuery.length > 0 ? qb.sql`ORDER BY ${qb.set(orderQuery)}` : undefined, + qb.sql`ORDER BY ${qb.set(orderQuery)}`, limit ? qb.limit(limit) : undefined, page && limit ? qb.offset((page - 1) * limit) : undefined, ); diff --git a/packages/app/src/core/features/notes/controller/index.ts b/packages/app/src/core/features/notes/controller/index.ts index cef2d9310..49bbfee4e 100644 --- a/packages/app/src/core/features/notes/controller/index.ts +++ b/packages/app/src/core/features/notes/controller/index.ts @@ -6,6 +6,7 @@ export type NoteMeta = { isDeleted: boolean; isArchived: boolean; isBookmarked: boolean; + isPinned: boolean; }; export type NoteSortField = 'id' | 'createdAt' | 'updatedAt' | 'deletedAt'; From 85a1253d278b9dded609d7d070aa9dd4a8e5dc21 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 28 Apr 2026 13:50:01 +0200 Subject: [PATCH 02/67] feat: implement pin and unpin --- .../app/src/core/features/telemetry/index.ts | 1 + packages/app/src/hooks/commands/index.ts | 2 ++ .../src/hooks/notes/useNoteCommandHandlers.ts | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/packages/app/src/core/features/telemetry/index.ts b/packages/app/src/core/features/telemetry/index.ts index f533bdbf0..aeafc74a1 100644 --- a/packages/app/src/core/features/telemetry/index.ts +++ b/packages/app/src/core/features/telemetry/index.ts @@ -28,6 +28,7 @@ export enum TELEMETRY_EVENT_NAME { NOTE_ARCHIVE_TOGGLE = 'Note archive toggled', NOTE_BOOKMARK_TOGGLE = 'Note bookmark toggled', + NOTE_PIN_TOGGLE = 'Note pin toggled', NOTE_TAG_ATTACHED = 'Tag attached', NOTE_TAG_DETACHED = 'Tag detached', diff --git a/packages/app/src/hooks/commands/index.ts b/packages/app/src/hooks/commands/index.ts index fae4c8225..dc160aaf7 100644 --- a/packages/app/src/hooks/commands/index.ts +++ b/packages/app/src/hooks/commands/index.ts @@ -27,6 +27,7 @@ export enum GLOBAL_COMMANDS { RESTORE_NOTE_FROM_BIN = 'Restore note from bin', TOGGLE_NOTE_ARCHIVE = 'Toggle note archive status', TOGGLE_NOTE_BOOKMARK = 'Toggle note bookmark status', + TOGGLE_NOTE_PIN = 'Toggle note pin status', /** * Open and close the history for a note @@ -55,6 +56,7 @@ type CommandsWithPayload = { [GLOBAL_COMMANDS.TOGGLE_NOTE_ARCHIVE]: { noteId: string }; [GLOBAL_COMMANDS.TOGGLE_NOTE_BOOKMARK]: { noteId: string }; + [GLOBAL_COMMANDS.TOGGLE_NOTE_PIN]: { noteId: string }; [GLOBAL_COMMANDS.TOGGLE_NOTE_HISTORY_PANEL]: { noteId: string }; [GLOBAL_COMMANDS.EXPORT_NOTE]: { noteId: string }; diff --git a/packages/app/src/hooks/notes/useNoteCommandHandlers.ts b/packages/app/src/hooks/notes/useNoteCommandHandlers.ts index 5e77fe54c..3ae822bda 100644 --- a/packages/app/src/hooks/notes/useNoteCommandHandlers.ts +++ b/packages/app/src/hooks/notes/useNoteCommandHandlers.ts @@ -181,4 +181,22 @@ export const useNoteCommandHandlers = () => { }); }, ); + + useWorkspaceCommandCallback(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, async ({ noteId }) => { + const [note] = await notes.getById([noteId]); + if (!note) { + console.warn(`Not found note with id ${noteId}`); + return; + } + + const newPinnedState = !note.isPinned; + await notes.updateMeta([noteId], { + isPinned: newPinnedState, + }); + eventBus.emit(WorkspaceEvents.NOTE_UPDATED, noteId); + + telemetry.track(TELEMETRY_EVENT_NAME.NOTE_PIN_TOGGLE, { + action: newPinnedState ? 'Pinned' : 'Unpinned', + }); + }); }; From b5eda295d022972c4ff3fcf64c6d4ff21261ad6a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 28 Apr 2026 14:03:26 +0200 Subject: [PATCH 03/67] refactor: move text to local file --- packages/app/src/locales/en/features.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/locales/en/features.json b/packages/app/src/locales/en/features.json index 92656b1f0..829e44799 100644 --- a/packages/app/src/locales/en/features.json +++ b/packages/app/src/locales/en/features.json @@ -92,7 +92,9 @@ "addToFavorites": "Add to favorites", "removeFromFavorites": "Remove from favorites", "moveToArchive": "Move to archive", - "removeFromArchive": "Remove from archive" + "removeFromArchive": "Remove from archive", + "pinNote": "Pin note", + "unpinNote": "Unpin note" }, "tags": { "addPlaceholder": "Type to add tag…" From 819eaebb18c95872c0d8b1cad7ee8c4b601ddd82 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 28 Apr 2026 17:21:13 +0200 Subject: [PATCH 04/67] refactor: improve style --- .../components/NotePreview/NotePreview.tsx | 89 +++++++++---------- .../MainScreen/NotesListPanel/NotesList.tsx | 14 +++ 2 files changed, 56 insertions(+), 47 deletions(-) diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index d2344cdcb..3c7394543 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -19,16 +19,29 @@ export const NotePreviewContent = memo( title: string; text: string; meta?: NotePreviewMeta; + pin: { + title: string; + isActive: boolean; + onToggle: () => void; + }; textToHighlight?: string; - }) => { - const recipe = useSlotRecipe({ key: 'notePreview' }); - const styles = recipe(); - const localizedDate = useLocalizedDate(); - - return ( - <> - - + } & StackProps +>(({ title, text, textToHighlight, meta, isSelected, pin, ...props }, ref) => { + const styles = useMultiStyleConfig('NotePreview'); + return ( + + + + {text.length > 0 ? ( - + ) : undefined} - - - {meta && meta.updatedAt !== undefined && ( - - {localizedDate(new Date(meta.updatedAt))} - - )} - - ); - }, - isEqual, -); + -NotePreviewContent.displayName = 'NotePreviewContent'; + + -export const NotePreview = forwardRef< - HTMLDivElement, - { - title: string; - text: string; - meta?: NotePreviewMeta; - isSelected?: boolean; - textToHighlight?: string; - } & StackProps ->(({ title, text, textToHighlight, meta, isSelected, ...props }, ref) => { - const recipe = useSlotRecipe({ key: 'notePreview' }); - const styles = recipe(); - - const style = useMemo( - () => ({ - ...styles.root, - }), - [styles.root], - ); - - return ( - - - + {meta && {meta}} + ); }); diff --git a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx index fa4888173..641051ef7 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx @@ -9,6 +9,8 @@ import { TELEMETRY_EVENT_NAME } from '@core/features/telemetry'; import { getContextMenuCoords } from '@electron/requests/contextMenu/renderer'; import { useNoteContextMenu } from '@features/NotesContainer/NoteContextMenu/useNoteContextMenu'; import { useTelemetryTracker } from '@features/telemetry'; +import { GLOBAL_COMMANDS } from '@hooks/commands'; +import { useCommand } from '@hooks/commands/useCommand'; import { useCreateNote } from '@hooks/notes/useCreateNote'; import { useNoteActions } from '@hooks/notes/useNoteActions'; import { useEstimateVirtualItemSize } from '@hooks/useEstimateVirtualItemSize'; @@ -34,6 +36,7 @@ export type NotesListProps = {}; export const NotesList: FC = () => { const { t } = useTranslation(LOCALE_NAMESPACE.features); const telemetry = useTelemetryTracker(); + const runCommand = useCommand(); const createNote = useCreateNote(); @@ -201,6 +204,17 @@ export const NotesList: FC = () => { }, ); }} + pin={{ + isActive: Boolean(note.isPinned), + title: note.isPinned + ? t('note.actions.unpinNote') + : t('note.actions.pinNote'), + onToggle: () => { + runCommand(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, { + noteId: note.id, + }); + }, + }} onDoubleClick={() => { // Convert preview tab to regular noteActions.click(note.id, { preview: false }); From 82a39fafbbdd1ff6603dee3c7a42418501c43e2f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 28 Apr 2026 22:57:03 +0200 Subject: [PATCH 05/67] chore: rename --- packages/app/src/core/database/sqlite/migrations/index.ts | 4 ++-- .../sql/{2_add_notes_pinned.sql => 2_add_pinned_to_notes.sql} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename packages/app/src/core/database/sqlite/migrations/sql/{2_add_notes_pinned.sql => 2_add_pinned_to_notes.sql} (100%) diff --git a/packages/app/src/core/database/sqlite/migrations/index.ts b/packages/app/src/core/database/sqlite/migrations/index.ts index d2bf65bca..c78a15d2e 100644 --- a/packages/app/src/core/database/sqlite/migrations/index.ts +++ b/packages/app/src/core/database/sqlite/migrations/index.ts @@ -34,7 +34,7 @@ export const getMigrationsList = async (): Promise => Promise.all([ convertSQLToMigrationObject('1_init_db', import('./sql/1_init_db.sql')), convertSQLToMigrationObject( - '2_add_notes_pinned', - import('./sql/2_add_notes_pinned.sql'), + '2_add_pinned_to_notes', + import('./sql/2_add_pinned_to_notes.sql'), ), ]); diff --git a/packages/app/src/core/database/sqlite/migrations/sql/2_add_notes_pinned.sql b/packages/app/src/core/database/sqlite/migrations/sql/2_add_pinned_to_notes.sql similarity index 100% rename from packages/app/src/core/database/sqlite/migrations/sql/2_add_notes_pinned.sql rename to packages/app/src/core/database/sqlite/migrations/sql/2_add_pinned_to_notes.sql From 42a3fc262eb751817dbb55b9c7387f95a3e18f75 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 28 Apr 2026 23:28:22 +0200 Subject: [PATCH 06/67] refactor: handle toggle separate of open click --- packages/app/src/components/NotePreview/NotePreview.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index 3c7394543..ed5079979 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -62,7 +62,10 @@ export const NotePreviewContent = memo( { + evt.stopPropagation(); + pin.onToggle(); + }} title={pin.title} boxSize="13px" opacity={pin.isActive ? 1 : 0} From 3fee3b6bcd1102c584aff3893e63b378238c93da Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 29 Apr 2026 22:12:49 +0200 Subject: [PATCH 07/67] refactor: use button instead icon --- .../components/NotePreview/NotePreview.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index ed5079979..372b44535 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -60,24 +60,24 @@ export const NotePreviewContent = memo( ) : undefined} - { - evt.stopPropagation(); - pin.onToggle(); - }} + {meta && {meta}} From 449775ae76477ca4b41713621f9a9c9758ad0582 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 30 Apr 2026 12:59:24 +0200 Subject: [PATCH 08/67] refactor: use visibility property --- packages/app/src/components/NotePreview/NotePreview.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index 372b44535..fb9113f6f 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -66,10 +66,10 @@ export const NotePreviewContent = memo( alignSelf="baseline" aria-label={pin.title} title={pin.title} - color={pin.isActive ? 'typography.base' : 'typography.secondary'} - opacity={pin.isActive ? 1 : 0} + color={pin.isActive ? undefined : 'typography.secondary'} + visibility={pin.isActive ? 'visible' : 'hidden'} _groupHover={{ - opacity: 1, + visibility: 'visible', }} onClick={(evt) => { evt.stopPropagation(); From 98c3916dc9cf1fe1ea258ab4c0e451a53cbc63c8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 30 Apr 2026 13:22:09 +0200 Subject: [PATCH 09/67] fix: handle keyboard focus --- packages/app/src/components/NotePreview/NotePreview.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index fb9113f6f..6eb5dddec 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -71,6 +71,12 @@ export const NotePreviewContent = memo( _groupHover={{ visibility: 'visible', }} + _groupFocusWithin={{ + visibility: 'visible', + }} + _focus={{ + visibility: 'visible', + }} onClick={(evt) => { evt.stopPropagation(); pin.onToggle(); From e2408d5a7c2113395a881b8dbc38b8841f886fd5 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 6 May 2026 13:24:25 +0200 Subject: [PATCH 10/67] chore: add pin to menu, remove from note preview --- .../components/NotePreview/NotePreview.tsx | 39 +++++++------------ .../MainScreen/NotesListPanel/NotesList.tsx | 15 +------ .../NotesContainer/NoteContextMenu/index.ts | 1 + .../NoteContextMenu/useNoteContextMenu.ts | 14 +++++++ packages/app/src/locales/en/menu.json | 2 + 5 files changed, 32 insertions(+), 39 deletions(-) diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index 6eb5dddec..eccfd763d 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -24,9 +24,12 @@ export const NotePreviewContent = memo( isActive: boolean; onToggle: () => void; }; + meta?: ReactNode; + isSelected?: boolean; + isPinned: boolean; textToHighlight?: string; } & StackProps ->(({ title, text, textToHighlight, meta, isSelected, pin, ...props }, ref) => { +>(({ title, text, textToHighlight, meta, isSelected, isPinned, ...props }, ref) => { const styles = useMultiStyleConfig('NotePreview'); return ( - + {isPinned && ( + + + + )} {meta && {meta}} diff --git a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx index 641051ef7..7900fb7da 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx @@ -9,8 +9,6 @@ import { TELEMETRY_EVENT_NAME } from '@core/features/telemetry'; import { getContextMenuCoords } from '@electron/requests/contextMenu/renderer'; import { useNoteContextMenu } from '@features/NotesContainer/NoteContextMenu/useNoteContextMenu'; import { useTelemetryTracker } from '@features/telemetry'; -import { GLOBAL_COMMANDS } from '@hooks/commands'; -import { useCommand } from '@hooks/commands/useCommand'; import { useCreateNote } from '@hooks/notes/useCreateNote'; import { useNoteActions } from '@hooks/notes/useNoteActions'; import { useEstimateVirtualItemSize } from '@hooks/useEstimateVirtualItemSize'; @@ -36,7 +34,6 @@ export type NotesListProps = {}; export const NotesList: FC = () => { const { t } = useTranslation(LOCALE_NAMESPACE.features); const telemetry = useTelemetryTracker(); - const runCommand = useCommand(); const createNote = useCreateNote(); @@ -204,17 +201,7 @@ export const NotesList: FC = () => { }, ); }} - pin={{ - isActive: Boolean(note.isPinned), - title: note.isPinned - ? t('note.actions.unpinNote') - : t('note.actions.pinNote'), - onToggle: () => { - runCommand(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, { - noteId: note.id, - }); - }, - }} + isPinned={Boolean(note.isPinned)} onDoubleClick={() => { // Convert preview tab to regular noteActions.click(note.id, { preview: false }); diff --git a/packages/app/src/features/NotesContainer/NoteContextMenu/index.ts b/packages/app/src/features/NotesContainer/NoteContextMenu/index.ts index e4ec07048..f485eb267 100644 --- a/packages/app/src/features/NotesContainer/NoteContextMenu/index.ts +++ b/packages/app/src/features/NotesContainer/NoteContextMenu/index.ts @@ -5,6 +5,7 @@ export enum NoteActions { DELETE_TO_BIN = 'deleteToBin', DELETE_PERMANENTLY = 'deletePermanently', RESTORE_FROM_BIN = 'restoreFromBin', + PIN_NOTE = 'pinNote', CLOSE = 'CLOSE', CLOSE_ALL = 'CLOSE_ALL', diff --git a/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts b/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts index afa72795c..c9e9795e8 100644 --- a/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts +++ b/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts @@ -48,6 +48,10 @@ export const useNoteContextMenu = (context?: 'tabs' | 'notes-list') => { runCommand(GLOBAL_COMMANDS.RESTORE_NOTE_FROM_BIN, { noteId }); }, + [NoteActions.PIN_NOTE]: (noteId: string) => { + runCommand(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, { noteId }); + }, + [NoteActions.DUPLICATE]: (noteId: string) => { runCommand(GLOBAL_COMMANDS.DUPLICATE_NOTE, { noteId }); }, @@ -126,6 +130,16 @@ export const useNoteContextMenu = (context?: 'tabs' | 'notes-list') => { ? [{ id: NoteActions.DUPLICATE, label: t('duplicate') }] : []), + note.isPinned + ? { + id: NoteActions.PIN_NOTE, + label: t('unpinNote'), + } + : { + id: NoteActions.PIN_NOTE, + label: t('pinNote'), + }, + { id: NoteActions.EXPORT, label: t('export'), diff --git a/packages/app/src/locales/en/menu.json b/packages/app/src/locales/en/menu.json index f7b33bbea..1878e3f4f 100644 --- a/packages/app/src/locales/en/menu.json +++ b/packages/app/src/locales/en/menu.json @@ -24,6 +24,8 @@ "deletePermanently": "Delete permanently", "restoreFromBin": "Restore from bin", "deleteToBin": "Move to bin", + "pinNote": "Pin note", + "unpinNote": "Unpin note", "tab": { "close": "Close", "closeOthers": "Close Others", From f1501ec752fbe7bcb80ead1009e9a82948d651b3 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 6 May 2026 13:44:32 +0200 Subject: [PATCH 11/67] feat: add pin to note menu --- .../app/src/features/NoteEditor/NoteMenu.tsx | 25 ++++++++++++++++++- packages/app/src/locales/en/features.json | 4 ++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/app/src/features/NoteEditor/NoteMenu.tsx b/packages/app/src/features/NoteEditor/NoteMenu.tsx index e48dbf5dc..67166dd96 100644 --- a/packages/app/src/features/NoteEditor/NoteMenu.tsx +++ b/packages/app/src/features/NoteEditor/NoteMenu.tsx @@ -6,11 +6,12 @@ import { FaCopy, FaEllipsis, FaFileExport, + FaThumbtack, FaTrashCan, FaTrashCanArrowUp, } from 'react-icons/fa6'; import { LOCALE_NAMESPACE } from 'src/i18n'; -import { Button, HStack, Menu, Portal, Text } from '@chakra-ui/react'; +import { Box, Button, HStack, Menu, Portal, Text } from '@chakra-ui/react'; import { INote } from '@core/features/notes'; import { GLOBAL_COMMANDS } from '@hooks/commands'; import { useCommand } from '@hooks/commands/useCommand'; @@ -41,6 +42,28 @@ export const NoteMenu = memo(({ note }: { note: INote }) => { + + runCommand( + GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, + { + noteId: note.id, + }, + ) + } + value="togglePinNote" + > + + + + + + {note.isPinned + ? t('note.menu.unpinNote') + : t('note.menu.pinNote')} + + + diff --git a/packages/app/src/locales/en/features.json b/packages/app/src/locales/en/features.json index 829e44799..a22e71542 100644 --- a/packages/app/src/locales/en/features.json +++ b/packages/app/src/locales/en/features.json @@ -118,7 +118,9 @@ "removeFromArchive": "Remove from archive", "deleteToBin": "Delete to bin", "deletePermanently": "Delete permanently", - "restoreFromBin": "Restore from bin" + "restoreFromBin": "Restore from bin", + "pinNote": "Pin note", + "unpinNote": "Unpin note" }, "version": { "chars": "{{count}} characters", From eb767189a382b1d86431616e0a7368678fd6302d Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 6 May 2026 14:48:07 +0200 Subject: [PATCH 12/67] refactor: scroll to pinned by if note pinned by context menu --- .../NotesListPanel/useScrollToActiveNote.tsx | 11 +++++++++++ .../NoteContextMenu/useNoteContextMenu.ts | 1 + packages/app/src/hooks/commands/index.ts | 4 ++++ 3 files changed, 16 insertions(+) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx index 07821e240..096525c4e 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useRef } from 'react'; import { WorkspaceEvents } from '@api/events/workspace'; import { NoteId } from '@core/features/notes'; import { useEventBus } from '@features/App/Workspace/WorkspaceProvider'; +import { GLOBAL_COMMANDS } from '@hooks/commands'; +import { useWorkspaceCommandCallback } from '@hooks/commands/useWorkspaceCommandCallback'; import { useImmutableCallback } from '@hooks/useImmutableCallback'; import { useWorkspaceSelector } from '@state/redux/vaults/hooks'; import { selectNotesView } from '@state/redux/vaults/selectors/view'; @@ -40,6 +42,15 @@ export const useScrollToActiveNote = ({ virtualizer.scrollToOffset(0); }); + useWorkspaceCommandCallback(GLOBAL_COMMANDS.SCROLL_TO_NOTE, async ({ noteId }) => { + const index = noteIds.indexOf(noteId); + if (index === -1) return; + + virtualizer.scrollToIndex(index, { + align: scrollAlignment, + }); + }); + const isScrollFixNeededRef = useRef(false); const scrollToActiveNote = useImmutableCallback(() => { if (!activeNoteId) return; diff --git a/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts b/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts index c9e9795e8..673d5b357 100644 --- a/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts +++ b/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts @@ -50,6 +50,7 @@ export const useNoteContextMenu = (context?: 'tabs' | 'notes-list') => { [NoteActions.PIN_NOTE]: (noteId: string) => { runCommand(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, { noteId }); + runCommand(GLOBAL_COMMANDS.SCROLL_TO_NOTE, { noteId }); }, [NoteActions.DUPLICATE]: (noteId: string) => { diff --git a/packages/app/src/hooks/commands/index.ts b/packages/app/src/hooks/commands/index.ts index dc160aaf7..a2fdf7089 100644 --- a/packages/app/src/hooks/commands/index.ts +++ b/packages/app/src/hooks/commands/index.ts @@ -47,6 +47,8 @@ export enum GLOBAL_COMMANDS { FOCUS_SEARCH = 'Focus search input', SHORTCUT_PRESSED = 'Shortcut pressed', + + SCROLL_TO_NOTE = 'Scroll to note', } type CommandsWithPayload = { @@ -64,6 +66,8 @@ type CommandsWithPayload = { [GLOBAL_COMMANDS.DUPLICATE_NOTE]: { noteId: string }; [GLOBAL_COMMANDS.SHORTCUT_PRESSED]: { shortcut: Shortcuts }; + + [GLOBAL_COMMANDS.SCROLL_TO_NOTE]: { noteId: string }; }; export type CommandPayloadsMap = { From 0df32256a35a6b4d31d386ba5d00349ddea123f7 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 6 May 2026 17:27:45 +0200 Subject: [PATCH 13/67] fix: scroll only after list ids was updated --- .../NotesListPanel/useScrollToActiveNote.tsx | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx index 096525c4e..cdf773251 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx @@ -42,15 +42,6 @@ export const useScrollToActiveNote = ({ virtualizer.scrollToOffset(0); }); - useWorkspaceCommandCallback(GLOBAL_COMMANDS.SCROLL_TO_NOTE, async ({ noteId }) => { - const index = noteIds.indexOf(noteId); - if (index === -1) return; - - virtualizer.scrollToIndex(index, { - align: scrollAlignment, - }); - }); - const isScrollFixNeededRef = useRef(false); const scrollToActiveNote = useImmutableCallback(() => { if (!activeNoteId) return; @@ -127,4 +118,20 @@ export const useScrollToActiveNote = ({ wasActiveNoteInViewport.current = false; scrollToActiveNote(); }); + + // Handle command to set scroll + // Defer scroll until noteIds is updated, otherwise scroll happens before index changes + const scrollTargetNoteIdRef = useRef(null); + useWorkspaceCommandCallback(GLOBAL_COMMANDS.SCROLL_TO_NOTE, ({ noteId }) => { + scrollTargetNoteIdRef.current = noteId; + }); + useEffect(() => { + if (!scrollTargetNoteIdRef.current) return; + + const index = noteIds.indexOf(scrollTargetNoteIdRef.current); + if (index === -1) return; + + scrollTargetNoteIdRef.current = null; + virtualizer.scrollToIndex(index, { align: scrollAlignment }); + }, [noteIds, virtualizer]); }; From 915a9547a624a96b6c09a5706cf73a0e253b78ed Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 6 May 2026 21:16:51 +0200 Subject: [PATCH 14/67] refactor: not sort by pinned in bin and search mode --- .../src/core/features/notes/controller/NotesController.ts | 4 ++-- packages/app/src/core/features/notes/controller/index.ts | 1 + packages/app/src/hooks/notes/useUpdateNotes.ts | 6 +++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/app/src/core/features/notes/controller/NotesController.ts b/packages/app/src/core/features/notes/controller/NotesController.ts index 9febb43fa..65aa39aa7 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.ts @@ -167,9 +167,9 @@ function getFetchQuery( filterQuery.push(qb.sql`updated_at <= ${updatedAt.to.getTime()}`); } - // Sort - orderQuery.push(qb.line('pinned DESC')); if (sort) { + if (sort.pinned) orderQuery.push(qb.line('pinned DESC')); + orderQuery.push( qb.line(sortFieldMap[sort.by], sort.order === 'desc' ? 'DESC' : 'ASC'), ); diff --git a/packages/app/src/core/features/notes/controller/index.ts b/packages/app/src/core/features/notes/controller/index.ts index 49bbfee4e..0bcd3d85d 100644 --- a/packages/app/src/core/features/notes/controller/index.ts +++ b/packages/app/src/core/features/notes/controller/index.ts @@ -75,6 +75,7 @@ export type NotesControllerFetchOptions = { sort?: { by: NoteSortField; order?: 'desc' | 'asc'; + pinned?: boolean; }; }; diff --git a/packages/app/src/hooks/notes/useUpdateNotes.ts b/packages/app/src/hooks/notes/useUpdateNotes.ts index ae3fb84e6..254b02eb1 100644 --- a/packages/app/src/hooks/notes/useUpdateNotes.ts +++ b/packages/app/src/hooks/notes/useUpdateNotes.ts @@ -40,7 +40,11 @@ export const useUpdateNotes = () => { const noteIds = await notesRegistry.query({ tags, - sort: { by: 'updatedAt', order: 'desc' }, + sort: { + by: 'updatedAt', + order: 'desc', + pinned: notesView !== NOTES_VIEW.BIN && searchText.length === 0, + }, search: searchText ? { text: searchText, From 687d3388e678184d41f6fac2bdece6c28d862343 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 7 May 2026 13:20:25 +0200 Subject: [PATCH 15/67] chore: return conditional sorting --- .../app/src/core/features/notes/controller/NotesController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/core/features/notes/controller/NotesController.ts b/packages/app/src/core/features/notes/controller/NotesController.ts index 65aa39aa7..fa12a86ec 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.ts @@ -197,7 +197,7 @@ function getFetchQuery( ), ), ), - qb.sql`ORDER BY ${qb.set(orderQuery)}`, + orderQuery.length > 0 ? qb.sql`ORDER BY ${qb.set(orderQuery)}` : undefined, limit ? qb.limit(limit) : undefined, page && limit ? qb.offset((page - 1) * limit) : undefined, ); From ea0aed30e4c84bb217d82e8a6bb165307b366d8a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 7 May 2026 13:35:58 +0200 Subject: [PATCH 16/67] chore: rename --- .../app/src/core/features/notes/controller/NotesController.ts | 3 ++- packages/app/src/core/features/notes/controller/index.ts | 2 +- packages/app/src/hooks/notes/useUpdateNotes.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/app/src/core/features/notes/controller/NotesController.ts b/packages/app/src/core/features/notes/controller/NotesController.ts index fa12a86ec..a4aff8491 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.ts @@ -167,8 +167,9 @@ function getFetchQuery( filterQuery.push(qb.sql`updated_at <= ${updatedAt.to.getTime()}`); } + // Sort if (sort) { - if (sort.pinned) orderQuery.push(qb.line('pinned DESC')); + if (sort.pinnedFirst) orderQuery.push(qb.line('pinned DESC')); orderQuery.push( qb.line(sortFieldMap[sort.by], sort.order === 'desc' ? 'DESC' : 'ASC'), diff --git a/packages/app/src/core/features/notes/controller/index.ts b/packages/app/src/core/features/notes/controller/index.ts index 0bcd3d85d..19e03e9f7 100644 --- a/packages/app/src/core/features/notes/controller/index.ts +++ b/packages/app/src/core/features/notes/controller/index.ts @@ -75,7 +75,7 @@ export type NotesControllerFetchOptions = { sort?: { by: NoteSortField; order?: 'desc' | 'asc'; - pinned?: boolean; + pinnedFirst?: boolean; }; }; diff --git a/packages/app/src/hooks/notes/useUpdateNotes.ts b/packages/app/src/hooks/notes/useUpdateNotes.ts index 254b02eb1..0c6ec5090 100644 --- a/packages/app/src/hooks/notes/useUpdateNotes.ts +++ b/packages/app/src/hooks/notes/useUpdateNotes.ts @@ -43,7 +43,7 @@ export const useUpdateNotes = () => { sort: { by: 'updatedAt', order: 'desc', - pinned: notesView !== NOTES_VIEW.BIN && searchText.length === 0, + pinnedFirst: notesView !== NOTES_VIEW.BIN && searchText.length === 0, }, search: searchText ? { From 90dd39b4ac20c2d2fdfbbc280b56f889095c1103 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 8 May 2026 13:36:43 +0200 Subject: [PATCH 17/67] feat: add style for pinned note --- .../NotePreview/NotePreview.theme.ts | 6 + .../components/NotePreview/NotePreview.tsx | 104 ++++++++++-------- .../MainScreen/NotesListPanel/NotesList.tsx | 13 +++ 3 files changed, 79 insertions(+), 44 deletions(-) diff --git a/packages/app/src/components/NotePreview/NotePreview.theme.ts b/packages/app/src/components/NotePreview/NotePreview.theme.ts index d3a8f8541..9542341f4 100644 --- a/packages/app/src/components/NotePreview/NotePreview.theme.ts +++ b/packages/app/src/components/NotePreview/NotePreview.theme.ts @@ -50,6 +50,12 @@ export const notePreviewRecipe = defineSlotRecipe({ color: 'control.option.active.foreground', }, }, + + '&[data-flashing="true"]': { + boxShadow: + 'inset 4px 0 0 0 var(--chakra-colors-control-action-active-background)', + }, + transition: 'box-shadow 0.2s ease', }, }, }, diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index eccfd763d..7416bb37c 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -28,56 +28,72 @@ export const NotePreviewContent = memo( isSelected?: boolean; isPinned: boolean; textToHighlight?: string; + isFlashing?: boolean; } & StackProps ->(({ title, text, textToHighlight, meta, isSelected, isPinned, ...props }, ref) => { - const styles = useMultiStyleConfig('NotePreview'); - return ( - - - - - - - - {text.length > 0 ? ( - +>( + ( + { + title, + text, + textToHighlight, + meta, + isSelected, + isPinned, + isFlashing, + ...props + }, + ref, + ) => { + const styles = useMultiStyleConfig('NotePreview'); + return ( + + + + - ) : undefined} - - {isPinned && ( - - - - )} - + {text.length > 0 ? ( + + + + ) : undefined} + + + {isPinned && ( + + + + )} + - {meta && {meta}} - - ); -}); + {meta && {meta}} + + ); + }, +); NotePreview.displayName = 'NotePreview'; diff --git a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx index 7900fb7da..e64ba5657 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx @@ -4,11 +4,14 @@ import { FaPenToSquare } from 'react-icons/fa6'; import { LOCALE_NAMESPACE } from 'src/i18n'; import { Box, Button, Skeleton, Text, VStack } from '@chakra-ui/react'; import { NotePreview } from '@components/NotePreview/NotePreview'; +import { NoteId } from '@core/features/notes'; import { getNoteTitle } from '@core/features/notes/utils'; import { TELEMETRY_EVENT_NAME } from '@core/features/telemetry'; import { getContextMenuCoords } from '@electron/requests/contextMenu/renderer'; import { useNoteContextMenu } from '@features/NotesContainer/NoteContextMenu/useNoteContextMenu'; import { useTelemetryTracker } from '@features/telemetry'; +import { GLOBAL_COMMANDS } from '@hooks/commands'; +import { useWorkspaceCommandCallback } from '@hooks/commands/useWorkspaceCommandCallback'; import { useCreateNote } from '@hooks/notes/useCreateNote'; import { useNoteActions } from '@hooks/notes/useNoteActions'; import { useEstimateVirtualItemSize } from '@hooks/useEstimateVirtualItemSize'; @@ -80,6 +83,14 @@ export const NotesList: FC = () => { activeNoteRef, }); + const [flashingNoteId, setFlashingNoteId] = useState(null); + useWorkspaceCommandCallback(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, ({ noteId }) => { + setFlashingNoteId(noteId); + setTimeout(() => { + setFlashingNoteId(null); + }, 800); + }); + // TODO: implement dragging and moving items return ( = () => { {virtualNoteItems.map((virtualRow) => { const id = noteIds[virtualRow.index]; const isActive = id === activeNoteId; + const isFlashing = flashingNoteId === id; const note = notesData.get(id); if (!note) @@ -201,6 +213,7 @@ export const NotesList: FC = () => { }, ); }} + isFlashing={isFlashing} isPinned={Boolean(note.isPinned)} onDoubleClick={() => { // Convert preview tab to regular From e12bf9666a02f3e7344805a83e84b77651d866ab Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 8 May 2026 14:02:13 +0200 Subject: [PATCH 18/67] refactor: remove command --- .../MainScreen/NotesListPanel/NotesList.tsx | 7 ++++- .../NotesListPanel/useScrollToActiveNote.tsx | 2 +- .../useScrollToNoteAfterPin.tsx | 31 +++++++++++++++++++ .../NoteContextMenu/useNoteContextMenu.ts | 1 - packages/app/src/hooks/commands/index.ts | 4 --- 5 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/features/MainScreen/NotesListPanel/useScrollToNoteAfterPin.tsx diff --git a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx index e64ba5657..e579920b9 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx @@ -26,6 +26,7 @@ import { ScrollToOptions, useVirtualizer } from '@tanstack/react-virtual'; import { useNotesData } from './useNotesData'; import { useScrollToActiveNote } from './useScrollToActiveNote'; +import { useScrollToNoteAfterPin } from './useScrollToNoteAfterPin'; const MemoizedSkeleton = memo(Skeleton); MemoizedSkeleton.displayName = 'MemoizedSkeleton'; @@ -83,12 +84,16 @@ export const NotesList: FC = () => { activeNoteRef, }); + useScrollToNoteAfterPin({ noteIds, virtualizer }); + const [flashingNoteId, setFlashingNoteId] = useState(null); useWorkspaceCommandCallback(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, ({ noteId }) => { setFlashingNoteId(noteId); - setTimeout(() => { + const timer = setTimeout(() => { setFlashingNoteId(null); }, 800); + + return () => clearTimeout(timer); }); // TODO: implement dragging and moving items diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx index cdf773251..f1ebd103a 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx @@ -122,7 +122,7 @@ export const useScrollToActiveNote = ({ // Handle command to set scroll // Defer scroll until noteIds is updated, otherwise scroll happens before index changes const scrollTargetNoteIdRef = useRef(null); - useWorkspaceCommandCallback(GLOBAL_COMMANDS.SCROLL_TO_NOTE, ({ noteId }) => { + useWorkspaceCommandCallback(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, ({ noteId }) => { scrollTargetNoteIdRef.current = noteId; }); useEffect(() => { diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToNoteAfterPin.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToNoteAfterPin.tsx new file mode 100644 index 000000000..a0b4fa585 --- /dev/null +++ b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToNoteAfterPin.tsx @@ -0,0 +1,31 @@ +import { useEffect, useRef } from 'react'; +import { NoteId } from '@core/features/notes'; +import { GLOBAL_COMMANDS } from '@hooks/commands'; +import { useWorkspaceCommandCallback } from '@hooks/commands/useWorkspaceCommandCallback'; +import { Virtualizer } from '@tanstack/react-virtual'; + +import { scrollAlignment } from './NotesList'; + +export const useScrollToNoteAfterPin = ({ + noteIds, + virtualizer, +}: { + noteIds: NoteId[]; + virtualizer: Virtualizer; +}) => { + const scrollTargetNoteIdRef = useRef(null); + + useWorkspaceCommandCallback(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, ({ noteId }) => { + scrollTargetNoteIdRef.current = noteId; + }); + + useEffect(() => { + if (!scrollTargetNoteIdRef.current) return; + + const noteIndex = noteIds.indexOf(scrollTargetNoteIdRef.current); + if (noteIndex === -1) return; + + scrollTargetNoteIdRef.current = null; + virtualizer.scrollToIndex(noteIndex, { align: scrollAlignment }); + }, [noteIds, virtualizer]); +}; diff --git a/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts b/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts index 673d5b357..c9e9795e8 100644 --- a/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts +++ b/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts @@ -50,7 +50,6 @@ export const useNoteContextMenu = (context?: 'tabs' | 'notes-list') => { [NoteActions.PIN_NOTE]: (noteId: string) => { runCommand(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, { noteId }); - runCommand(GLOBAL_COMMANDS.SCROLL_TO_NOTE, { noteId }); }, [NoteActions.DUPLICATE]: (noteId: string) => { diff --git a/packages/app/src/hooks/commands/index.ts b/packages/app/src/hooks/commands/index.ts index a2fdf7089..dc160aaf7 100644 --- a/packages/app/src/hooks/commands/index.ts +++ b/packages/app/src/hooks/commands/index.ts @@ -47,8 +47,6 @@ export enum GLOBAL_COMMANDS { FOCUS_SEARCH = 'Focus search input', SHORTCUT_PRESSED = 'Shortcut pressed', - - SCROLL_TO_NOTE = 'Scroll to note', } type CommandsWithPayload = { @@ -66,8 +64,6 @@ type CommandsWithPayload = { [GLOBAL_COMMANDS.DUPLICATE_NOTE]: { noteId: string }; [GLOBAL_COMMANDS.SHORTCUT_PRESSED]: { shortcut: Shortcuts }; - - [GLOBAL_COMMANDS.SCROLL_TO_NOTE]: { noteId: string }; }; export type CommandPayloadsMap = { From a69a3c97d218a9654daaec60eee0b3430d2604c9 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 8 May 2026 14:23:09 +0200 Subject: [PATCH 19/67] chore: move style to theme file --- .../NotePreview/NotePreview.theme.ts | 12 +++++- .../components/NotePreview/NotePreview.tsx | 41 ++++++++----------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/packages/app/src/components/NotePreview/NotePreview.theme.ts b/packages/app/src/components/NotePreview/NotePreview.theme.ts index 9542341f4..4d41cd3ea 100644 --- a/packages/app/src/components/NotePreview/NotePreview.theme.ts +++ b/packages/app/src/components/NotePreview/NotePreview.theme.ts @@ -1,7 +1,7 @@ import { defineSlotRecipe } from '@chakra-ui/react'; export const notePreviewRecipe = defineSlotRecipe({ - slots: ['root', 'body', 'title', 'text', 'meta'], + slots: ['root', 'body', 'title', 'text', 'meta', 'icon', 'header'], base: { root: { cursor: 'pointer', @@ -16,6 +16,11 @@ export const notePreviewRecipe = defineSlotRecipe({ gap: '0.2rem', alignItems: 'normal', maxWidth: '100%', + width: '100%', + }, + header: { + width: '100%', + justifyContent: 'space-between', }, title: { fontWeight: 'bold', @@ -30,6 +35,11 @@ export const notePreviewRecipe = defineSlotRecipe({ fontWeight: '500', width: '100%', }, + icon: { + color: 'typography.secondary', + transform: 'rotate(45deg)', + fontSize: '12px', + }, }, variants: { variant: { diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index 7416bb37c..16e3eee57 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -57,8 +57,8 @@ export const NotePreviewContent = memo( }} role="group" > - - + + - {text.length > 0 ? ( - - - - ) : undefined} - + {isPinned && ( + + + + )} + - {isPinned && ( - - - - )} - + {text.length > 0 ? ( + + + + ) : undefined} + {meta && {meta}} From 217dbd80cd122fcd7a783da2c31f450e752a67cc Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 8 May 2026 14:26:52 +0200 Subject: [PATCH 20/67] chore: remove old --- .../NotesListPanel/useScrollToActiveNote.tsx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx index f1ebd103a..07821e240 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx @@ -2,8 +2,6 @@ import React, { useEffect, useRef } from 'react'; import { WorkspaceEvents } from '@api/events/workspace'; import { NoteId } from '@core/features/notes'; import { useEventBus } from '@features/App/Workspace/WorkspaceProvider'; -import { GLOBAL_COMMANDS } from '@hooks/commands'; -import { useWorkspaceCommandCallback } from '@hooks/commands/useWorkspaceCommandCallback'; import { useImmutableCallback } from '@hooks/useImmutableCallback'; import { useWorkspaceSelector } from '@state/redux/vaults/hooks'; import { selectNotesView } from '@state/redux/vaults/selectors/view'; @@ -118,20 +116,4 @@ export const useScrollToActiveNote = ({ wasActiveNoteInViewport.current = false; scrollToActiveNote(); }); - - // Handle command to set scroll - // Defer scroll until noteIds is updated, otherwise scroll happens before index changes - const scrollTargetNoteIdRef = useRef(null); - useWorkspaceCommandCallback(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, ({ noteId }) => { - scrollTargetNoteIdRef.current = noteId; - }); - useEffect(() => { - if (!scrollTargetNoteIdRef.current) return; - - const index = noteIds.indexOf(scrollTargetNoteIdRef.current); - if (index === -1) return; - - scrollTargetNoteIdRef.current = null; - virtualizer.scrollToIndex(index, { align: scrollAlignment }); - }, [noteIds, virtualizer]); }; From 7101e06940c42acbb030c9d14f6551265c3de909 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 9 May 2026 13:09:22 +0200 Subject: [PATCH 21/67] feat: keep pin order --- .../sqlite/migrations/sql/2_add_pinned_to_notes.sql | 2 +- .../features/notes/controller/NotesController.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/app/src/core/database/sqlite/migrations/sql/2_add_pinned_to_notes.sql b/packages/app/src/core/database/sqlite/migrations/sql/2_add_pinned_to_notes.sql index 19a3f988e..1cdc5dbe3 100644 --- a/packages/app/src/core/database/sqlite/migrations/sql/2_add_pinned_to_notes.sql +++ b/packages/app/src/core/database/sqlite/migrations/sql/2_add_pinned_to_notes.sql @@ -1,2 +1,2 @@ ALTER TABLE notes -ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0; \ No newline at end of file +ADD COLUMN pinned_at INTEGER; \ No newline at end of file diff --git a/packages/app/src/core/features/notes/controller/NotesController.ts b/packages/app/src/core/features/notes/controller/NotesController.ts index a4aff8491..80eb44a99 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.ts @@ -29,7 +29,7 @@ const RowScheme = z deleted_at: z.coerce.date().nullable(), archived: z.coerce.boolean(), bookmarked: z.coerce.boolean(), - pinned: z.coerce.boolean(), + pinned_at: z.coerce.date().nullable(), }) .transform( ({ @@ -42,8 +42,8 @@ const RowScheme = z visible, deleted_at, archived, - pinned, bookmarked, + pinned_at, }): INote => ({ id, createdTimestamp: created_at.getTime(), @@ -54,7 +54,7 @@ const RowScheme = z deletedAt: deleted_at?.getTime(), isArchived: archived, isBookmarked: bookmarked, - isPinned: pinned, + isPinned: pinned_at !== null, content: { title, text }, }), ); @@ -84,7 +84,7 @@ function formatNoteMeta(meta: Partial) { fields['bookmarked'] = Number(value); break; case 'isPinned': - fields['pinned'] = Number(value); + fields['pinned_at'] = value ? new Date().getTime() : null; break; } } @@ -169,7 +169,10 @@ function getFetchQuery( // Sort if (sort) { - if (sort.pinnedFirst) orderQuery.push(qb.line('pinned DESC')); + if (sort.pinnedFirst) { + orderQuery.push(qb.line('pinned_at IS NULL ASC')); + orderQuery.push(qb.line('pinned_at DESC')); + } orderQuery.push( qb.line(sortFieldMap[sort.by], sort.order === 'desc' ? 'DESC' : 'ASC'), From 5354ad41e1f5c29e14ce92f17221e0affd302586 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 9 May 2026 13:18:05 +0200 Subject: [PATCH 22/67] chore: not scroll if note visible --- .../MainScreen/NotesListPanel/useScrollToNoteAfterPin.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToNoteAfterPin.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToNoteAfterPin.tsx index a0b4fa585..8650195bf 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToNoteAfterPin.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToNoteAfterPin.tsx @@ -4,8 +4,6 @@ import { GLOBAL_COMMANDS } from '@hooks/commands'; import { useWorkspaceCommandCallback } from '@hooks/commands/useWorkspaceCommandCallback'; import { Virtualizer } from '@tanstack/react-virtual'; -import { scrollAlignment } from './NotesList'; - export const useScrollToNoteAfterPin = ({ noteIds, virtualizer, @@ -26,6 +24,6 @@ export const useScrollToNoteAfterPin = ({ if (noteIndex === -1) return; scrollTargetNoteIdRef.current = null; - virtualizer.scrollToIndex(noteIndex, { align: scrollAlignment }); + virtualizer.scrollToIndex(noteIndex, { align: 'auto' }); }, [noteIds, virtualizer]); }; From f3c8f6c16b6d8c7690f6d05da3baf8bc1226acb9 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 9 May 2026 13:33:45 +0200 Subject: [PATCH 23/67] chore: rename css atr --- packages/app/src/components/NotePreview/NotePreview.theme.ts | 2 +- packages/app/src/components/NotePreview/NotePreview.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/NotePreview/NotePreview.theme.ts b/packages/app/src/components/NotePreview/NotePreview.theme.ts index 4d41cd3ea..fdce82a26 100644 --- a/packages/app/src/components/NotePreview/NotePreview.theme.ts +++ b/packages/app/src/components/NotePreview/NotePreview.theme.ts @@ -61,7 +61,7 @@ export const notePreviewRecipe = defineSlotRecipe({ }, }, - '&[data-flashing="true"]': { + '&[data-flashing]': { boxShadow: 'inset 4px 0 0 0 var(--chakra-colors-control-action-active-background)', }, diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index 16e3eee57..963954e2d 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -49,7 +49,7 @@ export const NotePreviewContent = memo( Date: Sat, 9 May 2026 19:18:27 +0200 Subject: [PATCH 24/67] chore: simplify sort --- .../app/src/core/features/notes/controller/NotesController.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/app/src/core/features/notes/controller/NotesController.ts b/packages/app/src/core/features/notes/controller/NotesController.ts index 80eb44a99..a561ad466 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.ts @@ -170,7 +170,6 @@ function getFetchQuery( // Sort if (sort) { if (sort.pinnedFirst) { - orderQuery.push(qb.line('pinned_at IS NULL ASC')); orderQuery.push(qb.line('pinned_at DESC')); } From 7fa972366e853098556d146aaaed8810226fb83f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 9 May 2026 19:26:26 +0200 Subject: [PATCH 25/67] refactor: update sort api --- .../features/notes/bin/DeletedNotesController.ts | 10 ++++++---- .../controller/NotesController.filters.test.ts | 4 ++-- .../notes/controller/NotesController.test.ts | 4 ++-- .../features/notes/controller/NotesController.ts | 13 ++++++------- .../features/notes/controller/NotesTextIndexer.ts | 2 +- .../src/core/features/notes/controller/index.ts | 5 ++--- .../app/src/core/storage/interop/export/index.ts | 2 +- .../storage/interop/import/NotesImporter.test.ts | 2 +- packages/app/src/hooks/notes/useUpdateNotes.ts | 14 +++++++++----- 9 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/app/src/core/features/notes/bin/DeletedNotesController.ts b/packages/app/src/core/features/notes/bin/DeletedNotesController.ts index f4159e752..2e741b00e 100644 --- a/packages/app/src/core/features/notes/bin/DeletedNotesController.ts +++ b/packages/app/src/core/features/notes/bin/DeletedNotesController.ts @@ -113,10 +113,12 @@ export class DeletedNotesController { return notes .get({ meta: { isDeleted: true }, - sort: { - by: considerModificationTime ? 'updatedAt' : 'deletedAt', - order: 'asc', - }, + sort: [ + { + by: considerModificationTime ? 'updatedAt' : 'deletedAt', + order: 'asc', + }, + ], limit: 1, }) .then((notes) => { diff --git a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts index f376e4536..22c51f9a7 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts @@ -27,7 +27,7 @@ test('filter by update time', async () => { await expect( notes.get({ updatedAt: { from: new Date('01/01/2002 12:00') }, - sort: { by: 'createdAt', order: 'asc' }, + sort: [{ by: 'createdAt', order: 'asc' }], }), ).resolves.toEqual([ expect.objectContaining({ content: expect.objectContaining({ title: '2002' }) }), @@ -37,7 +37,7 @@ test('filter by update time', async () => { await expect( notes.get({ updatedAt: { to: new Date('01/01/2002 12:00') }, - sort: { by: 'createdAt', order: 'asc' }, + sort: [{ by: 'createdAt', order: 'asc' }], }), ).resolves.toEqual([ expect.objectContaining({ content: expect.objectContaining({ title: '2001' }) }), diff --git a/packages/app/src/core/features/notes/controller/NotesController.test.ts b/packages/app/src/core/features/notes/controller/NotesController.test.ts index 57aa7fd2b..2d4016f1c 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.test.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.test.ts @@ -194,14 +194,14 @@ test('Get notes by pages', async () => { const page1 = await registry.get({ limit: 100, page: 1, - sort: { by: 'createdAt', order: 'asc' }, + sort: [{ by: 'createdAt', order: 'asc' }], }); expect(page1[0].content).toMatchObject(notesSample[0]); const page2 = await registry.get({ limit: 100, page: 2, - sort: { by: 'createdAt', order: 'asc' }, + sort: [{ by: 'createdAt', order: 'asc' }], }); expect(page2[0].content).toMatchObject(notesSample[100]); }); diff --git a/packages/app/src/core/features/notes/controller/NotesController.ts b/packages/app/src/core/features/notes/controller/NotesController.ts index a561ad466..d8cfca702 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.ts @@ -118,6 +118,7 @@ function getFetchQuery( createdAt: 'created_at', updatedAt: 'updated_at', deletedAt: 'deleted_at', + pinnedAt: 'pinned_at', }; const withQuery: { name: string; query: Query }[] = []; @@ -169,13 +170,11 @@ function getFetchQuery( // Sort if (sort) { - if (sort.pinnedFirst) { - orderQuery.push(qb.line('pinned_at DESC')); - } - - orderQuery.push( - qb.line(sortFieldMap[sort.by], sort.order === 'desc' ? 'DESC' : 'ASC'), - ); + sort.forEach((rule) => { + orderQuery.push( + qb.line(sortFieldMap[rule.by], rule.order === 'desc' ? 'DESC' : 'ASC'), + ); + }); } return qb.line( diff --git a/packages/app/src/core/features/notes/controller/NotesTextIndexer.ts b/packages/app/src/core/features/notes/controller/NotesTextIndexer.ts index b9c82f702..b8da0b6ff 100644 --- a/packages/app/src/core/features/notes/controller/NotesTextIndexer.ts +++ b/packages/app/src/core/features/notes/controller/NotesTextIndexer.ts @@ -46,7 +46,7 @@ export class NotesTextIndexer { // Take notes chunk const notes = await this.notes .get({ - sort: { by: 'updatedAt', order: 'asc' }, + sort: [{ by: 'updatedAt', order: 'asc' }], updatedAt: { from: startDate ?? undefined }, limit: chunkSize, page, diff --git a/packages/app/src/core/features/notes/controller/index.ts b/packages/app/src/core/features/notes/controller/index.ts index 19e03e9f7..8fe25eb52 100644 --- a/packages/app/src/core/features/notes/controller/index.ts +++ b/packages/app/src/core/features/notes/controller/index.ts @@ -9,7 +9,7 @@ export type NoteMeta = { isPinned: boolean; }; -export type NoteSortField = 'id' | 'createdAt' | 'updatedAt' | 'deletedAt'; +export type NoteSortField = 'id' | 'createdAt' | 'updatedAt' | 'deletedAt' | 'pinnedAt'; export type NoteContentUpdateInfo = { id: NoteId; @@ -75,8 +75,7 @@ export type NotesControllerFetchOptions = { sort?: { by: NoteSortField; order?: 'desc' | 'asc'; - pinnedFirst?: boolean; - }; + }[]; }; export type ControlledNoteMeta = Partial & { updatedAt?: number }; diff --git a/packages/app/src/core/storage/interop/export/index.ts b/packages/app/src/core/storage/interop/export/index.ts index b17e5ad5c..84bc7bd1e 100644 --- a/packages/app/src/core/storage/interop/export/index.ts +++ b/packages/app/src/core/storage/interop/export/index.ts @@ -133,7 +133,7 @@ export class NotesExporter { const notes = await notesRegistry.get({ limit: this.config.notesFetchLimit ?? 10_000, page, - sort: { by: 'createdAt', order: 'asc' }, + sort: [{ by: 'createdAt', order: 'asc' }], }); // Complete when no more notes left diff --git a/packages/app/src/core/storage/interop/import/NotesImporter.test.ts b/packages/app/src/core/storage/interop/import/NotesImporter.test.ts index 7f4e2bc6a..746530829 100644 --- a/packages/app/src/core/storage/interop/import/NotesImporter.test.ts +++ b/packages/app/src/core/storage/interop/import/NotesImporter.test.ts @@ -117,7 +117,7 @@ describe('Base notes import cases', () => { const notesRegistry = new NotesController(db, workspaceId); await expect( - notesRegistry.get({ sort: { by: 'createdAt', order: 'asc' } }), + notesRegistry.get({ sort: [{ by: 'createdAt', order: 'asc' }] }), ).resolves.toEqual([ expect.objectContaining({ // TODO: fix unnecessary new line diff --git a/packages/app/src/hooks/notes/useUpdateNotes.ts b/packages/app/src/hooks/notes/useUpdateNotes.ts index 0c6ec5090..7ed0ea957 100644 --- a/packages/app/src/hooks/notes/useUpdateNotes.ts +++ b/packages/app/src/hooks/notes/useUpdateNotes.ts @@ -40,11 +40,15 @@ export const useUpdateNotes = () => { const noteIds = await notesRegistry.query({ tags, - sort: { - by: 'updatedAt', - order: 'desc', - pinnedFirst: notesView !== NOTES_VIEW.BIN && searchText.length === 0, - }, + sort: [ + ...(notesView !== NOTES_VIEW.BIN && searchText.length === 0 + ? [{ by: 'pinnedAt', order: 'desc' } as const] + : []), + { + by: 'updatedAt', + order: 'desc', + }, + ], search: searchText ? { text: searchText, From c3fd8e699ba2e6d3a7ed4261b9ddaa054bb6dd7f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 9 May 2026 20:23:21 +0200 Subject: [PATCH 26/67] test: add --- .../NotesController.filters.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts index 22c51f9a7..4ade1b246 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts @@ -66,6 +66,80 @@ test('filter by update time', async () => { ]); }); +test('sorts notes by pin time', async () => { + const db = await openSQLite(createFileControllerMock()); + onTestFinished(db.close); + + const workspaceId = await createWorkspaceId(db); + const registry = new NotesController(db, workspaceId); + + const note1 = await registry.add({ title: '2001', text: 'Dummy text' }); + const note2 = await registry.add({ title: '2002', text: 'Dummy text' }); + const note3 = await registry.add({ title: '2003', text: 'Dummy text' }); + + // Pin + vi.setSystemTime('01/01/2010 12:00'); + await registry.updateMeta([note1], { isPinned: true }); + + vi.setSystemTime('01/01/2011 12:00'); + await registry.updateMeta([note3], { isPinned: true }); + + await expect( + registry.query({ sort: [{ by: 'pinnedAt', order: 'desc' }] }), + ).resolves.toEqual([note3, note1, note2]); + + // Updating note content should not affect pin order + vi.setSystemTime('01/01/2015 12:00'); + await registry.update(note1, { title: '2015', text: 'Dummy text' }); + + await expect( + registry.query({ sort: [{ by: 'pinnedAt', order: 'desc' }] }), + ).resolves.toEqual([note3, note1, note2]); +}); + +test('sorts pinned notes first, then by update time', async () => { + const db = await openSQLite(createFileControllerMock()); + onTestFinished(db.close); + + const workspaceId = await createWorkspaceId(db); + const registry = new NotesController(db, workspaceId); + + const note1 = await registry.add({ title: '2001', text: 'Dummy text' }); + + vi.setSystemTime('01/01/2002 12:00'); + const note2 = await registry.add({ title: '2002', text: 'Dummy text' }); + + vi.setSystemTime('01/01/2004 12:00'); + const note3 = await registry.add({ title: '2004', text: 'Dummy text' }); + + // Pin note1 + vi.setSystemTime('01/01/2010 12:00'); + await registry.updateMeta([note1], { isPinned: true }); + + // Pinned notes come first, unpinned notes are sorted by update time + await expect( + registry.query({ + sort: [ + { by: 'pinnedAt', order: 'desc' }, + { by: 'updatedAt', order: 'desc' }, + ], + }), + ).resolves.toEqual([note1, note3, note2]); + + // Update an unpinned note + vi.setSystemTime('01/01/2015 12:00'); + await registry.update(note2, { title: '2015', text: 'Dummy text' }); + + await expect( + registry.query({ + sort: [ + { by: 'pinnedAt', order: 'desc' }, + { by: 'updatedAt', order: 'desc' }, + ], + }), + ).resolves.toEqual([note1, note2, note3]); +}); + describe('data fetching', () => { const getWorkspaceContext = createWorkspaceContext(); From e2680e8ff6acc60b38233eed04bf154f7afaaa70 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 9 May 2026 20:32:43 +0200 Subject: [PATCH 27/67] chore: update css --- packages/app/src/components/NotePreview/NotePreview.theme.ts | 4 ++-- packages/app/src/components/NotePreview/NotePreview.tsx | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/NotePreview/NotePreview.theme.ts b/packages/app/src/components/NotePreview/NotePreview.theme.ts index fdce82a26..a4ce6368d 100644 --- a/packages/app/src/components/NotePreview/NotePreview.theme.ts +++ b/packages/app/src/components/NotePreview/NotePreview.theme.ts @@ -1,7 +1,7 @@ import { defineSlotRecipe } from '@chakra-ui/react'; export const notePreviewRecipe = defineSlotRecipe({ - slots: ['root', 'body', 'title', 'text', 'meta', 'icon', 'header'], + slots: ['root', 'body', 'title', 'text', 'meta', 'pinIcon', 'header'], base: { root: { cursor: 'pointer', @@ -35,7 +35,7 @@ export const notePreviewRecipe = defineSlotRecipe({ fontWeight: '500', width: '100%', }, - icon: { + pinIcon: { color: 'typography.secondary', transform: 'rotate(45deg)', fontSize: '12px', diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index 963954e2d..7c9663ee7 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -55,7 +55,6 @@ export const NotePreviewContent = memo( ...styles.root, ...props.sx, }} - role="group" > @@ -68,7 +67,7 @@ export const NotePreviewContent = memo( {isPinned && ( - + )} From 5d6ab8bb2a1bc4ff8c09728c67a0620fada0e77c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 9 May 2026 20:53:53 +0200 Subject: [PATCH 28/67] chore: lift menu item --- .../NotesContainer/NoteContextMenu/useNoteContextMenu.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts b/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts index c9e9795e8..9cf0ae0a8 100644 --- a/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts +++ b/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts @@ -130,6 +130,11 @@ export const useNoteContextMenu = (context?: 'tabs' | 'notes-list') => { ? [{ id: NoteActions.DUPLICATE, label: t('duplicate') }] : []), + { + id: NoteActions.EXPORT, + label: t('export'), + }, + note.isPinned ? { id: NoteActions.PIN_NOTE, @@ -140,10 +145,6 @@ export const useNoteContextMenu = (context?: 'tabs' | 'notes-list') => { label: t('pinNote'), }, - { - id: NoteActions.EXPORT, - label: t('export'), - }, { id: NoteActions.COPY_MARKDOWN_LINK, label: t('copyMarkdownLink'), From 101b9733a67aef06f352388bb61e362efea55ec8 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 9 May 2026 21:08:22 +0200 Subject: [PATCH 29/67] refactor: combine pin effects --- .../MainScreen/NotesListPanel/NotesList.tsx | 17 +------- .../NotesListPanel/usePinNoteEffect.tsx | 43 +++++++++++++++++++ .../useScrollToNoteAfterPin.tsx | 29 ------------- 3 files changed, 45 insertions(+), 44 deletions(-) create mode 100644 packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx delete mode 100644 packages/app/src/features/MainScreen/NotesListPanel/useScrollToNoteAfterPin.tsx diff --git a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx index e579920b9..a2ca2f1a6 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx @@ -4,14 +4,11 @@ import { FaPenToSquare } from 'react-icons/fa6'; import { LOCALE_NAMESPACE } from 'src/i18n'; import { Box, Button, Skeleton, Text, VStack } from '@chakra-ui/react'; import { NotePreview } from '@components/NotePreview/NotePreview'; -import { NoteId } from '@core/features/notes'; import { getNoteTitle } from '@core/features/notes/utils'; import { TELEMETRY_EVENT_NAME } from '@core/features/telemetry'; import { getContextMenuCoords } from '@electron/requests/contextMenu/renderer'; import { useNoteContextMenu } from '@features/NotesContainer/NoteContextMenu/useNoteContextMenu'; import { useTelemetryTracker } from '@features/telemetry'; -import { GLOBAL_COMMANDS } from '@hooks/commands'; -import { useWorkspaceCommandCallback } from '@hooks/commands/useWorkspaceCommandCallback'; import { useCreateNote } from '@hooks/notes/useCreateNote'; import { useNoteActions } from '@hooks/notes/useNoteActions'; import { useEstimateVirtualItemSize } from '@hooks/useEstimateVirtualItemSize'; @@ -25,8 +22,8 @@ import { import { ScrollToOptions, useVirtualizer } from '@tanstack/react-virtual'; import { useNotesData } from './useNotesData'; +import { usePinNoteEffect } from './usePinNoteEffect'; import { useScrollToActiveNote } from './useScrollToActiveNote'; -import { useScrollToNoteAfterPin } from './useScrollToNoteAfterPin'; const MemoizedSkeleton = memo(Skeleton); MemoizedSkeleton.displayName = 'MemoizedSkeleton'; @@ -84,17 +81,7 @@ export const NotesList: FC = () => { activeNoteRef, }); - useScrollToNoteAfterPin({ noteIds, virtualizer }); - - const [flashingNoteId, setFlashingNoteId] = useState(null); - useWorkspaceCommandCallback(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, ({ noteId }) => { - setFlashingNoteId(noteId); - const timer = setTimeout(() => { - setFlashingNoteId(null); - }, 800); - - return () => clearTimeout(timer); - }); + const { flashingNoteId } = usePinNoteEffect({ noteIds, virtualizer }); // TODO: implement dragging and moving items return ( diff --git a/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx b/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx new file mode 100644 index 000000000..7f6858839 --- /dev/null +++ b/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx @@ -0,0 +1,43 @@ +import { useEffect, useRef, useState } from 'react'; +import { NoteId } from '@core/features/notes'; +import { GLOBAL_COMMANDS } from '@hooks/commands'; +import { useWorkspaceCommandCallback } from '@hooks/commands/useWorkspaceCommandCallback'; +import { Virtualizer } from '@tanstack/react-virtual'; + +export const usePinNoteEffect = ({ + noteIds, + virtualizer, +}: { + noteIds: NoteId[]; + virtualizer: Virtualizer; +}) => { + const targetNoteIdRef = useRef(null); + const [flashingNoteId, setFlashingNoteId] = useState(null); + + useWorkspaceCommandCallback(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, ({ noteId }) => { + targetNoteIdRef.current = noteId; + }); + + // Wait for noteIds to update after pin before scrolling + useEffect(() => { + if (!targetNoteIdRef.current) return; + + const noteIndex = noteIds.indexOf(targetNoteIdRef.current); + if (noteIndex === -1) return; + + targetNoteIdRef.current = null; + + // If the note is in the viewport, do not scroll + virtualizer.scrollToIndex(noteIndex, { align: 'auto' }); + + // Briefly highlight the note so the user can find it at its new position + setFlashingNoteId(targetNoteIdRef.current); + const timer = setTimeout(() => { + setFlashingNoteId(null); + }, 800); + + return () => clearTimeout(timer); + }, [noteIds, virtualizer]); + + return { flashingNoteId }; +}; diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToNoteAfterPin.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToNoteAfterPin.tsx deleted file mode 100644 index 8650195bf..000000000 --- a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToNoteAfterPin.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { NoteId } from '@core/features/notes'; -import { GLOBAL_COMMANDS } from '@hooks/commands'; -import { useWorkspaceCommandCallback } from '@hooks/commands/useWorkspaceCommandCallback'; -import { Virtualizer } from '@tanstack/react-virtual'; - -export const useScrollToNoteAfterPin = ({ - noteIds, - virtualizer, -}: { - noteIds: NoteId[]; - virtualizer: Virtualizer; -}) => { - const scrollTargetNoteIdRef = useRef(null); - - useWorkspaceCommandCallback(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, ({ noteId }) => { - scrollTargetNoteIdRef.current = noteId; - }); - - useEffect(() => { - if (!scrollTargetNoteIdRef.current) return; - - const noteIndex = noteIds.indexOf(scrollTargetNoteIdRef.current); - if (noteIndex === -1) return; - - scrollTargetNoteIdRef.current = null; - virtualizer.scrollToIndex(noteIndex, { align: 'auto' }); - }, [noteIds, virtualizer]); -}; From 8251acf3cf9e1897c4167e7ea65c389ebd398a4d Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sat, 9 May 2026 22:34:24 +0200 Subject: [PATCH 30/67] fix: do not wait before highliht note --- .../MainScreen/NotesListPanel/usePinNoteEffect.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx b/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx index 7f6858839..31d3fd7ac 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx @@ -16,6 +16,12 @@ export const usePinNoteEffect = ({ useWorkspaceCommandCallback(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, ({ noteId }) => { targetNoteIdRef.current = noteId; + + // Briefly highlight the note so the user can find it at its new position + setFlashingNoteId(noteId); + setTimeout(() => { + setFlashingNoteId(null); + }, 800); }); // Wait for noteIds to update after pin before scrolling @@ -29,14 +35,6 @@ export const usePinNoteEffect = ({ // If the note is in the viewport, do not scroll virtualizer.scrollToIndex(noteIndex, { align: 'auto' }); - - // Briefly highlight the note so the user can find it at its new position - setFlashingNoteId(targetNoteIdRef.current); - const timer = setTimeout(() => { - setFlashingNoteId(null); - }, 800); - - return () => clearTimeout(timer); }, [noteIds, virtualizer]); return { flashingNoteId }; From 9d28d0381673406db011b47c54336fdf309dc35e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 10 May 2026 11:46:15 +0200 Subject: [PATCH 31/67] chore: update comment --- .../features/MainScreen/NotesListPanel/usePinNoteEffect.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx b/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx index 31d3fd7ac..afb2ba584 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx @@ -25,6 +25,7 @@ export const usePinNoteEffect = ({ }); // Wait for noteIds to update after pin before scrolling + // otherwise scrolling may use the old note position useEffect(() => { if (!targetNoteIdRef.current) return; @@ -33,7 +34,7 @@ export const usePinNoteEffect = ({ targetNoteIdRef.current = null; - // If the note is in the viewport, do not scroll + // 'auto' allows skipping scroll if the element is already in the viewport virtualizer.scrollToIndex(noteIndex, { align: 'auto' }); }, [noteIds, virtualizer]); From e76c2dfb65acf43b62da100991a21c400df577fc Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 10 May 2026 11:52:09 +0200 Subject: [PATCH 32/67] test: rename --- .../features/notes/controller/NotesController.filters.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts index 4ade1b246..e9b19d1d0 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts @@ -66,7 +66,7 @@ test('filter by update time', async () => { ]); }); -test('sorts notes by pin time', async () => { +test('sorts notes by pinned status', async () => { const db = await openSQLite(createFileControllerMock()); onTestFinished(db.close); @@ -97,7 +97,7 @@ test('sorts notes by pin time', async () => { ).resolves.toEqual([note3, note1, note2]); }); -test('sorts pinned notes first, then by update time', async () => { +test('sorts notes by pinned status and update time', async () => { const db = await openSQLite(createFileControllerMock()); onTestFinished(db.close); From 683307448eb51f40f17c6cb46168a8f5e7397f64 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 10 May 2026 16:34:57 +0200 Subject: [PATCH 33/67] fix: filter by pin status have incorrect sql --- .../controller/NotesController.filters.test.ts | 18 ++++++++++++++++++ .../notes/controller/NotesController.ts | 7 ++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts index e9b19d1d0..829794cca 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts @@ -360,6 +360,24 @@ describe('data fetching', () => { ).resolves.toHaveLength(notesSample.length - 40); }); + test('filters notes by pinned status', async () => { + const { db, workspaceId } = getWorkspaceContext(); + + const registry = new NotesController(db, workspaceId); + + const notesId = await registry + .get({ limit: 40 }) + .then((notes) => notes.map((note) => note.id)); + + await registry.updateMeta(notesId, { isPinned: true }); + await expect(registry.get({ meta: { isPinned: true } })).resolves.toHaveLength( + 40, + ); + await expect(registry.get({ meta: { isPinned: false } })).resolves.toHaveLength( + notesSample.length - 40, + ); + }); + test('method getLength consider filters', async () => { const { db, workspaceId } = getWorkspaceContext(); const registry = new NotesController(db, workspaceId); diff --git a/packages/app/src/core/features/notes/controller/NotesController.ts b/packages/app/src/core/features/notes/controller/NotesController.ts index d8cfca702..5565f4a75 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.ts @@ -104,7 +104,7 @@ function getFetchQuery( limit, page, tags = [], - meta: { isDeleted, ...meta } = {}, + meta: { isDeleted, isPinned, ...meta } = {}, deletedAt = {}, updatedAt = {}, sort, @@ -153,6 +153,11 @@ function getFetchQuery( qb.sql`deleted_at IS ${isDeleted ? qb.sql`NOT NULL` : qb.sql`NULL`}`, ); } + if (isPinned !== undefined) { + filterQuery.push( + qb.sql`pinned_at IS ${isPinned ? qb.sql`NOT NULL` : qb.sql`NULL`}`, + ); + } if (deletedAt.from) { filterQuery.push(qb.sql`deleted_at >= ${deletedAt.from.getTime()}`); From b6f56376c4eddea63eaa0d5a3ddebb6916cd587b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Sun, 10 May 2026 16:54:41 +0200 Subject: [PATCH 34/67] fix: old clear timeout can remove new timeout --- .../MainScreen/NotesListPanel/usePinNoteEffect.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx b/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx index afb2ba584..0520c6e43 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx @@ -12,15 +12,22 @@ export const usePinNoteEffect = ({ virtualizer: Virtualizer; }) => { const targetNoteIdRef = useRef(null); + const [flashingNoteId, setFlashingNoteId] = useState(null); + const flashTimeoutRef = useRef(null); useWorkspaceCommandCallback(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, ({ noteId }) => { targetNoteIdRef.current = noteId; // Briefly highlight the note so the user can find it at its new position setFlashingNoteId(noteId); - setTimeout(() => { + + if (flashTimeoutRef.current) { + clearTimeout(flashTimeoutRef.current); + } + flashTimeoutRef.current = setTimeout(() => { setFlashingNoteId(null); + flashTimeoutRef.current = null; }, 800); }); From d98e91790c17fa6352b6843e5b3a9ad0b068505f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 13 May 2026 14:13:38 +0200 Subject: [PATCH 35/67] refactor: do not scroll if element in viewport --- .../MainScreen/NotesListPanel/NotesList.tsx | 6 +++++- .../MainScreen/NotesListPanel/usePinNoteEffect.tsx | 14 ++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx index a2ca2f1a6..bb00f55f3 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx @@ -81,7 +81,8 @@ export const NotesList: FC = () => { activeNoteRef, }); - const { flashingNoteId } = usePinNoteEffect({ noteIds, virtualizer }); + const pinNoteRef = useRef(null); + const { flashingNoteId } = usePinNoteEffect({ noteIds, virtualizer, pinNoteRef }); // TODO: implement dragging and moving items return ( @@ -177,6 +178,9 @@ export const NotesList: FC = () => { if (isActive) { activeNoteRef.current = node; } + if (isFlashing) { + pinNoteRef.current = node; + } virtualizer.measureElement(node); }} diff --git a/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx b/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx index 0520c6e43..e365b5165 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx @@ -3,13 +3,18 @@ import { NoteId } from '@core/features/notes'; import { GLOBAL_COMMANDS } from '@hooks/commands'; import { useWorkspaceCommandCallback } from '@hooks/commands/useWorkspaceCommandCallback'; import { Virtualizer } from '@tanstack/react-virtual'; +import { isElementInViewport } from '@utils/dom/isElementInViewport'; + +import { scrollAlignment } from './NotesList'; export const usePinNoteEffect = ({ noteIds, virtualizer, + pinNoteRef, }: { noteIds: NoteId[]; virtualizer: Virtualizer; + pinNoteRef: React.RefObject; }) => { const targetNoteIdRef = useRef(null); @@ -39,11 +44,12 @@ export const usePinNoteEffect = ({ const noteIndex = noteIds.indexOf(targetNoteIdRef.current); if (noteIndex === -1) return; - targetNoteIdRef.current = null; + // Skip if pin note is in viewport + if (pinNoteRef.current && isElementInViewport(pinNoteRef.current)) return; - // 'auto' allows skipping scroll if the element is already in the viewport - virtualizer.scrollToIndex(noteIndex, { align: 'auto' }); - }, [noteIds, virtualizer]); + targetNoteIdRef.current = null; + virtualizer.scrollToIndex(noteIndex, { align: scrollAlignment }); + }, [noteIds, pinNoteRef, virtualizer]); return { flashingNoteId }; }; From 9396ff050a9044bb495feb0cca533268c08169fa Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 18 May 2026 23:27:42 +0200 Subject: [PATCH 36/67] fix: after rebase --- .../notes/controller/NotesController.filters.test.ts | 8 ++++---- .../src/core/storage/interop/import/NotesImporter.test.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts index 829794cca..02428d4d7 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts @@ -70,8 +70,8 @@ test('sorts notes by pinned status', async () => { const db = await openSQLite(createFileControllerMock()); onTestFinished(db.close); - const workspaceId = await createWorkspaceId(db); - const registry = new NotesController(db, workspaceId); + const workspaceId = await createWorkspaceId(db.get()); + const registry = new NotesController(db.get(), workspaceId); const note1 = await registry.add({ title: '2001', text: 'Dummy text' }); const note2 = await registry.add({ title: '2002', text: 'Dummy text' }); @@ -101,8 +101,8 @@ test('sorts notes by pinned status and update time', async () => { const db = await openSQLite(createFileControllerMock()); onTestFinished(db.close); - const workspaceId = await createWorkspaceId(db); - const registry = new NotesController(db, workspaceId); + const workspaceId = await createWorkspaceId(db.get()); + const registry = new NotesController(db.get(), workspaceId); const note1 = await registry.add({ title: '2001', text: 'Dummy text' }); diff --git a/packages/app/src/core/storage/interop/import/NotesImporter.test.ts b/packages/app/src/core/storage/interop/import/NotesImporter.test.ts index 746530829..ee9f15f0c 100644 --- a/packages/app/src/core/storage/interop/import/NotesImporter.test.ts +++ b/packages/app/src/core/storage/interop/import/NotesImporter.test.ts @@ -823,7 +823,7 @@ describe('Import respects meta info', () => { const { notesRegistry } = deps; await expect( notesRegistry - .query({ sort: { by: 'createdAt' } }) + .query({ sort: [{ by: 'createdAt' }] }) .then((ids) => notesRegistry.getById(ids)), ).resolves.toMatchObject([ { From 733d255261a9c22f31f890ced1958f7e56ae8e22 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 18 May 2026 23:42:01 +0200 Subject: [PATCH 37/67] refactor: pin action available only in note list context menu --- .../NoteContextMenu/useNoteContextMenu.ts | 22 ++++++++++--------- packages/app/src/locales/en/menu.json | 6 +++-- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts b/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts index 9cf0ae0a8..8439cfc0b 100644 --- a/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts +++ b/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts @@ -122,6 +122,18 @@ export const useNoteContextMenu = (context?: 'tabs' | 'notes-list') => { ] as const) : []), + ...(context !== 'tabs' + ? ([ + { + id: NoteActions.PIN_NOTE, + label: note.isPinned + ? t('noteList.unpinNote') + : t('noteList.pinNote'), + }, + { type: 'separator' }, + ] as const) + : []), + ...(note.isDeleted ? [{ id: NoteActions.RESTORE_FROM_BIN, label: t('restoreFromBin') }] : []), @@ -135,16 +147,6 @@ export const useNoteContextMenu = (context?: 'tabs' | 'notes-list') => { label: t('export'), }, - note.isPinned - ? { - id: NoteActions.PIN_NOTE, - label: t('unpinNote'), - } - : { - id: NoteActions.PIN_NOTE, - label: t('pinNote'), - }, - { id: NoteActions.COPY_MARKDOWN_LINK, label: t('copyMarkdownLink'), diff --git a/packages/app/src/locales/en/menu.json b/packages/app/src/locales/en/menu.json index 1878e3f4f..d4d5db752 100644 --- a/packages/app/src/locales/en/menu.json +++ b/packages/app/src/locales/en/menu.json @@ -24,8 +24,10 @@ "deletePermanently": "Delete permanently", "restoreFromBin": "Restore from bin", "deleteToBin": "Move to bin", - "pinNote": "Pin note", - "unpinNote": "Unpin note", + "noteList": { + "pinNote": "Pin note", + "unpinNote": "Unpin note" + }, "tab": { "close": "Close", "closeOthers": "Close Others", From 4c6d90e7a8b84deecbe04e444fc00bead06551ad Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 19 May 2026 15:55:30 +0200 Subject: [PATCH 38/67] refactor: not scroll after pin and unpin --- .../MainScreen/NotesListPanel/NotesList.tsx | 9 --- .../NotesListPanel/usePinNoteEffect.tsx | 55 ------------------- 2 files changed, 64 deletions(-) delete mode 100644 packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx diff --git a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx index bb00f55f3..7900fb7da 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx @@ -22,7 +22,6 @@ import { import { ScrollToOptions, useVirtualizer } from '@tanstack/react-virtual'; import { useNotesData } from './useNotesData'; -import { usePinNoteEffect } from './usePinNoteEffect'; import { useScrollToActiveNote } from './useScrollToActiveNote'; const MemoizedSkeleton = memo(Skeleton); @@ -81,9 +80,6 @@ export const NotesList: FC = () => { activeNoteRef, }); - const pinNoteRef = useRef(null); - const { flashingNoteId } = usePinNoteEffect({ noteIds, virtualizer, pinNoteRef }); - // TODO: implement dragging and moving items return ( = () => { {virtualNoteItems.map((virtualRow) => { const id = noteIds[virtualRow.index]; const isActive = id === activeNoteId; - const isFlashing = flashingNoteId === id; const note = notesData.get(id); if (!note) @@ -178,9 +173,6 @@ export const NotesList: FC = () => { if (isActive) { activeNoteRef.current = node; } - if (isFlashing) { - pinNoteRef.current = node; - } virtualizer.measureElement(node); }} @@ -209,7 +201,6 @@ export const NotesList: FC = () => { }, ); }} - isFlashing={isFlashing} isPinned={Boolean(note.isPinned)} onDoubleClick={() => { // Convert preview tab to regular diff --git a/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx b/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx deleted file mode 100644 index e365b5165..000000000 --- a/packages/app/src/features/MainScreen/NotesListPanel/usePinNoteEffect.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { NoteId } from '@core/features/notes'; -import { GLOBAL_COMMANDS } from '@hooks/commands'; -import { useWorkspaceCommandCallback } from '@hooks/commands/useWorkspaceCommandCallback'; -import { Virtualizer } from '@tanstack/react-virtual'; -import { isElementInViewport } from '@utils/dom/isElementInViewport'; - -import { scrollAlignment } from './NotesList'; - -export const usePinNoteEffect = ({ - noteIds, - virtualizer, - pinNoteRef, -}: { - noteIds: NoteId[]; - virtualizer: Virtualizer; - pinNoteRef: React.RefObject; -}) => { - const targetNoteIdRef = useRef(null); - - const [flashingNoteId, setFlashingNoteId] = useState(null); - const flashTimeoutRef = useRef(null); - - useWorkspaceCommandCallback(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, ({ noteId }) => { - targetNoteIdRef.current = noteId; - - // Briefly highlight the note so the user can find it at its new position - setFlashingNoteId(noteId); - - if (flashTimeoutRef.current) { - clearTimeout(flashTimeoutRef.current); - } - flashTimeoutRef.current = setTimeout(() => { - setFlashingNoteId(null); - flashTimeoutRef.current = null; - }, 800); - }); - - // Wait for noteIds to update after pin before scrolling - // otherwise scrolling may use the old note position - useEffect(() => { - if (!targetNoteIdRef.current) return; - - const noteIndex = noteIds.indexOf(targetNoteIdRef.current); - if (noteIndex === -1) return; - - // Skip if pin note is in viewport - if (pinNoteRef.current && isElementInViewport(pinNoteRef.current)) return; - - targetNoteIdRef.current = null; - virtualizer.scrollToIndex(noteIndex, { align: scrollAlignment }); - }, [noteIds, pinNoteRef, virtualizer]); - - return { flashingNoteId }; -}; From 9bb4a229697a6a627b6b902af2bb74e7aa08d613 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 19 May 2026 15:57:18 +0200 Subject: [PATCH 39/67] refactor: remove higliting note after pin --- .../NotePreview/NotePreview.theme.ts | 6 -- .../components/NotePreview/NotePreview.tsx | 98 ++++++++----------- 2 files changed, 41 insertions(+), 63 deletions(-) diff --git a/packages/app/src/components/NotePreview/NotePreview.theme.ts b/packages/app/src/components/NotePreview/NotePreview.theme.ts index a4ce6368d..9570acdae 100644 --- a/packages/app/src/components/NotePreview/NotePreview.theme.ts +++ b/packages/app/src/components/NotePreview/NotePreview.theme.ts @@ -60,12 +60,6 @@ export const notePreviewRecipe = defineSlotRecipe({ color: 'control.option.active.foreground', }, }, - - '&[data-flashing]': { - boxShadow: - 'inset 4px 0 0 0 var(--chakra-colors-control-action-active-background)', - }, - transition: 'box-shadow 0.2s ease', }, }, }, diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index 7c9663ee7..453b23e2b 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -28,66 +28,50 @@ export const NotePreviewContent = memo( isSelected?: boolean; isPinned: boolean; textToHighlight?: string; - isFlashing?: boolean; } & StackProps ->( - ( - { - title, - text, - textToHighlight, - meta, - isSelected, - isPinned, - isFlashing, - ...props - }, - ref, - ) => { - const styles = useMultiStyleConfig('NotePreview'); - return ( - - - - - - +>(({ title, text, textToHighlight, meta, isSelected, isPinned, ...props }, ref) => { + const styles = useMultiStyleConfig('NotePreview'); + return ( + + + + + + - {isPinned && ( - - - - )} - + {isPinned && ( + + + + )} + - {text.length > 0 ? ( - - - - ) : undefined} - - - {meta && {meta}} + {text.length > 0 ? ( + + + + ) : undefined} - ); - }, -); + + {meta && {meta}} + + ); +}); NotePreview.displayName = 'NotePreview'; From 65f10fb815f83e533716696530d18cebf6c7a724 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 19 May 2026 16:14:22 +0200 Subject: [PATCH 40/67] refactor: do not scroll while note meta was updated --- packages/app/src/api/events/workspace/index.tsx | 5 ++++- .../MainScreen/NotesListPanel/useNotesData.tsx | 4 +++- .../NotesListPanel/useScrollToActiveNote.tsx | 8 +++++++- packages/app/src/features/NotesContainer/index.tsx | 2 +- packages/app/src/hooks/notes/useNoteCommandHandlers.ts | 10 +++++----- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/app/src/api/events/workspace/index.tsx b/packages/app/src/api/events/workspace/index.tsx index 7423fd36a..53ca4fd63 100644 --- a/packages/app/src/api/events/workspace/index.tsx +++ b/packages/app/src/api/events/workspace/index.tsx @@ -22,7 +22,10 @@ export type WorkspaceEventsPayloadMap = { * * For example note version has been applied or remote end force updated a note */ - [WorkspaceEvents.NOTE_UPDATED]: NoteId; + [WorkspaceEvents.NOTE_UPDATED]: { + noteId: NoteId; + reason?: 'meta' | 'content'; + }; /** * Fired when note has been edited by user and change is committed in DB diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx index 1a49a0c88..1ed59c015 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx @@ -50,7 +50,9 @@ export const useNotesData = ({ noteIds }: { noteIds: NoteId[] }) => { }; return joinCallbacks( - eventBus.listen(WorkspaceEvents.NOTE_UPDATED, onNoteUpdated), + eventBus.listen(WorkspaceEvents.NOTE_UPDATED, ({ noteId }) => + onNoteUpdated(noteId), + ), eventBus.listen(WorkspaceEvents.NOTE_EDITED, onNoteUpdated), ); }, [eventBus, loadNotesData, notesData]); diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx index 07821e240..fe08f256d 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx @@ -99,7 +99,13 @@ export const useScrollToActiveNote = ({ }; return joinCallbacks( - eventBus.listen(WorkspaceEvents.NOTE_UPDATED, onNoteUpdated), + eventBus.listen(WorkspaceEvents.NOTE_UPDATED, ({ noteId, reason }) => { + // Ignore scroll resets for meta changes (pin, etc) + // because users may modify note metadata while browsing the list + // and losing the current scroll position would break their browsing context + if (reason === 'meta') return; + onNoteUpdated(noteId); + }), eventBus.listen(WorkspaceEvents.NOTE_EDITED, onNoteUpdated), ); }, [activeNoteId, activeNoteRef, eventBus]); diff --git a/packages/app/src/features/NotesContainer/index.tsx b/packages/app/src/features/NotesContainer/index.tsx index 553eced6c..bbf03dd88 100644 --- a/packages/app/src/features/NotesContainer/index.tsx +++ b/packages/app/src/features/NotesContainer/index.tsx @@ -51,7 +51,7 @@ export const NotesContainer: FC = ({ ...props }) => { useEffect(() => { return joinCallbacks( eventBus.listen(WorkspaceEvents.NOTES_UPDATED, updateNotes), - eventBus.listen(WorkspaceEvents.NOTE_UPDATED, (noteId) => { + eventBus.listen(WorkspaceEvents.NOTE_UPDATED, ({ noteId }) => { updateNotes(); notesRegistry.getById([noteId]).then(([note]) => { if (note) noteUpdated(note); diff --git a/packages/app/src/hooks/notes/useNoteCommandHandlers.ts b/packages/app/src/hooks/notes/useNoteCommandHandlers.ts index 3ae822bda..2a8443421 100644 --- a/packages/app/src/hooks/notes/useNoteCommandHandlers.ts +++ b/packages/app/src/hooks/notes/useNoteCommandHandlers.ts @@ -44,7 +44,7 @@ export const useNoteCommandHandlers = () => { await noteActions.close(noteId); await notes.updateMeta([noteId], { isDeleted: true }); - eventBus.emit(WorkspaceEvents.NOTE_UPDATED, noteId); + eventBus.emit(WorkspaceEvents.NOTE_UPDATED, { noteId }); telemetry.track(TELEMETRY_EVENT_NAME.NOTE_DELETED, { permanently: 'false', @@ -81,7 +81,7 @@ export const useNoteCommandHandlers = () => { if (!note || !note.isDeleted) return; await notes.updateMeta([noteId], { isDeleted: false }); - eventBus.emit(WorkspaceEvents.NOTE_UPDATED, noteId); + eventBus.emit(WorkspaceEvents.NOTE_UPDATED, { noteId }); telemetry.track(TELEMETRY_EVENT_NAME.NOTE_RESTORED_FROM_BIN); }, @@ -153,7 +153,7 @@ export const useNoteCommandHandlers = () => { await notes.updateMeta([noteId], { isArchived: newArchivedState, }); - eventBus.emit(WorkspaceEvents.NOTE_UPDATED, noteId); + eventBus.emit(WorkspaceEvents.NOTE_UPDATED, { noteId, reason: 'meta' }); telemetry.track(TELEMETRY_EVENT_NAME.NOTE_ARCHIVE_TOGGLE, { action: newArchivedState ? 'Added' : 'Removed', @@ -174,7 +174,7 @@ export const useNoteCommandHandlers = () => { await notes.updateMeta([noteId], { isBookmarked: newBookmarkedState, }); - eventBus.emit(WorkspaceEvents.NOTE_UPDATED, noteId); + eventBus.emit(WorkspaceEvents.NOTE_UPDATED, { noteId, reason: 'meta' }); telemetry.track(TELEMETRY_EVENT_NAME.NOTE_BOOKMARK_TOGGLE, { action: newBookmarkedState ? 'Added' : 'Removed', @@ -193,7 +193,7 @@ export const useNoteCommandHandlers = () => { await notes.updateMeta([noteId], { isPinned: newPinnedState, }); - eventBus.emit(WorkspaceEvents.NOTE_UPDATED, noteId); + eventBus.emit(WorkspaceEvents.NOTE_UPDATED, { noteId, reason: 'meta' }); telemetry.track(TELEMETRY_EVENT_NAME.NOTE_PIN_TOGGLE, { action: newPinnedState ? 'Pinned' : 'Unpinned', From ab5e3138aa9822d5d1fba4b4629f1b0d6d30417f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 19 May 2026 16:15:29 +0200 Subject: [PATCH 41/67] chore: remove pin controller from note menu --- packages/app/src/features/NoteEditor/NoteMenu.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/app/src/features/NoteEditor/NoteMenu.tsx b/packages/app/src/features/NoteEditor/NoteMenu.tsx index 67166dd96..14f0d9af2 100644 --- a/packages/app/src/features/NoteEditor/NoteMenu.tsx +++ b/packages/app/src/features/NoteEditor/NoteMenu.tsx @@ -6,7 +6,6 @@ import { FaCopy, FaEllipsis, FaFileExport, - FaThumbtack, FaTrashCan, FaTrashCanArrowUp, } from 'react-icons/fa6'; From 915f7c15a404acbd3295e5aceddf1f5bdfc2cd91 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 19 May 2026 16:45:33 +0200 Subject: [PATCH 42/67] chore: pin in preview is optional --- packages/app/src/components/NotePreview/NotePreview.tsx | 2 +- .../app/src/features/MainScreen/NotesListPanel/NotesList.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index 453b23e2b..142e26191 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -26,7 +26,7 @@ export const NotePreviewContent = memo( }; meta?: ReactNode; isSelected?: boolean; - isPinned: boolean; + isPinned?: boolean; textToHighlight?: string; } & StackProps >(({ title, text, textToHighlight, meta, isSelected, isPinned, ...props }, ref) => { diff --git a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx index 7900fb7da..94cee73d2 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx @@ -201,7 +201,7 @@ export const NotesList: FC = () => { }, ); }} - isPinned={Boolean(note.isPinned)} + isPinned={note.isPinned} onDoubleClick={() => { // Convert preview tab to regular noteActions.click(note.id, { preview: false }); From e3360e51ad3400f8ef9d34c78200b200cf64ce44 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 19 May 2026 16:49:03 +0200 Subject: [PATCH 43/67] chore: typo --- .../MainScreen/NotesListPanel/useScrollToActiveNote.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx index fe08f256d..e21a322f2 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx @@ -100,10 +100,11 @@ export const useScrollToActiveNote = ({ return joinCallbacks( eventBus.listen(WorkspaceEvents.NOTE_UPDATED, ({ noteId, reason }) => { - // Ignore scroll resets for meta changes (pin, etc) - // because users may modify note metadata while browsing the list - // and losing the current scroll position would break their browsing context + // Ignore meta changes (pin, bookmarks, etc) and do not change the scroll position, + // because users may update note metadata while browsing the notes + // and losing the scroll position would break their browsing context if (reason === 'meta') return; + onNoteUpdated(noteId); }), eventBus.listen(WorkspaceEvents.NOTE_EDITED, onNoteUpdated), From f4ab7de61b94e123a85b50e25017b3b5324561e6 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 19 May 2026 23:54:01 +0200 Subject: [PATCH 44/67] chore: do not pass payload --- packages/app/src/hooks/notes/useNoteCommandHandlers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/hooks/notes/useNoteCommandHandlers.ts b/packages/app/src/hooks/notes/useNoteCommandHandlers.ts index 2a8443421..052a20425 100644 --- a/packages/app/src/hooks/notes/useNoteCommandHandlers.ts +++ b/packages/app/src/hooks/notes/useNoteCommandHandlers.ts @@ -153,7 +153,7 @@ export const useNoteCommandHandlers = () => { await notes.updateMeta([noteId], { isArchived: newArchivedState, }); - eventBus.emit(WorkspaceEvents.NOTE_UPDATED, { noteId, reason: 'meta' }); + eventBus.emit(WorkspaceEvents.NOTE_UPDATED, { noteId }); telemetry.track(TELEMETRY_EVENT_NAME.NOTE_ARCHIVE_TOGGLE, { action: newArchivedState ? 'Added' : 'Removed', @@ -174,7 +174,7 @@ export const useNoteCommandHandlers = () => { await notes.updateMeta([noteId], { isBookmarked: newBookmarkedState, }); - eventBus.emit(WorkspaceEvents.NOTE_UPDATED, { noteId, reason: 'meta' }); + eventBus.emit(WorkspaceEvents.NOTE_UPDATED, { noteId }); telemetry.track(TELEMETRY_EVENT_NAME.NOTE_BOOKMARK_TOGGLE, { action: newBookmarkedState ? 'Added' : 'Removed', From 20fe67528bdc08c4e2d406f53e610b70d087d73b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 19 May 2026 23:59:22 +0200 Subject: [PATCH 45/67] test: update check --- .../notes/controller/NotesController.filters.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts index 02428d4d7..7856382de 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts @@ -80,13 +80,12 @@ test('sorts notes by pinned status', async () => { // Pin vi.setSystemTime('01/01/2010 12:00'); await registry.updateMeta([note1], { isPinned: true }); - vi.setSystemTime('01/01/2011 12:00'); await registry.updateMeta([note3], { isPinned: true }); await expect( registry.query({ sort: [{ by: 'pinnedAt', order: 'desc' }] }), - ).resolves.toEqual([note3, note1, note2]); + ).resolves.toStrictEqual([note3, note1, note2]); // Updating note content should not affect pin order vi.setSystemTime('01/01/2015 12:00'); @@ -94,7 +93,7 @@ test('sorts notes by pinned status', async () => { await expect( registry.query({ sort: [{ by: 'pinnedAt', order: 'desc' }] }), - ).resolves.toEqual([note3, note1, note2]); + ).resolves.toStrictEqual([note3, note1, note2]); }); test('sorts notes by pinned status and update time', async () => { @@ -104,6 +103,7 @@ test('sorts notes by pinned status and update time', async () => { const workspaceId = await createWorkspaceId(db.get()); const registry = new NotesController(db.get(), workspaceId); + vi.setSystemTime('01/01/2000 12:00'); const note1 = await registry.add({ title: '2001', text: 'Dummy text' }); vi.setSystemTime('01/01/2002 12:00'); @@ -124,9 +124,9 @@ test('sorts notes by pinned status and update time', async () => { { by: 'updatedAt', order: 'desc' }, ], }), - ).resolves.toEqual([note1, note3, note2]); + ).resolves.toStrictEqual([note1, note3, note2]); - // Update an unpinned note + // Update an unpinned note2 vi.setSystemTime('01/01/2015 12:00'); await registry.update(note2, { title: '2015', text: 'Dummy text' }); @@ -137,7 +137,7 @@ test('sorts notes by pinned status and update time', async () => { { by: 'updatedAt', order: 'desc' }, ], }), - ).resolves.toEqual([note1, note2, note3]); + ).resolves.toStrictEqual([note1, note2, note3]); }); describe('data fetching', () => { From df5679043c79217386aee677ac47f2d168dab7ad Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 20 May 2026 13:03:37 +0200 Subject: [PATCH 46/67] chore: simplify --- .../src/components/NotePreview/NotePreview.theme.ts | 10 +++++----- .../app/src/components/NotePreview/NotePreview.tsx | 6 +----- .../core/features/notes/controller/NotesController.ts | 10 +++++----- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/NotePreview/NotePreview.theme.ts b/packages/app/src/components/NotePreview/NotePreview.theme.ts index 9570acdae..8e43d5082 100644 --- a/packages/app/src/components/NotePreview/NotePreview.theme.ts +++ b/packages/app/src/components/NotePreview/NotePreview.theme.ts @@ -26,6 +26,11 @@ export const notePreviewRecipe = defineSlotRecipe({ fontWeight: 'bold', fontSize: '18px', }, + pinIcon: { + color: 'typography.secondary', + transform: 'rotate(45deg)', + fontSize: '12px', + }, text: { fontSize: '14px', maxWidth: '100%', @@ -35,11 +40,6 @@ export const notePreviewRecipe = defineSlotRecipe({ fontWeight: '500', width: '100%', }, - pinIcon: { - color: 'typography.secondary', - transform: 'rotate(45deg)', - fontSize: '12px', - }, }, variants: { variant: { diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index 142e26191..3a0af8a4a 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -51,11 +51,7 @@ export const NotePreviewContent = memo( /> - {isPinned && ( - - - - )} + {isPinned && } {text.length > 0 ? ( diff --git a/packages/app/src/core/features/notes/controller/NotesController.ts b/packages/app/src/core/features/notes/controller/NotesController.ts index 5565f4a75..c9a4d7787 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.ts @@ -175,11 +175,11 @@ function getFetchQuery( // Sort if (sort) { - sort.forEach((rule) => { - orderQuery.push( - qb.line(sortFieldMap[rule.by], rule.order === 'desc' ? 'DESC' : 'ASC'), - ); - }); + orderQuery.push( + ...sort.map(({ by, order }) => + qb.line(sortFieldMap[by], order === 'desc' ? 'DESC' : 'ASC'), + ), + ); } return qb.line( From b18602cd20a7ed36e00e96e75709232009a7dd9d Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 20 May 2026 13:46:43 +0200 Subject: [PATCH 47/67] test: rename, add test case --- .../NotesController.filters.test.ts | 10 +++---- .../notes/controller/NotesController.test.ts | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts index 7856382de..b41b3ab71 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts @@ -66,7 +66,7 @@ test('filter by update time', async () => { ]); }); -test('sorts notes by pinned status', async () => { +test('sorts notes by pinned time', async () => { const db = await openSQLite(createFileControllerMock()); onTestFinished(db.close); @@ -96,7 +96,7 @@ test('sorts notes by pinned status', async () => { ).resolves.toStrictEqual([note3, note1, note2]); }); -test('sorts notes by pinned status and update time', async () => { +test('sorts notes by pinned time and update time', async () => { const db = await openSQLite(createFileControllerMock()); onTestFinished(db.close); @@ -366,15 +366,15 @@ describe('data fetching', () => { const registry = new NotesController(db, workspaceId); const notesId = await registry - .get({ limit: 40 }) + .get({ limit: 60 }) .then((notes) => notes.map((note) => note.id)); await registry.updateMeta(notesId, { isPinned: true }); await expect(registry.get({ meta: { isPinned: true } })).resolves.toHaveLength( - 40, + 60, ); await expect(registry.get({ meta: { isPinned: false } })).resolves.toHaveLength( - notesSample.length - 40, + notesSample.length - 60, ); }); diff --git a/packages/app/src/core/features/notes/controller/NotesController.test.ts b/packages/app/src/core/features/notes/controller/NotesController.test.ts index 2d4016f1c..21620f66f 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.test.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.test.ts @@ -408,6 +408,35 @@ describe('Meta data control', () => { }, ]); }); + + test('toggle note pinned status', async () => { + const { db, workspaceId } = getWorkspaceContext(); + const registry = new NotesController(db, workspaceId); + + const noteId = await registry.add({ title: 'Title', text: 'Text' }); + await expect(registry.getById([noteId])).resolves.toMatchObject([ + { + id: noteId, + isPinned: false, + }, + ]); + + await registry.updateMeta([noteId], { isPinned: true }); + await expect(registry.getById([noteId])).resolves.toMatchObject([ + { + id: noteId, + isPinned: true, + }, + ]); + + await registry.updateMeta([noteId], { isPinned: false }); + await expect(registry.getById([noteId])).resolves.toMatchObject([ + { + id: noteId, + isPinned: false, + }, + ]); + }); }); const getWorkspaceContext = createWorkspaceContext(); From f12c315fd219389fcd38906fc13eeb52e9c56b97 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 20 May 2026 13:55:09 +0200 Subject: [PATCH 48/67] chore: remove unnecessary text --- packages/app/src/locales/en/features.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/app/src/locales/en/features.json b/packages/app/src/locales/en/features.json index a22e71542..829e44799 100644 --- a/packages/app/src/locales/en/features.json +++ b/packages/app/src/locales/en/features.json @@ -118,9 +118,7 @@ "removeFromArchive": "Remove from archive", "deleteToBin": "Delete to bin", "deletePermanently": "Delete permanently", - "restoreFromBin": "Restore from bin", - "pinNote": "Pin note", - "unpinNote": "Unpin note" + "restoreFromBin": "Restore from bin" }, "version": { "chars": "{{count}} characters", From 107bd3031b8aa8ce551ed9cdc437f46d95373456 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Wed, 20 May 2026 14:05:20 +0200 Subject: [PATCH 49/67] chore: fix typo --- .../features/notes/controller/NotesController.filters.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts index b41b3ab71..78c2b9f06 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts @@ -87,7 +87,7 @@ test('sorts notes by pinned time', async () => { registry.query({ sort: [{ by: 'pinnedAt', order: 'desc' }] }), ).resolves.toStrictEqual([note3, note1, note2]); - // Updating note content should not affect pin order + // Updating note content in a pinned note should not affect the order vi.setSystemTime('01/01/2015 12:00'); await registry.update(note1, { title: '2015', text: 'Dummy text' }); @@ -362,7 +362,6 @@ describe('data fetching', () => { test('filters notes by pinned status', async () => { const { db, workspaceId } = getWorkspaceContext(); - const registry = new NotesController(db, workspaceId); const notesId = await registry From 35ba6b43bba7438210da2df473787e5079f6eb24 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 21 May 2026 14:13:12 +0200 Subject: [PATCH 50/67] fix: after unpin the pin icon is blink in note preview --- .../MainScreen/NotesListPanel/useNotesData.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx index 1ed59c015..a40b4f19b 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx @@ -43,19 +43,29 @@ export const useNotesData = ({ noteIds }: { noteIds: NoteId[] }) => { // Re-fetch note data by changes const eventBus = useEventBus(); useEffect(() => { - const onNoteUpdated = (noteId: NoteId) => { + const onNoteUpdated = (noteId: NoteId, reason?: 'meta' | 'content') => { if (notesData.has(noteId)) { + // Meta changes (e.g. pin/unpin) reorder the notes list, but note data is updated with debounce + // which can cause stale UI. To avoid inconsistency, we immediately refresh the updated note + if (reason === 'meta') { + notesRegistry.getById([noteId]).then((note) => { + if (note.length === 0) return; + notesData.add( + note.map((note) => [note.id, note] as [string, INote]), + ); + }); + } loadNotesData(); } }; return joinCallbacks( - eventBus.listen(WorkspaceEvents.NOTE_UPDATED, ({ noteId }) => - onNoteUpdated(noteId), + eventBus.listen(WorkspaceEvents.NOTE_UPDATED, ({ noteId, reason }) => + onNoteUpdated(noteId, reason), ), eventBus.listen(WorkspaceEvents.NOTE_EDITED, onNoteUpdated), ); - }, [eventBus, loadNotesData, notesData]); + }, [eventBus, loadNotesData, notesData, notesRegistry]); return notesData; }; From e91b7cf02d31cd4ec2f5d924781565f13d487472 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 21 May 2026 15:34:53 +0200 Subject: [PATCH 51/67] fix: disable native auto scroll after update note meta --- .../app/src/features/MainScreen/NotesListPanel/NotesList.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx index 94cee73d2..9286ed3f1 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx @@ -91,6 +91,7 @@ export const NotesList: FC = () => { align: 'center', userSelect: 'none', paddingInlineEnd: '.3rem', + overflowAnchor: 'none', }} > {noteIds.length === 0 ? ( From 96c4591b3c39e540c0f279d8a71cc2c7da9f1c88 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 21 May 2026 15:57:10 +0200 Subject: [PATCH 52/67] chore: typo --- .../src/features/MainScreen/NotesListPanel/useNotesData.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx index a40b4f19b..396196cbf 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx @@ -45,8 +45,9 @@ export const useNotesData = ({ noteIds }: { noteIds: NoteId[] }) => { useEffect(() => { const onNoteUpdated = (noteId: NoteId, reason?: 'meta' | 'content') => { if (notesData.has(noteId)) { - // Meta changes (e.g. pin/unpin) reorder the notes list, but note data is updated with debounce - // which can cause stale UI. To avoid inconsistency, we immediately refresh the updated note + // Meta changes (e.g. pin/unpin) reorder the notes list, but note data is updated with debounce, + // which can cause stale UI (e.g. after unpinning the note may still show the pin icon) + // To avoid inconsistency immediately refresh the updated note if (reason === 'meta') { notesRegistry.getById([noteId]).then((note) => { if (note.length === 0) return; From ea4a5f6f87dfdc76376eb3d66b95d3a00b903eeb Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 21 May 2026 16:16:39 +0200 Subject: [PATCH 53/67] chore: simplify --- .../features/MainScreen/NotesListPanel/useNotesData.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx index 396196cbf..4c124efe5 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx @@ -49,11 +49,10 @@ export const useNotesData = ({ noteIds }: { noteIds: NoteId[] }) => { // which can cause stale UI (e.g. after unpinning the note may still show the pin icon) // To avoid inconsistency immediately refresh the updated note if (reason === 'meta') { - notesRegistry.getById([noteId]).then((note) => { - if (note.length === 0) return; - notesData.add( - note.map((note) => [note.id, note] as [string, INote]), - ); + notesRegistry.getById([noteId]).then((loadedNote) => { + const note = loadedNote[0]; + if (!note) return; + notesData.add([[note.id, note]]); }); } loadNotesData(); From 5d576e13daeb9f2a9fc686b83dc1521ad9619c65 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Thu, 21 May 2026 16:20:42 +0200 Subject: [PATCH 54/67] chore: add type --- packages/app/src/api/events/workspace/index.tsx | 4 +++- .../src/features/MainScreen/NotesListPanel/useNotesData.tsx | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/app/src/api/events/workspace/index.tsx b/packages/app/src/api/events/workspace/index.tsx index 53ca4fd63..8c379699a 100644 --- a/packages/app/src/api/events/workspace/index.tsx +++ b/packages/app/src/api/events/workspace/index.tsx @@ -7,6 +7,8 @@ export enum WorkspaceEvents { NOTE_HISTORY_UPDATED = 'noteHistoryUpdated', } +export type NoteUpdatedReason = 'meta' | 'content'; + /** * Events payload map */ @@ -24,7 +26,7 @@ export type WorkspaceEventsPayloadMap = { */ [WorkspaceEvents.NOTE_UPDATED]: { noteId: NoteId; - reason?: 'meta' | 'content'; + reason?: NoteUpdatedReason; }; /** diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx index 4c124efe5..cf81320ef 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { WorkspaceEvents } from '@api/events/workspace'; +import { NoteUpdatedReason, WorkspaceEvents } from '@api/events/workspace'; import { INote, NoteId } from '@core/features/notes'; import { useEventBus, useNotesRegistry } from '@features/App/Workspace/WorkspaceProvider'; import { useDeepEqualValue } from '@hooks/useDeepEqualValue'; @@ -43,7 +43,7 @@ export const useNotesData = ({ noteIds }: { noteIds: NoteId[] }) => { // Re-fetch note data by changes const eventBus = useEventBus(); useEffect(() => { - const onNoteUpdated = (noteId: NoteId, reason?: 'meta' | 'content') => { + const onNoteUpdated = (noteId: NoteId, reason?: NoteUpdatedReason) => { if (notesData.has(noteId)) { // Meta changes (e.g. pin/unpin) reorder the notes list, but note data is updated with debounce, // which can cause stale UI (e.g. after unpinning the note may still show the pin icon) From 21df539f686f2db7336200382039289c3c4f6de5 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 22 May 2026 12:57:22 +0200 Subject: [PATCH 55/67] chore: typo --- packages/app/src/api/events/workspace/index.tsx | 4 ++-- .../src/features/MainScreen/NotesListPanel/useNotesData.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/api/events/workspace/index.tsx b/packages/app/src/api/events/workspace/index.tsx index 8c379699a..9a1fffd85 100644 --- a/packages/app/src/api/events/workspace/index.tsx +++ b/packages/app/src/api/events/workspace/index.tsx @@ -7,7 +7,7 @@ export enum WorkspaceEvents { NOTE_HISTORY_UPDATED = 'noteHistoryUpdated', } -export type NoteUpdatedReason = 'meta' | 'content'; +export type NoteUpdateReason = 'meta' | 'content'; /** * Events payload map @@ -26,7 +26,7 @@ export type WorkspaceEventsPayloadMap = { */ [WorkspaceEvents.NOTE_UPDATED]: { noteId: NoteId; - reason?: NoteUpdatedReason; + reason?: NoteUpdateReason; }; /** diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx index cf81320ef..c263321df 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { NoteUpdatedReason, WorkspaceEvents } from '@api/events/workspace'; +import { NoteUpdateReason, WorkspaceEvents } from '@api/events/workspace'; import { INote, NoteId } from '@core/features/notes'; import { useEventBus, useNotesRegistry } from '@features/App/Workspace/WorkspaceProvider'; import { useDeepEqualValue } from '@hooks/useDeepEqualValue'; @@ -43,7 +43,7 @@ export const useNotesData = ({ noteIds }: { noteIds: NoteId[] }) => { // Re-fetch note data by changes const eventBus = useEventBus(); useEffect(() => { - const onNoteUpdated = (noteId: NoteId, reason?: NoteUpdatedReason) => { + const onNoteUpdated = (noteId: NoteId, reason?: NoteUpdateReason) => { if (notesData.has(noteId)) { // Meta changes (e.g. pin/unpin) reorder the notes list, but note data is updated with debounce, // which can cause stale UI (e.g. after unpinning the note may still show the pin icon) From f9d7ed3c0ff4eec901f3cb5e80b107a75d8e743b Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Fri, 22 May 2026 14:41:08 +0200 Subject: [PATCH 56/67] refactor: use index for pinned field --- .../sqlite/migrations/sql/2_add_pinned_to_notes.sql | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/app/src/core/database/sqlite/migrations/sql/2_add_pinned_to_notes.sql b/packages/app/src/core/database/sqlite/migrations/sql/2_add_pinned_to_notes.sql index 1cdc5dbe3..3754342bc 100644 --- a/packages/app/src/core/database/sqlite/migrations/sql/2_add_pinned_to_notes.sql +++ b/packages/app/src/core/database/sqlite/migrations/sql/2_add_pinned_to_notes.sql @@ -1,2 +1,7 @@ ALTER TABLE notes -ADD COLUMN pinned_at INTEGER; \ No newline at end of file +ADD COLUMN pinned_at INTEGER; + +DROP INDEX idx_notes_workspace_status; + +CREATE INDEX idx_notes_workspace_status + ON notes(workspace_id, deleted_at, archived, bookmarked, visible, pinned_at, updated_at DESC); \ No newline at end of file From e50d37a441c05ff1466cd7f460020d6f25a3096e Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 8 Jun 2026 14:12:15 +0200 Subject: [PATCH 57/67] fix: after rebase --- .../components/NotePreview/NotePreview.tsx | 111 +++++++++++------- .../app/src/features/NoteEditor/NoteMenu.tsx | 24 +--- .../app/src/features/NoteEditor/index.tsx | 2 +- 3 files changed, 70 insertions(+), 67 deletions(-) diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index 3a0af8a4a..e4e3223b9 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -1,6 +1,7 @@ import React, { forwardRef, memo, useMemo } from 'react'; +import { FaThumbtack } from 'react-icons/fa6'; import { isEqual } from 'lodash'; -import { Box, Stack, StackProps, Text, useSlotRecipe } from '@chakra-ui/react'; +import { Box, HStack, Stack, StackProps, Text, useSlotRecipe } from '@chakra-ui/react'; import { useLocalizedDate } from '@hooks/useLocalizedDate'; import { TextSample } from './TextSample'; @@ -15,58 +16,82 @@ export const NotePreviewContent = memo( text, textToHighlight, meta, + isPinned, }: { title: string; text: string; meta?: NotePreviewMeta; - pin: { - title: string; - isActive: boolean; - onToggle: () => void; - }; - meta?: ReactNode; - isSelected?: boolean; + textToHighlight?: string; isPinned?: boolean; + }) => { + const recipe = useSlotRecipe({ key: 'notePreview' }); + const styles = recipe(); + const localizedDate = useLocalizedDate(); + + return ( + <> + + + + + + + {isPinned && } + + + {text.length > 0 ? ( + + + + ) : undefined} + + + {meta && meta.updatedAt !== undefined && ( + + {localizedDate(new Date(meta.updatedAt))} + + )} + + ); + }, + isEqual, +); + +NotePreviewContent.displayName = 'NotePreviewContent'; + +export const NotePreview = forwardRef< + HTMLDivElement, + { + title: string; + text: string; + meta?: NotePreviewMeta; + isSelected?: boolean; textToHighlight?: string; + isPinned?: boolean; } & StackProps >(({ title, text, textToHighlight, meta, isSelected, isPinned, ...props }, ref) => { - const styles = useMultiStyleConfig('NotePreview'); - return ( - - - - - - - - {isPinned && } - + const recipe = useSlotRecipe({ key: 'notePreview' }); + const styles = recipe(); - {text.length > 0 ? ( - - - - ) : undefined} - + const style = useMemo( + () => ({ + ...styles.root, + }), + [styles.root], + ); - {meta && {meta}} - + return ( + + + ); }); diff --git a/packages/app/src/features/NoteEditor/NoteMenu.tsx b/packages/app/src/features/NoteEditor/NoteMenu.tsx index 14f0d9af2..e48dbf5dc 100644 --- a/packages/app/src/features/NoteEditor/NoteMenu.tsx +++ b/packages/app/src/features/NoteEditor/NoteMenu.tsx @@ -10,7 +10,7 @@ import { FaTrashCanArrowUp, } from 'react-icons/fa6'; import { LOCALE_NAMESPACE } from 'src/i18n'; -import { Box, Button, HStack, Menu, Portal, Text } from '@chakra-ui/react'; +import { Button, HStack, Menu, Portal, Text } from '@chakra-ui/react'; import { INote } from '@core/features/notes'; import { GLOBAL_COMMANDS } from '@hooks/commands'; import { useCommand } from '@hooks/commands/useCommand'; @@ -41,28 +41,6 @@ export const NoteMenu = memo(({ note }: { note: INote }) => { - - runCommand( - GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, - { - noteId: note.id, - }, - ) - } - value="togglePinNote" - > - - - - - - {note.isPinned - ? t('note.menu.unpinNote') - : t('note.menu.pinNote')} - - - diff --git a/packages/app/src/features/NoteEditor/index.tsx b/packages/app/src/features/NoteEditor/index.tsx index 3533c9445..db9b028c5 100644 --- a/packages/app/src/features/NoteEditor/index.tsx +++ b/packages/app/src/features/NoteEditor/index.tsx @@ -578,7 +578,7 @@ export const Note2: FC = memo( ); eventBus.emit( WorkspaceEvents.NOTE_UPDATED, - note.id, + { noteId: note.id }, ); forceUpdateLocalStateRef.current = true; }} From eba60ee2ac074a459d9c15a2e997085a476fab5c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Tue, 9 Jun 2026 09:04:00 +0200 Subject: [PATCH 58/67] refactor: remove pin from note editor --- .../app/src/features/NoteEditor/NoteMenu.tsx | 50 ++++++++++++++++++- packages/app/src/locales/en/features.json | 8 +-- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/app/src/features/NoteEditor/NoteMenu.tsx b/packages/app/src/features/NoteEditor/NoteMenu.tsx index e48dbf5dc..4bf8a2667 100644 --- a/packages/app/src/features/NoteEditor/NoteMenu.tsx +++ b/packages/app/src/features/NoteEditor/NoteMenu.tsx @@ -6,11 +6,12 @@ import { FaCopy, FaEllipsis, FaFileExport, + FaThumbtack, FaTrashCan, FaTrashCanArrowUp, } from 'react-icons/fa6'; import { LOCALE_NAMESPACE } from 'src/i18n'; -import { Button, HStack, Menu, Portal, Text } from '@chakra-ui/react'; +import { Box, Button, HStack, Menu, Portal, Text } from '@chakra-ui/react'; import { INote } from '@core/features/notes'; import { GLOBAL_COMMANDS } from '@hooks/commands'; import { useCommand } from '@hooks/commands/useCommand'; @@ -41,6 +42,53 @@ export const NoteMenu = memo(({ note }: { note: INote }) => { + + runCommand( + GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, + { + noteId: note.id, + }, + ) + } + value="pin" + > + + + + {note.isPinned + ? t('note.menu.unpin') + : t('note.menu.pin')} + + + + + runCommand( + GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, + { + noteId: note.id, + }, + ) + } + value="pin" + > + + + + {note.isPinned + ? t('note.menu.unpin') + : t('note.menu.pin')} + + + diff --git a/packages/app/src/locales/en/features.json b/packages/app/src/locales/en/features.json index 829e44799..17fcb5574 100644 --- a/packages/app/src/locales/en/features.json +++ b/packages/app/src/locales/en/features.json @@ -92,9 +92,7 @@ "addToFavorites": "Add to favorites", "removeFromFavorites": "Remove from favorites", "moveToArchive": "Move to archive", - "removeFromArchive": "Remove from archive", - "pinNote": "Pin note", - "unpinNote": "Unpin note" + "removeFromArchive": "Remove from archive" }, "tags": { "addPlaceholder": "Type to add tag…" @@ -118,7 +116,9 @@ "removeFromArchive": "Remove from archive", "deleteToBin": "Delete to bin", "deletePermanently": "Delete permanently", - "restoreFromBin": "Restore from bin" + "restoreFromBin": "Restore from bin", + "pin": "Pin note", + "unpin": "Unpin note" }, "version": { "chars": "{{count}} characters", From 2be4eb3c6b43e976ec1e907763755ea98ff7338f Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 15 Jun 2026 20:57:58 +0200 Subject: [PATCH 59/67] chore: simplify --- .../app/src/api/events/workspace/index.tsx | 7 +----- .../MainScreen/NotesListPanel/NotesList.tsx | 17 ++++++++++++- .../NotesListPanel/useNotesData.tsx | 24 ++++++++----------- .../NotesListPanel/useScrollToActiveNote.tsx | 12 ++++------ .../app/src/features/NoteEditor/index.tsx | 2 +- .../app/src/features/NotesContainer/index.tsx | 2 +- .../src/hooks/notes/useNoteCommandHandlers.ts | 10 ++++---- 7 files changed, 39 insertions(+), 35 deletions(-) diff --git a/packages/app/src/api/events/workspace/index.tsx b/packages/app/src/api/events/workspace/index.tsx index 9a1fffd85..7423fd36a 100644 --- a/packages/app/src/api/events/workspace/index.tsx +++ b/packages/app/src/api/events/workspace/index.tsx @@ -7,8 +7,6 @@ export enum WorkspaceEvents { NOTE_HISTORY_UPDATED = 'noteHistoryUpdated', } -export type NoteUpdateReason = 'meta' | 'content'; - /** * Events payload map */ @@ -24,10 +22,7 @@ export type WorkspaceEventsPayloadMap = { * * For example note version has been applied or remote end force updated a note */ - [WorkspaceEvents.NOTE_UPDATED]: { - noteId: NoteId; - reason?: NoteUpdateReason; - }; + [WorkspaceEvents.NOTE_UPDATED]: NoteId; /** * Fired when note has been edited by user and change is committed in DB diff --git a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx index 9286ed3f1..08c79ae66 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx @@ -4,9 +4,11 @@ import { FaPenToSquare } from 'react-icons/fa6'; import { LOCALE_NAMESPACE } from 'src/i18n'; import { Box, Button, Skeleton, Text, VStack } from '@chakra-ui/react'; import { NotePreview } from '@components/NotePreview/NotePreview'; +import { INote, NoteId } from '@core/features/notes'; import { getNoteTitle } from '@core/features/notes/utils'; import { TELEMETRY_EVENT_NAME } from '@core/features/telemetry'; import { getContextMenuCoords } from '@electron/requests/contextMenu/renderer'; +import { useNotesRegistry } from '@features/App/Workspace/WorkspaceProvider'; import { useNoteContextMenu } from '@features/NotesContainer/NoteContextMenu/useNoteContextMenu'; import { useTelemetryTracker } from '@features/telemetry'; import { useCreateNote } from '@hooks/notes/useCreateNote'; @@ -49,7 +51,6 @@ export const NotesList: FC = () => { const parentRef = useRef(null); const isActiveWorkspace = useIsActiveWorkspace(); - // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ enabled: isActiveWorkspace, count: noteIds.length, @@ -72,12 +73,26 @@ export const NotesList: FC = () => { // TODO: add command to scroll a list to note id. Call this command by click note tab // Scroll to active note + const prevActiveNoteRef = useRef(null); + const notesRegistry = useNotesRegistry(); + + const shouldSkipScroll = async (noteId: NoteId): Promise => { + if (noteId !== activeNoteId) return false; + + const [note] = await notesRegistry.getById([noteId]); + if (!note) return false; + + prevActiveNoteRef.current = note; + return note.isPinned !== prevActiveNoteRef.current?.isPinned; + }; + const activeNoteRef = useRef(null); useScrollToActiveNote({ virtualizer, noteIds, activeNoteId, activeNoteRef, + shouldSkipScroll, }); // TODO: implement dragging and moving items diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx index c263321df..d857e20a1 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { NoteUpdateReason, WorkspaceEvents } from '@api/events/workspace'; +import { WorkspaceEvents } from '@api/events/workspace'; import { INote, NoteId } from '@core/features/notes'; import { useEventBus, useNotesRegistry } from '@features/App/Workspace/WorkspaceProvider'; import { useDeepEqualValue } from '@hooks/useDeepEqualValue'; @@ -43,25 +43,21 @@ export const useNotesData = ({ noteIds }: { noteIds: NoteId[] }) => { // Re-fetch note data by changes const eventBus = useEventBus(); useEffect(() => { - const onNoteUpdated = (noteId: NoteId, reason?: NoteUpdateReason) => { + const onNoteUpdated = (noteId: NoteId) => { if (notesData.has(noteId)) { - // Meta changes (e.g. pin/unpin) reorder the notes list, but note data is updated with debounce, - // which can cause stale UI (e.g. after unpinning the note may still show the pin icon) - // To avoid inconsistency immediately refresh the updated note - if (reason === 'meta') { - notesRegistry.getById([noteId]).then((loadedNote) => { - const note = loadedNote[0]; - if (!note) return; - notesData.add([[note.id, note]]); - }); - } + notesRegistry.getById([noteId]).then((loadedNote) => { + const note = loadedNote[0]; + if (!note) return; + notesData.add([[note.id, note]]); + }); + loadNotesData(); } }; return joinCallbacks( - eventBus.listen(WorkspaceEvents.NOTE_UPDATED, ({ noteId, reason }) => - onNoteUpdated(noteId, reason), + eventBus.listen(WorkspaceEvents.NOTE_UPDATED, (noteId) => + onNoteUpdated(noteId), ), eventBus.listen(WorkspaceEvents.NOTE_EDITED, onNoteUpdated), ); diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx index e21a322f2..86b134a60 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx @@ -29,11 +29,13 @@ export const useScrollToActiveNote = ({ noteIds, activeNoteId, activeNoteRef, + shouldSkipScroll, }: { virtualizer: Virtualizer; noteIds: NoteId[]; activeNoteId: NoteId | null; activeNoteRef: React.RefObject; + shouldSkipScroll?: any; }) => { // Reset the scroll once filters changed useOnFiltersChange(() => { @@ -99,17 +101,13 @@ export const useScrollToActiveNote = ({ }; return joinCallbacks( - eventBus.listen(WorkspaceEvents.NOTE_UPDATED, ({ noteId, reason }) => { - // Ignore meta changes (pin, bookmarks, etc) and do not change the scroll position, - // because users may update note metadata while browsing the notes - // and losing the scroll position would break their browsing context - if (reason === 'meta') return; - + eventBus.listen(WorkspaceEvents.NOTE_UPDATED, async (noteId) => { + if (await shouldSkipScroll?.(noteId)) return; onNoteUpdated(noteId); }), eventBus.listen(WorkspaceEvents.NOTE_EDITED, onNoteUpdated), ); - }, [activeNoteId, activeNoteRef, eventBus]); + }, [activeNoteId, activeNoteRef, eventBus, shouldSkipScroll]); useEffect(() => { if (activeNoteId === null) return; diff --git a/packages/app/src/features/NoteEditor/index.tsx b/packages/app/src/features/NoteEditor/index.tsx index db9b028c5..3533c9445 100644 --- a/packages/app/src/features/NoteEditor/index.tsx +++ b/packages/app/src/features/NoteEditor/index.tsx @@ -578,7 +578,7 @@ export const Note2: FC = memo( ); eventBus.emit( WorkspaceEvents.NOTE_UPDATED, - { noteId: note.id }, + note.id, ); forceUpdateLocalStateRef.current = true; }} diff --git a/packages/app/src/features/NotesContainer/index.tsx b/packages/app/src/features/NotesContainer/index.tsx index bbf03dd88..553eced6c 100644 --- a/packages/app/src/features/NotesContainer/index.tsx +++ b/packages/app/src/features/NotesContainer/index.tsx @@ -51,7 +51,7 @@ export const NotesContainer: FC = ({ ...props }) => { useEffect(() => { return joinCallbacks( eventBus.listen(WorkspaceEvents.NOTES_UPDATED, updateNotes), - eventBus.listen(WorkspaceEvents.NOTE_UPDATED, ({ noteId }) => { + eventBus.listen(WorkspaceEvents.NOTE_UPDATED, (noteId) => { updateNotes(); notesRegistry.getById([noteId]).then(([note]) => { if (note) noteUpdated(note); diff --git a/packages/app/src/hooks/notes/useNoteCommandHandlers.ts b/packages/app/src/hooks/notes/useNoteCommandHandlers.ts index 052a20425..3ae822bda 100644 --- a/packages/app/src/hooks/notes/useNoteCommandHandlers.ts +++ b/packages/app/src/hooks/notes/useNoteCommandHandlers.ts @@ -44,7 +44,7 @@ export const useNoteCommandHandlers = () => { await noteActions.close(noteId); await notes.updateMeta([noteId], { isDeleted: true }); - eventBus.emit(WorkspaceEvents.NOTE_UPDATED, { noteId }); + eventBus.emit(WorkspaceEvents.NOTE_UPDATED, noteId); telemetry.track(TELEMETRY_EVENT_NAME.NOTE_DELETED, { permanently: 'false', @@ -81,7 +81,7 @@ export const useNoteCommandHandlers = () => { if (!note || !note.isDeleted) return; await notes.updateMeta([noteId], { isDeleted: false }); - eventBus.emit(WorkspaceEvents.NOTE_UPDATED, { noteId }); + eventBus.emit(WorkspaceEvents.NOTE_UPDATED, noteId); telemetry.track(TELEMETRY_EVENT_NAME.NOTE_RESTORED_FROM_BIN); }, @@ -153,7 +153,7 @@ export const useNoteCommandHandlers = () => { await notes.updateMeta([noteId], { isArchived: newArchivedState, }); - eventBus.emit(WorkspaceEvents.NOTE_UPDATED, { noteId }); + eventBus.emit(WorkspaceEvents.NOTE_UPDATED, noteId); telemetry.track(TELEMETRY_EVENT_NAME.NOTE_ARCHIVE_TOGGLE, { action: newArchivedState ? 'Added' : 'Removed', @@ -174,7 +174,7 @@ export const useNoteCommandHandlers = () => { await notes.updateMeta([noteId], { isBookmarked: newBookmarkedState, }); - eventBus.emit(WorkspaceEvents.NOTE_UPDATED, { noteId }); + eventBus.emit(WorkspaceEvents.NOTE_UPDATED, noteId); telemetry.track(TELEMETRY_EVENT_NAME.NOTE_BOOKMARK_TOGGLE, { action: newBookmarkedState ? 'Added' : 'Removed', @@ -193,7 +193,7 @@ export const useNoteCommandHandlers = () => { await notes.updateMeta([noteId], { isPinned: newPinnedState, }); - eventBus.emit(WorkspaceEvents.NOTE_UPDATED, { noteId, reason: 'meta' }); + eventBus.emit(WorkspaceEvents.NOTE_UPDATED, noteId); telemetry.track(TELEMETRY_EVENT_NAME.NOTE_PIN_TOGGLE, { action: newPinnedState ? 'Pinned' : 'Unpinned', From 69e3f4921beec5b767bc165d744b2b209ced149a Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 15 Jun 2026 21:04:04 +0200 Subject: [PATCH 60/67] test: rename --- .../NotesController.filters.test.ts | 74 ----------------- .../controller/NotesController.sort.test.ts | 79 +++++++++++++++++++ 2 files changed, 79 insertions(+), 74 deletions(-) create mode 100644 packages/app/src/core/features/notes/controller/NotesController.sort.test.ts diff --git a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts index 78c2b9f06..b256ef056 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts @@ -66,80 +66,6 @@ test('filter by update time', async () => { ]); }); -test('sorts notes by pinned time', async () => { - const db = await openSQLite(createFileControllerMock()); - onTestFinished(db.close); - - const workspaceId = await createWorkspaceId(db.get()); - const registry = new NotesController(db.get(), workspaceId); - - const note1 = await registry.add({ title: '2001', text: 'Dummy text' }); - const note2 = await registry.add({ title: '2002', text: 'Dummy text' }); - const note3 = await registry.add({ title: '2003', text: 'Dummy text' }); - - // Pin - vi.setSystemTime('01/01/2010 12:00'); - await registry.updateMeta([note1], { isPinned: true }); - vi.setSystemTime('01/01/2011 12:00'); - await registry.updateMeta([note3], { isPinned: true }); - - await expect( - registry.query({ sort: [{ by: 'pinnedAt', order: 'desc' }] }), - ).resolves.toStrictEqual([note3, note1, note2]); - - // Updating note content in a pinned note should not affect the order - vi.setSystemTime('01/01/2015 12:00'); - await registry.update(note1, { title: '2015', text: 'Dummy text' }); - - await expect( - registry.query({ sort: [{ by: 'pinnedAt', order: 'desc' }] }), - ).resolves.toStrictEqual([note3, note1, note2]); -}); - -test('sorts notes by pinned time and update time', async () => { - const db = await openSQLite(createFileControllerMock()); - onTestFinished(db.close); - - const workspaceId = await createWorkspaceId(db.get()); - const registry = new NotesController(db.get(), workspaceId); - - vi.setSystemTime('01/01/2000 12:00'); - const note1 = await registry.add({ title: '2001', text: 'Dummy text' }); - - vi.setSystemTime('01/01/2002 12:00'); - const note2 = await registry.add({ title: '2002', text: 'Dummy text' }); - - vi.setSystemTime('01/01/2004 12:00'); - const note3 = await registry.add({ title: '2004', text: 'Dummy text' }); - - // Pin note1 - vi.setSystemTime('01/01/2010 12:00'); - await registry.updateMeta([note1], { isPinned: true }); - - // Pinned notes come first, unpinned notes are sorted by update time - await expect( - registry.query({ - sort: [ - { by: 'pinnedAt', order: 'desc' }, - { by: 'updatedAt', order: 'desc' }, - ], - }), - ).resolves.toStrictEqual([note1, note3, note2]); - - // Update an unpinned note2 - vi.setSystemTime('01/01/2015 12:00'); - await registry.update(note2, { title: '2015', text: 'Dummy text' }); - - await expect( - registry.query({ - sort: [ - { by: 'pinnedAt', order: 'desc' }, - { by: 'updatedAt', order: 'desc' }, - ], - }), - ).resolves.toStrictEqual([note1, note2, note3]); -}); - describe('data fetching', () => { const getWorkspaceContext = createWorkspaceContext(); diff --git a/packages/app/src/core/features/notes/controller/NotesController.sort.test.ts b/packages/app/src/core/features/notes/controller/NotesController.sort.test.ts new file mode 100644 index 000000000..39a8af1be --- /dev/null +++ b/packages/app/src/core/features/notes/controller/NotesController.sort.test.ts @@ -0,0 +1,79 @@ +import { openSQLite } from '@core/database/sqlite/openSQLite'; +import { createWorkspaceId } from '@tests/utils/vaultContext'; +import { createFileControllerMock } from '@utils/mocks/fileControllerMock'; + +import { NotesController } from './NotesController'; + +test('updating a pinned note does not affect the order of pinned notes', async () => { + const db = await openSQLite(createFileControllerMock()); + onTestFinished(db.close); + + const workspaceId = await createWorkspaceId(db.get()); + const registry = new NotesController(db.get(), workspaceId); + + const note1 = await registry.add({ title: '2001', text: 'Dummy text' }); + const note2 = await registry.add({ title: '2002', text: 'Dummy text' }); + const note3 = await registry.add({ title: '2003', text: 'Dummy text' }); + + // Pin + vi.setSystemTime('01/01/2010 12:00'); + await registry.updateMeta([note1], { isPinned: true }); + vi.setSystemTime('01/01/2011 12:00'); + await registry.updateMeta([note3], { isPinned: true }); + + await expect( + registry.query({ sort: [{ by: 'pinnedAt', order: 'desc' }] }), + ).resolves.toStrictEqual([note3, note1, note2]); + + // Updating note content in a pinned note should not affect the order + vi.setSystemTime('01/01/2015 12:00'); + await registry.update(note1, { title: '2015', text: 'Dummy text' }); + + await expect( + registry.query({ sort: [{ by: 'pinnedAt', order: 'desc' }] }), + ).resolves.toStrictEqual([note3, note1, note2]); +}); + +test('updating an unpinned note does not affect the order of pinned notes', async () => { + const db = await openSQLite(createFileControllerMock()); + onTestFinished(db.close); + + const workspaceId = await createWorkspaceId(db.get()); + const registry = new NotesController(db.get(), workspaceId); + + vi.setSystemTime('01/01/2000 12:00'); + const note1 = await registry.add({ title: '2001', text: 'Dummy text' }); + + vi.setSystemTime('01/01/2002 12:00'); + const note2 = await registry.add({ title: '2002', text: 'Dummy text' }); + + vi.setSystemTime('01/01/2004 12:00'); + const note3 = await registry.add({ title: '2004', text: 'Dummy text' }); + + // Pin note1 + vi.setSystemTime('01/01/2010 12:00'); + await registry.updateMeta([note1], { isPinned: true }); + + // Pinned notes come first, unpinned notes are sorted by update time + await expect( + registry.query({ + sort: [ + { by: 'pinnedAt', order: 'desc' }, + { by: 'updatedAt', order: 'desc' }, + ], + }), + ).resolves.toStrictEqual([note1, note3, note2]); + + // Update an unpinned note2 + vi.setSystemTime('01/01/2015 12:00'); + await registry.update(note2, { title: '2015', text: 'Dummy text' }); + + await expect( + registry.query({ + sort: [ + { by: 'pinnedAt', order: 'desc' }, + { by: 'updatedAt', order: 'desc' }, + ], + }), + ).resolves.toStrictEqual([note1, note2, note3]); +}); From 6043d9440dd1d15359ae0de9f1f46b74f627c1fb Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 15 Jun 2026 21:11:04 +0200 Subject: [PATCH 61/67] refactor: rename slot in chakra recipe --- .../app/src/components/NotePreview/NotePreview.theme.ts | 5 ++--- packages/app/src/components/NotePreview/NotePreview.tsx | 8 +++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/NotePreview/NotePreview.theme.ts b/packages/app/src/components/NotePreview/NotePreview.theme.ts index 8e43d5082..70fca6a13 100644 --- a/packages/app/src/components/NotePreview/NotePreview.theme.ts +++ b/packages/app/src/components/NotePreview/NotePreview.theme.ts @@ -1,7 +1,7 @@ import { defineSlotRecipe } from '@chakra-ui/react'; export const notePreviewRecipe = defineSlotRecipe({ - slots: ['root', 'body', 'title', 'text', 'meta', 'pinIcon', 'header'], + slots: ['root', 'body', 'title', 'text', 'meta', 'header', 'icon'], base: { root: { cursor: 'pointer', @@ -26,9 +26,8 @@ export const notePreviewRecipe = defineSlotRecipe({ fontWeight: 'bold', fontSize: '18px', }, - pinIcon: { + icon: { color: 'typography.secondary', - transform: 'rotate(45deg)', fontSize: '12px', }, text: { diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index e4e3223b9..42c20e133 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -40,7 +40,13 @@ export const NotePreviewContent = memo( /> - {isPinned && } + {isPinned && ( + + )} {text.length > 0 ? ( From e2832147fe1f9fb83b6a3b1bf57efab1241a140c Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 15 Jun 2026 21:42:38 +0200 Subject: [PATCH 62/67] refactor: add event to distinguish event --- packages/app/src/api/events/workspace/index.tsx | 8 ++++++++ .../MainScreen/NotesListPanel/NotesList.tsx | 16 ---------------- .../NotesListPanel/useScrollToActiveNote.tsx | 15 ++++++++++----- .../src/hooks/notes/useNoteCommandHandlers.ts | 2 ++ 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/packages/app/src/api/events/workspace/index.tsx b/packages/app/src/api/events/workspace/index.tsx index 7423fd36a..f311854d3 100644 --- a/packages/app/src/api/events/workspace/index.tsx +++ b/packages/app/src/api/events/workspace/index.tsx @@ -5,6 +5,7 @@ export enum WorkspaceEvents { NOTE_UPDATED = 'noteUpdated', NOTE_EDITED = 'noteChanged', NOTE_HISTORY_UPDATED = 'noteHistoryUpdated', + NOTE_META_UPDATED = 'noteMetaChanged', } /** @@ -24,6 +25,13 @@ export type WorkspaceEventsPayloadMap = { */ [WorkspaceEvents.NOTE_UPDATED]: NoteId; + /** + * Fired when specific note metadata has been updated and change is committed in DB + * + * For example pin state or other note properties that do not affect its content + */ + [WorkspaceEvents.NOTE_META_UPDATED]: NoteId; + /** * Fired when note has been edited by user and change is committed in DB */ diff --git a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx index 08c79ae66..d1ef3556b 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx @@ -4,11 +4,9 @@ import { FaPenToSquare } from 'react-icons/fa6'; import { LOCALE_NAMESPACE } from 'src/i18n'; import { Box, Button, Skeleton, Text, VStack } from '@chakra-ui/react'; import { NotePreview } from '@components/NotePreview/NotePreview'; -import { INote, NoteId } from '@core/features/notes'; import { getNoteTitle } from '@core/features/notes/utils'; import { TELEMETRY_EVENT_NAME } from '@core/features/telemetry'; import { getContextMenuCoords } from '@electron/requests/contextMenu/renderer'; -import { useNotesRegistry } from '@features/App/Workspace/WorkspaceProvider'; import { useNoteContextMenu } from '@features/NotesContainer/NoteContextMenu/useNoteContextMenu'; import { useTelemetryTracker } from '@features/telemetry'; import { useCreateNote } from '@hooks/notes/useCreateNote'; @@ -73,26 +71,12 @@ export const NotesList: FC = () => { // TODO: add command to scroll a list to note id. Call this command by click note tab // Scroll to active note - const prevActiveNoteRef = useRef(null); - const notesRegistry = useNotesRegistry(); - - const shouldSkipScroll = async (noteId: NoteId): Promise => { - if (noteId !== activeNoteId) return false; - - const [note] = await notesRegistry.getById([noteId]); - if (!note) return false; - - prevActiveNoteRef.current = note; - return note.isPinned !== prevActiveNoteRef.current?.isPinned; - }; - const activeNoteRef = useRef(null); useScrollToActiveNote({ virtualizer, noteIds, activeNoteId, activeNoteRef, - shouldSkipScroll, }); // TODO: implement dragging and moving items diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx index 86b134a60..84e080ca3 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx @@ -29,13 +29,11 @@ export const useScrollToActiveNote = ({ noteIds, activeNoteId, activeNoteRef, - shouldSkipScroll, }: { virtualizer: Virtualizer; noteIds: NoteId[]; activeNoteId: NoteId | null; activeNoteRef: React.RefObject; - shouldSkipScroll?: any; }) => { // Reset the scroll once filters changed useOnFiltersChange(() => { @@ -89,6 +87,7 @@ export const useScrollToActiveNote = ({ // Focus active note by changes in case it was in viewport const wasActiveNoteInViewport = useRef(false); + const skipScrollRef = useRef(false); const eventBus = useEventBus(); useEffect(() => { if (activeNoteId === null) return; @@ -101,13 +100,19 @@ export const useScrollToActiveNote = ({ }; return joinCallbacks( - eventBus.listen(WorkspaceEvents.NOTE_UPDATED, async (noteId) => { - if (await shouldSkipScroll?.(noteId)) return; + eventBus.listen(WorkspaceEvents.NOTE_META_UPDATED, (noteId) => { + if (noteId === activeNoteId) skipScrollRef.current = true; + }), + eventBus.listen(WorkspaceEvents.NOTE_UPDATED, (noteId) => { + if (skipScrollRef.current) { + skipScrollRef.current = false; + return; + } onNoteUpdated(noteId); }), eventBus.listen(WorkspaceEvents.NOTE_EDITED, onNoteUpdated), ); - }, [activeNoteId, activeNoteRef, eventBus, shouldSkipScroll]); + }, [activeNoteId, activeNoteRef, eventBus]); useEffect(() => { if (activeNoteId === null) return; diff --git a/packages/app/src/hooks/notes/useNoteCommandHandlers.ts b/packages/app/src/hooks/notes/useNoteCommandHandlers.ts index 3ae822bda..c17396956 100644 --- a/packages/app/src/hooks/notes/useNoteCommandHandlers.ts +++ b/packages/app/src/hooks/notes/useNoteCommandHandlers.ts @@ -193,6 +193,8 @@ export const useNoteCommandHandlers = () => { await notes.updateMeta([noteId], { isPinned: newPinnedState, }); + + eventBus.emit(WorkspaceEvents.NOTE_META_UPDATED, noteId); eventBus.emit(WorkspaceEvents.NOTE_UPDATED, noteId); telemetry.track(TELEMETRY_EVENT_NAME.NOTE_PIN_TOGGLE, { From 31c5699bd840fd377a126f19d055c8addcbb0eaa Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 15 Jun 2026 21:48:50 +0200 Subject: [PATCH 63/67] chore: rename --- packages/app/src/features/NoteEditor/NoteMenu.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app/src/features/NoteEditor/NoteMenu.tsx b/packages/app/src/features/NoteEditor/NoteMenu.tsx index 4bf8a2667..00ad0159e 100644 --- a/packages/app/src/features/NoteEditor/NoteMenu.tsx +++ b/packages/app/src/features/NoteEditor/NoteMenu.tsx @@ -51,7 +51,7 @@ export const NoteMenu = memo(({ note }: { note: INote }) => { }, ) } - value="pin" + value="togglePin" > { }, ) } - value="pin" > Date: Mon, 15 Jun 2026 21:50:27 +0200 Subject: [PATCH 64/67] chore: disable eslint rule --- .../app/src/features/MainScreen/NotesListPanel/NotesList.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx index d1ef3556b..9286ed3f1 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx @@ -49,6 +49,7 @@ export const NotesList: FC = () => { const parentRef = useRef(null); const isActiveWorkspace = useIsActiveWorkspace(); + // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ enabled: isActiveWorkspace, count: noteIds.length, From 1caa3b7b8eebefaa0c35f4cd92d5dda235028cd0 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 15 Jun 2026 21:51:44 +0200 Subject: [PATCH 65/67] chore: fix after rebase --- .../app/src/features/NoteEditor/NoteMenu.tsx | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/packages/app/src/features/NoteEditor/NoteMenu.tsx b/packages/app/src/features/NoteEditor/NoteMenu.tsx index 00ad0159e..a0b96d51c 100644 --- a/packages/app/src/features/NoteEditor/NoteMenu.tsx +++ b/packages/app/src/features/NoteEditor/NoteMenu.tsx @@ -65,29 +65,6 @@ export const NoteMenu = memo(({ note }: { note: INote }) => { - - runCommand( - GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, - { - noteId: note.id, - }, - ) - } - > - - - - {note.isPinned - ? t('note.menu.unpin') - : t('note.menu.pin')} - - - From 18cea8d0054dfa66d49ffe68d6ad3b29b0ac6428 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 15 Jun 2026 22:07:38 +0200 Subject: [PATCH 66/67] chore: add comment --- .../src/features/MainScreen/NotesListPanel/useNotesData.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx index d857e20a1..417c9ed29 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx @@ -45,6 +45,8 @@ export const useNotesData = ({ noteIds }: { noteIds: NoteId[] }) => { useEffect(() => { const onNoteUpdated = (noteId: NoteId) => { if (notesData.has(noteId)) { + // When a note is updated should refresh immediately, otherwise the note date may be outdated + // For example, after a pin status update the moved note may still have stale pin state notesRegistry.getById([noteId]).then((loadedNote) => { const note = loadedNote[0]; if (!note) return; @@ -56,9 +58,7 @@ export const useNotesData = ({ noteIds }: { noteIds: NoteId[] }) => { }; return joinCallbacks( - eventBus.listen(WorkspaceEvents.NOTE_UPDATED, (noteId) => - onNoteUpdated(noteId), - ), + eventBus.listen(WorkspaceEvents.NOTE_UPDATED, onNoteUpdated), eventBus.listen(WorkspaceEvents.NOTE_EDITED, onNoteUpdated), ); }, [eventBus, loadNotesData, notesData, notesRegistry]); From 8fa741ef7f2628fd7ada3f562da035fd11ea36f3 Mon Sep 17 00:00:00 2001 From: Irina Katsyuta Date: Mon, 15 Jun 2026 22:54:52 +0200 Subject: [PATCH 67/67] chore: docs --- packages/app/src/api/events/workspace/index.tsx | 2 +- .../src/features/MainScreen/NotesListPanel/useNotesData.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/app/src/api/events/workspace/index.tsx b/packages/app/src/api/events/workspace/index.tsx index f311854d3..6fc702b3d 100644 --- a/packages/app/src/api/events/workspace/index.tsx +++ b/packages/app/src/api/events/workspace/index.tsx @@ -28,7 +28,7 @@ export type WorkspaceEventsPayloadMap = { /** * Fired when specific note metadata has been updated and change is committed in DB * - * For example pin state or other note properties that do not affect its content + * Metadata includes all note properties except the title and content */ [WorkspaceEvents.NOTE_META_UPDATED]: NoteId; diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx index 417c9ed29..377b33e0e 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx @@ -45,8 +45,9 @@ export const useNotesData = ({ noteIds }: { noteIds: NoteId[] }) => { useEffect(() => { const onNoteUpdated = (noteId: NoteId) => { if (notesData.has(noteId)) { - // When a note is updated should refresh immediately, otherwise the note date may be outdated - // For example, after a pin status update the moved note may still have stale pin state + // Update the note immediately, otherwise it may temporarily display stale data + // e.g. after unpinning, the note moves but still shows the pinned icon + // until the cache is updated and it is re-rendered with the correct state notesRegistry.getById([noteId]).then((loadedNote) => { const note = loadedNote[0]; if (!note) return;