Skip to content

Commit cd64bde

Browse files
authored
feat(desktop): add editor back, forward, and zen (#501)
## Summary Editor chrome aligned with the All Notes / Note Templates header row. - **Open in new window** - **Distraction free** — hides sidebar + note list (same width animation) - **Back / Forward** — visit stack, ⌘[ / ⌘] - Edit / split / preview stay in the same row Title sits under the chrome, like the reference. ## Type of Change - [x] New feature ## Checklist - [x] Tests pass (`noteHistory`) - [x] Typecheck renderer - [x] PR targets `develop` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added back and forward navigation for recently viewed notes. - Added distraction-free mode to hide the sidebar and note list. - Added commands to toggle distraction-free mode and open notes in a new window. - Added workspace detail navigation from notebook entries. - Added adjustable motion settings for interface transitions. - **Improvements** - Refined note editor controls and window layouts. - Updated sidebar styling with rounded corners, borders, and clearer hover actions. - Improved animated transitions when panels collapse or resize. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 0e1ddd8 commit cd64bde

144 files changed

Lines changed: 5525 additions & 719 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/electron.vite.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ export default defineConfig({
9797
resolve: {
9898
alias: [
9999
{ find: '@', replacement: resolve(__dirname, 'src/renderer') },
100+
{
101+
find: '@dripnex/tables',
102+
replacement: resolve(__dirname, '../../packages/tables/src/index.ts'),
103+
},
100104
// highlight@1.2.3 nests common@1.5.0; language/markdown nest 1.5.2.
101105
// Two NodeProp identities → HighlightStyle.style(undefined) →
102106
// "tags is not iterable". Pin every import to the desktop copy.

apps/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"@dripnex/storage-core": "workspace:*",
8080
"@dripnex/storage-sqlite": "workspace:*",
8181
"@dripnex/sync-core": "workspace:*",
82+
"@dripnex/tables": "workspace:*",
8283
"@dripnex/wikilinks": "workspace:*",
8384
"@playwright/test": "^1.49.1",
8485
"@types/better-sqlite3": "^7.6.12",

apps/desktop/src/main/handlers/dataHandlers.ts

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,22 @@ import {
2929
type NoteStatus,
3030
} from '@dripnex/core';
3131
import { defineIpcHandler } from '../ipc/registry.js';
32+
import { htmlToPdfBuffer, printHtml } from '../services/printNote.js';
3233
import type { SQLiteNoteRepository, Database } from './types.js';
3334

35+
function safeExportName(suggestedName: string): string {
36+
let safeName =
37+
suggestedName
38+
.normalize('NFC')
39+
// eslint-disable-next-line no-control-regex
40+
.replace(/[/\\:*?"<>|\x00-\x1f.]/g, '')
41+
.substring(0, 80)
42+
.trim() || 'note';
43+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
44+
if (WINDOWS_RESERVED.test(safeName)) safeName = `_${safeName}`;
45+
return safeName;
46+
}
47+
3448
export interface DataHandlerDeps {
3549
dataPaths: DataPaths;
3650
noteRepository: SQLiteNoteRepository;
@@ -164,6 +178,8 @@ export function registerDataHandlers(deps: DataHandlerDeps): void {
164178
updatedAt: note.metadata.updatedAt,
165179
tags: [...note.metadata.tags],
166180
wordCount: note.metadata.wordCount,
181+
taskCount: note.metadata.taskCount,
182+
checkedTaskCount: note.metadata.checkedTaskCount,
167183
archivedAt: note.metadata.archivedAt,
168184
notebookId: note.notebookId,
169185
isArchived: note.metadata.archivedAt !== null,
@@ -190,15 +206,7 @@ export function registerDataHandlers(deps: DataHandlerDeps): void {
190206
channel: 'data:exportNote',
191207
args: z.tuple([z.string().max(1024 * 1024), z.string().max(512)]),
192208
handler: async (content, suggestedName) => {
193-
let safeName =
194-
suggestedName
195-
.normalize('NFC')
196-
// eslint-disable-next-line no-control-regex
197-
.replace(/[/\\:*?"<>|\x00-\x1f.]/g, '')
198-
.substring(0, 80)
199-
.trim() || 'note';
200-
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
201-
if (WINDOWS_RESERVED.test(safeName)) safeName = `_${safeName}`;
209+
const safeName = safeExportName(suggestedName);
202210
const { filePath, canceled } = await dialog.showSaveDialog({
203211
title: 'Export Note',
204212
defaultPath: join(app.getPath('documents'), `${safeName}.md`),
@@ -222,6 +230,55 @@ export function registerDataHandlers(deps: DataHandlerDeps): void {
222230
},
223231
});
224232

233+
defineIpcHandler({
234+
channel: 'data:exportFile',
235+
args: z.tuple([
236+
z.string().max(5 * 1024 * 1024),
237+
z.string().max(512),
238+
z.enum(['md', 'html', 'pdf']),
239+
]),
240+
handler: async (content, suggestedName, kind) => {
241+
const safeName = safeExportName(suggestedName);
242+
const filters =
243+
kind === 'pdf'
244+
? [{ name: 'PDF', extensions: ['pdf'] }]
245+
: kind === 'html'
246+
? [{ name: 'HTML', extensions: ['html'] }]
247+
: [{ name: 'Markdown', extensions: ['md'] }];
248+
const { filePath, canceled } = await dialog.showSaveDialog({
249+
title: 'Export Note',
250+
defaultPath: join(app.getPath('documents'), `${safeName}.${kind}`),
251+
buttonLabel: 'Export',
252+
filters,
253+
});
254+
255+
if (canceled || !filePath) {
256+
return { success: false, error: 'Export cancelled' };
257+
}
258+
259+
try {
260+
if (kind === 'pdf') {
261+
const pdf = await htmlToPdfBuffer(content);
262+
await writeFile(filePath, pdf);
263+
} else {
264+
await writeFile(filePath, content, 'utf-8');
265+
}
266+
return { success: true, path: filePath };
267+
} catch (error) {
268+
return {
269+
success: false,
270+
error: error instanceof Error ? error.message : 'Failed to write file',
271+
};
272+
}
273+
},
274+
});
275+
276+
defineIpcHandler({
277+
channel: 'note:printHtml',
278+
args: z.tuple([z.string().max(5 * 1024 * 1024)]),
279+
handler: html => printHtml(html),
280+
});
281+
225282
defineIpcHandler({
226283
channel: 'data:import',
227284
args: z.tuple([]),

apps/desktop/src/main/handlers/localServerHandlers.ts

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,38 @@
55
* to the renderer (settings UI).
66
*/
77

8-
import { dirname } from 'path';
8+
import { dirname, join } from 'path';
99
import { app } from 'electron';
1010
import { z } from 'zod';
11-
import { createNoteId, createNoteOperation, updateNoteOperation } from '@dripnex/core';
1211
import {
12+
createNoteId,
13+
createNotebook,
14+
createNotebookId,
15+
createNoteOperation,
16+
deleteNoteOperation,
17+
renameNotebook,
18+
setNotebookIcon,
19+
trashNoteOperation,
20+
updateNoteOperation,
21+
} from '@dripnex/core';
22+
import {
23+
ChangeLog,
1324
LocalServer,
1425
getOrCreateApiToken,
1526
type LocalServerHandlers,
1627
} from '../services/localServer.js';
1728
import { resolveMcpLaunch } from '../services/mcpLaunch.js';
1829
import { writeMcpWritesConfig } from '../services/mcpWrites.js';
1930
import { defineIpcHandler } from '../ipc/registry.js';
20-
import type { SQLiteNoteRepository, DataPaths } from './types.js';
31+
import type { SQLiteNoteRepository, SQLiteNotebookRepository, DataPaths } from './types.js';
2132

2233
// ============================================================================
2334
// Types
2435
// ============================================================================
2536

2637
export interface LocalServerHandlerDeps {
2738
noteRepository: SQLiteNoteRepository;
39+
notebookRepository: SQLiteNotebookRepository;
2840
dataPaths: DataPaths;
2941
noteToSnapshot: (note: {
3042
id: string;
@@ -50,6 +62,8 @@ export interface LocalServerHandlerDeps {
5062
updatedAt: string;
5163
tags: string[];
5264
wordCount: number;
65+
taskCount?: number;
66+
checkedTaskCount?: number;
5367
archivedAt: string | null;
5468
isArchived: boolean;
5569
isPinned: boolean;
@@ -63,14 +77,17 @@ export interface LocalServerHandlerDeps {
6377
// ============================================================================
6478

6579
const server = new LocalServer();
80+
const changeLog = new ChangeLog();
6681
let apiToken: string | null = null;
6782

6883
// ============================================================================
6984
// Registration
7085
// ============================================================================
7186

7287
export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void {
73-
const { noteRepository: repo, dataPaths, noteToSnapshot } = deps;
88+
const { noteRepository: repo, notebookRepository, dataPaths, noteToSnapshot } = deps;
89+
changeLog.attach(join(dataPaths.root, 'changes.json'));
90+
void changeLog.load();
7491

7592
// Build handler callbacks that bridge HTTP requests to the note repository
7693
const handlers: LocalServerHandlers = {
@@ -99,13 +116,16 @@ export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void
99116
updatedAt: snap.updatedAt,
100117
tags: snap.tags,
101118
wordCount: snap.wordCount,
119+
taskCount: snap.taskCount,
120+
checkedTaskCount: snap.checkedTaskCount,
102121
isPinned: snap.isPinned,
103122
};
104123
},
105124

106125
async createNote(input) {
107126
const result = await createNoteOperation(input, repo);
108127
if (result.ok) {
128+
changeLog.record('note', result.data.id);
109129
return { ok: true, data: { id: result.data.id } };
110130
}
111131
return { ok: false, error: result.error };
@@ -114,6 +134,7 @@ export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void
114134
async updateNote(id, content) {
115135
const noteId = createNoteId(id);
116136
const result = await updateNoteOperation({ id: noteId, content }, repo);
137+
if (result.ok) changeLog.record('note', id);
117138
return { ok: result.ok, error: result.ok ? undefined : result.error };
118139
},
119140

@@ -134,6 +155,108 @@ export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void
134155
getAppVersion() {
135156
return app.getVersion();
136157
},
158+
159+
async listNotebooks() {
160+
const notebooks = await notebookRepository.getAll();
161+
return notebooks.map(nb => ({
162+
id: nb.id,
163+
name: nb.name,
164+
parentId: nb.parentId,
165+
icon: nb.icon,
166+
}));
167+
},
168+
169+
async listTags() {
170+
return repo.listTags();
171+
},
172+
173+
async deleteNote(id, permanent) {
174+
const noteId = createNoteId(id);
175+
const result = permanent
176+
? await deleteNoteOperation({ id: noteId }, repo)
177+
: await trashNoteOperation({ id: noteId }, repo);
178+
if (result.ok) changeLog.record('note', id, true);
179+
return { ok: result.ok, error: result.ok ? undefined : result.error };
180+
},
181+
182+
async createNotebook(input) {
183+
try {
184+
let parentDepth = 0;
185+
if (input.parentId) {
186+
const parent = await notebookRepository.get(createNotebookId(input.parentId));
187+
if (parent) parentDepth = parent.depth;
188+
}
189+
const nextOrder = await notebookRepository.getNextOrder(
190+
input.parentId ? createNotebookId(input.parentId) : null
191+
);
192+
const notebook = createNotebook({
193+
name: input.name,
194+
parentId: input.parentId ? createNotebookId(input.parentId) : null,
195+
parentDepth,
196+
order: nextOrder,
197+
});
198+
await notebookRepository.save(notebook);
199+
changeLog.record('book', notebook.id);
200+
return { ok: true, data: { id: notebook.id } };
201+
} catch (err) {
202+
return { ok: false, error: err };
203+
}
204+
},
205+
206+
async deleteNotebook(id) {
207+
try {
208+
await notebookRepository.delete(createNotebookId(id));
209+
changeLog.record('book', id, true);
210+
return { ok: true };
211+
} catch (err) {
212+
return { ok: false, error: err };
213+
}
214+
},
215+
216+
async updateNotebook(id, patch) {
217+
try {
218+
const notebook = await notebookRepository.get(createNotebookId(id));
219+
if (!notebook) return { ok: false, error: 'not found' };
220+
let next = notebook;
221+
if (typeof patch.name === 'string' && patch.name.trim()) {
222+
next = renameNotebook(next, patch.name);
223+
}
224+
if (patch.icon !== undefined) {
225+
next = setNotebookIcon(next, patch.icon);
226+
}
227+
await notebookRepository.save(next);
228+
changeLog.record('book', id);
229+
return { ok: true };
230+
} catch (err) {
231+
return { ok: false, error: err };
232+
}
233+
},
234+
235+
async putTag(name, patch) {
236+
try {
237+
const current = name.trim();
238+
if (!current) return { ok: false, error: 'empty name' };
239+
if (patch.color !== undefined) {
240+
repo.setTagColor(current, patch.color);
241+
} else if (!patch.newName) {
242+
repo.setTagColor(current, null);
243+
}
244+
if (patch.newName && patch.newName.trim() && patch.newName.trim() !== current) {
245+
const renamed = repo.renameTag(current, patch.newName);
246+
if (!renamed.ok) return { ok: false, error: renamed.error };
247+
changeLog.record('tag', patch.newName.trim().toLowerCase());
248+
} else {
249+
changeLog.record('tag', current.toLowerCase());
250+
}
251+
return { ok: true };
252+
} catch (err) {
253+
return { ok: false, error: err };
254+
}
255+
},
256+
257+
getChanges(since) {
258+
return changeLog.since(since);
259+
},
137260
};
138261

139262
defineIpcHandler({

apps/desktop/src/main/handlers/notebookHandlers.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
createNotebook,
1111
createTemplatesNotebook,
1212
renameNotebook,
13+
setNotebookIcon,
1314
moveNotebook,
1415
INBOX_NOTEBOOK_ID,
1516
TEMPLATES_NOTEBOOK_ID,
@@ -35,6 +36,7 @@ export function registerNotebookHandlers(deps: NotebookHandlerDeps): void {
3536
order: number;
3637
createdAt: string;
3738
updatedAt: string;
39+
icon: string | null;
3840
}) => ({
3941
id: nb.id,
4042
name: nb.name,
@@ -43,6 +45,7 @@ export function registerNotebookHandlers(deps: NotebookHandlerDeps): void {
4345
order: nb.order,
4446
createdAt: nb.createdAt,
4547
updatedAt: nb.updatedAt,
48+
icon: nb.icon,
4649
});
4750

4851
defineIpcHandler({
@@ -140,6 +143,20 @@ export function registerNotebookHandlers(deps: NotebookHandlerDeps): void {
140143
},
141144
});
142145

146+
defineIpcHandler({
147+
channel: 'notebooks:setIcon',
148+
args: z.tuple([IdSchema, z.string().min(1).max(64).nullable()]),
149+
handler: async (id, icon) => {
150+
const notebook = await repo.get(createNotebookId(id));
151+
if (!notebook) {
152+
throw new Error('Notebook not found');
153+
}
154+
const updated = setNotebookIcon(notebook, icon);
155+
await repo.save(updated);
156+
return serialize(updated);
157+
},
158+
});
159+
143160
defineIpcHandler({
144161
channel: 'notebooks:move',
145162
args: z.tuple([IdSchema, IdSchema.nullable()]),

0 commit comments

Comments
 (0)