Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
2c1fa7a
feat: add pinned field to note
katsyuta Apr 28, 2026
85a1253
feat: implement pin and unpin
katsyuta Apr 28, 2026
b5eda29
refactor: move text to local file
katsyuta Apr 28, 2026
819eaeb
refactor: improve style
katsyuta Apr 28, 2026
82a39fa
chore: rename
katsyuta Apr 28, 2026
42a3fc2
refactor: handle toggle separate of open click
katsyuta Apr 28, 2026
3fee3b6
refactor: use button instead icon
katsyuta Apr 29, 2026
449775a
refactor: use visibility property
katsyuta Apr 30, 2026
98c3916
fix: handle keyboard focus
katsyuta Apr 30, 2026
e2408d5
chore: add pin to menu, remove from note preview
katsyuta May 6, 2026
f1501ec
feat: add pin to note menu
katsyuta May 6, 2026
eb76718
refactor: scroll to pinned by if note pinned by context menu
katsyuta May 6, 2026
0df3225
fix: scroll only after list ids was updated
katsyuta May 6, 2026
915a954
refactor: not sort by pinned in bin and search mode
katsyuta May 6, 2026
687d338
chore: return conditional sorting
katsyuta May 7, 2026
ea0aed3
chore: rename
katsyuta May 7, 2026
90dd39b
feat: add style for pinned note
katsyuta May 8, 2026
e12bf96
refactor: remove command
katsyuta May 8, 2026
a69a3c9
chore: move style to theme file
katsyuta May 8, 2026
217dbd8
chore: remove old
katsyuta May 8, 2026
7101e06
feat: keep pin order
katsyuta May 9, 2026
5354ad4
chore: not scroll if note visible
katsyuta May 9, 2026
f3c8f6c
chore: rename css atr
katsyuta May 9, 2026
08e1981
chore: simplify sort
katsyuta May 9, 2026
7fa9723
refactor: update sort api
katsyuta May 9, 2026
c3fd8e6
test: add
katsyuta May 9, 2026
e2680e8
chore: update css
katsyuta May 9, 2026
5d6ab8b
chore: lift menu item
katsyuta May 9, 2026
101b973
refactor: combine pin effects
katsyuta May 9, 2026
8251acf
fix: do not wait before highliht note
katsyuta May 9, 2026
9d28d03
chore: update comment
katsyuta May 10, 2026
e76c2df
test: rename
katsyuta May 10, 2026
6833074
fix: filter by pin status have incorrect sql
katsyuta May 10, 2026
b6f5637
fix: old clear timeout can remove new timeout
katsyuta May 10, 2026
d98e917
refactor: do not scroll if element in viewport
katsyuta May 13, 2026
9396ff0
fix: after rebase
katsyuta May 18, 2026
733d255
refactor: pin action available only in note list context menu
katsyuta May 18, 2026
4c6d90e
refactor: not scroll after pin and unpin
katsyuta May 19, 2026
9bb4a22
refactor: remove higliting note after pin
katsyuta May 19, 2026
65f10fb
refactor: do not scroll while note meta was updated
katsyuta May 19, 2026
ab5e313
chore: remove pin controller from note menu
katsyuta May 19, 2026
915f7c1
chore: pin in preview is optional
katsyuta May 19, 2026
e3360e5
chore: typo
katsyuta May 19, 2026
f4ab7de
chore: do not pass payload
katsyuta May 19, 2026
20fe675
test: update check
katsyuta May 19, 2026
df56790
chore: simplify
katsyuta May 20, 2026
b18602c
test: rename, add test case
katsyuta May 20, 2026
f12c315
chore: remove unnecessary text
katsyuta May 20, 2026
107bd30
chore: fix typo
katsyuta May 20, 2026
35ba6b4
fix: after unpin the pin icon is blink in note preview
katsyuta May 21, 2026
e91b7cf
fix: disable native auto scroll after update note meta
katsyuta May 21, 2026
96c4591
chore: typo
katsyuta May 21, 2026
ea4a5f6
chore: simplify
katsyuta May 21, 2026
5d576e1
chore: add type
katsyuta May 21, 2026
21df539
chore: typo
katsyuta May 22, 2026
f9d7ed3
refactor: use index for pinned field
katsyuta May 22, 2026
e50d37a
fix: after rebase
katsyuta Jun 8, 2026
eba60ee
refactor: remove pin from note editor
katsyuta Jun 9, 2026
2be4eb3
chore: simplify
katsyuta Jun 15, 2026
69e3f49
test: rename
katsyuta Jun 15, 2026
6043d94
refactor: rename slot in chakra recipe
katsyuta Jun 15, 2026
e283214
refactor: add event to distinguish event
katsyuta Jun 15, 2026
31c5699
chore: rename
katsyuta Jun 15, 2026
210c33e
chore: disable eslint rule
katsyuta Jun 15, 2026
1caa3b7
chore: fix after rebase
katsyuta Jun 15, 2026
18cea8d
chore: add comment
katsyuta Jun 15, 2026
8fa741e
chore: docs
katsyuta Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/app/src/api/events/workspace/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export enum WorkspaceEvents {
NOTE_UPDATED = 'noteUpdated',
NOTE_EDITED = 'noteChanged',
NOTE_HISTORY_UPDATED = 'noteHistoryUpdated',
NOTE_META_UPDATED = 'noteMetaChanged',

@katsyuta katsyuta Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the NOTE_UPDATED event is fired, we update the scroll position.

The NOTE_UPDATED event is triggered for any change to a note. However, when a user pins a note, we should not scroll the list. Doing so interrupts the user's workflow, because they then have to manually scroll back to the position they were viewing.

We have no way to distinguish which note updates should trigger scrolling and which should not.
With new event when only a note's metadata changes (such as its pinned status), we can avoid scrolling the list.

}

/**
Expand All @@ -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
*
* Metadata includes all note properties except the title and content
*/
[WorkspaceEvents.NOTE_META_UPDATED]: NoteId;

/**
* Fired when note has been edited by user and change is committed in DB
*/
Expand Down
11 changes: 10 additions & 1 deletion packages/app/src/components/NotePreview/NotePreview.theme.ts
Original file line number Diff line number Diff line change
@@ -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', 'header', 'icon'],
base: {
root: {
cursor: 'pointer',
Expand All @@ -16,11 +16,20 @@ export const notePreviewRecipe = defineSlotRecipe({
gap: '0.2rem',
alignItems: 'normal',
maxWidth: '100%',
width: '100%',
},
header: {
width: '100%',
justifyContent: 'space-between',
},
title: {
fontWeight: 'bold',
fontSize: '18px',
},
icon: {
color: 'typography.secondary',
fontSize: '12px',
},
text: {
fontSize: '14px',
maxWidth: '100%',
Expand Down
34 changes: 24 additions & 10 deletions packages/app/src/components/NotePreview/NotePreview.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,11 +16,13 @@ export const NotePreviewContent = memo(
text,
textToHighlight,
meta,
isPinned,
}: {
title: string;
text: string;
meta?: NotePreviewMeta;
textToHighlight?: string;
isPinned?: boolean;
}) => {
const recipe = useSlotRecipe({ key: 'notePreview' });
const styles = recipe();
Expand All @@ -28,13 +31,23 @@ export const NotePreviewContent = memo(
return (
<>
<Stack css={styles.body}>
<Text as="h3" css={styles.title}>
<TextSample
text={title}
highlightText={textToHighlight}
lengthLimit={50}
/>
</Text>
<HStack css={styles.header}>
<Text as="h3" css={styles.title}>
<TextSample
text={title}
highlightText={textToHighlight}
lengthLimit={50}
/>
</Text>

{isPinned && (
<Box
as={FaThumbtack}
css={styles.icon}
transform="rotate(45deg)"
/>
)}
</HStack>

{text.length > 0 ? (
<Text css={styles.text}>
Expand Down Expand Up @@ -68,8 +81,9 @@ export const NotePreview = forwardRef<
meta?: NotePreviewMeta;
isSelected?: boolean;
textToHighlight?: string;
isPinned?: boolean;
} & StackProps
>(({ title, text, textToHighlight, meta, isSelected, ...props }, ref) => {
>(({ title, text, textToHighlight, meta, isSelected, isPinned, ...props }, ref) => {
const recipe = useSlotRecipe({ key: 'notePreview' });
const styles = recipe();

Expand All @@ -82,7 +96,7 @@ export const NotePreview = forwardRef<

return (
<Stack ref={ref} aria-selected={isSelected} {...props} css={style}>
<NotePreviewContent {...{ title, text, textToHighlight, meta }} />
<NotePreviewContent {...{ title, text, textToHighlight, meta, isPinned }} />
</Stack>
);
});
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/core/database/sqlite/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,8 @@ export async function convertSQLToMigrationObject(
export const getMigrationsList = async (): Promise<SQLiteMigration[]> =>
Promise.all([
convertSQLToMigrationObject('1_init_db', import('./sql/1_init_db.sql')),
convertSQLToMigrationObject(
'2_add_pinned_to_notes',
import('./sql/2_add_pinned_to_notes.sql'),
),
]);
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ALTER TABLE notes
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);
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }) }),
Expand All @@ -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' }) }),
Expand Down Expand Up @@ -286,6 +286,23 @@ 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: 60 })
.then((notes) => notes.map((note) => note.id));

await registry.updateMeta(notesId, { isPinned: true });
await expect(registry.get({ meta: { isPinned: true } })).resolves.toHaveLength(
60,
);
await expect(registry.get({ meta: { isPinned: false } })).resolves.toHaveLength(
notesSample.length - 60,
);
});

test('method getLength consider filters', async () => {
const { db, workspaceId } = getWorkspaceContext();
const registry = new NotesController(db, workspaceId);
Expand Down
Original file line number Diff line number Diff line change
@@ -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]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
Expand Down Expand Up @@ -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();
Expand Down
Loading