diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d112b639..c12b4266 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.29.0", + "version": "2.30.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index 7050a53f..7bc36ee0 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -144,6 +144,11 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'font_size', comment: 'editor + preview font size (px)' }, + mathFontScale: { + section: 'editor', + tomlKey: 'math_font_scale', + comment: 'math size as a percentage (50-200); scales $…$ and $$…$$ in editor and preview' + }, editorLineHeight: { section: 'editor', tomlKey: 'line_height', comment: 'line-height multiplier' }, editorTabSize: { section: 'editor', @@ -276,6 +281,11 @@ const SCALAR_FIELDS: Partial> = { comment: 'code / monospace font; empty = system default' }, // view + atlasEnabled: { + section: 'view', + tomlKey: 'atlas_enabled', + comment: 'the Atlas map view of the vault (sidebar row, command, Space g); on by default' + }, workflowsEnabled: { section: 'view', tomlKey: 'workflows_enabled', diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 8982ff15..2e34fcc3 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -2990,17 +2990,31 @@ function registerIpc(): void { // Workflows are authored as files in the vault, so remote workspaces (which // have no local `.zennotes/workflows`) simply have none. + // Remote workspaces delegate every workflow call to the server's journalled + // workflow API from #608 when it is advertised; older servers stay + // read-only, matching the web client. (#618) + const requireRemoteWorkflows = async () => { + const client = requireRemoteWorkspaceClient(); + if (!(await client.supportsWorkflows())) { + throw new Error( + "This ZenNotes server does not support workflows yet. Update the server and reconnect.", + ); + } + return client; + }; + handle(IPC.VAULT_LIST_WORKFLOWS, async () => { - if (isRemoteWorkspaceActive()) return []; + if (isRemoteWorkspaceActive()) { + const client = requireRemoteWorkspaceClient(); + return (await client.supportsWorkflows()) ? await client.listWorkflows() : []; + } const v = requireVault(); return await listWorkflowFiles(v.root); }); - // Authoring needs the local filesystem, so remote workspaces reject rather - // than resolve: a silent success would leave the editor believing it saved. handle(IPC.VAULT_WRITE_WORKFLOW, async (_e, input: WriteWorkflowInput) => { if (isRemoteWorkspaceActive()) { - throw new Error("Workflows are unavailable on remote vaults"); + return await (await requireRemoteWorkflows()).writeWorkflow(input); } const v = requireVault(); return await writeWorkflowFile(v.root, input); @@ -3008,7 +3022,7 @@ function registerIpc(): void { handle(IPC.VAULT_DELETE_WORKFLOW, async (_e, sourcePath: string) => { if (isRemoteWorkspaceActive()) { - throw new Error("Workflows are unavailable on remote vaults"); + return await (await requireRemoteWorkflows()).deleteWorkflow(sourcePath); } const v = requireVault(); return await deleteWorkflowFile(v.root, sourcePath); @@ -3080,7 +3094,7 @@ function registerIpc(): void { // the dry run and asked for it here. handle(IPC.VAULT_APPLY_WORKFLOW, async (_e, input: ApplyWorkflowInput) => { if (isRemoteWorkspaceActive()) { - throw new Error("Workflows are unavailable on remote vaults"); + return await (await requireRemoteWorkflows()).applyWorkflow(input); } const v = requireVault(); return await applyWorkflowOps(v.root, input); @@ -3091,7 +3105,7 @@ function registerIpc(): void { // is unknown or already undone. handle(IPC.VAULT_UNDO_WORKFLOW_RUN, async (_e, runId: string) => { if (isRemoteWorkspaceActive()) { - throw new Error("Workflows are unavailable on remote vaults"); + return await (await requireRemoteWorkflows()).undoWorkflowRun(runId); } const v = requireVault(); return await undoWorkflowRun(v.root, runId); @@ -3100,13 +3114,21 @@ function registerIpc(): void { // Run history is read from files in the vault, so a remote workspace simply // has none, matching how it reports workflows themselves. handle(IPC.VAULT_LIST_WORKFLOW_RUNS, async () => { - if (isRemoteWorkspaceActive()) return []; + if (isRemoteWorkspaceActive()) { + const client = requireRemoteWorkspaceClient(); + return (await client.supportsWorkflows()) ? await client.listWorkflowRuns() : []; + } const v = requireVault(); return await listWorkflowRuns(v.root); }); handle(IPC.VAULT_DELETE_WORKFLOW_RUNS, async (_e, workflowId: string) => { - if (isRemoteWorkspaceActive()) return 0; + if (isRemoteWorkspaceActive()) { + if (typeof workflowId !== "string" || !workflowId) { + throw new Error("deleteWorkflowRuns needs a workflow id"); + } + return await (await requireRemoteWorkflows()).deleteWorkflowRuns(workflowId); + } if (typeof workflowId !== "string" || !workflowId) { throw new Error("deleteWorkflowRuns needs a workflow id"); } @@ -5051,7 +5073,12 @@ app.whenReady().then(async () => { try { const cfg = await loadConfig(); - const desired = cfg.quickCaptureHotkey || DEFAULT_QUICK_CAPTURE_HOTKEY; + // loadConfig always yields a normalized string here, and empty string is + // the user's explicit "disabled" choice — registerQuickCaptureHotkey("") + // is a clean no-op. Falling back to the default on falsey re-registered + // the shortcut on every launch, which on Wayland invoked the + // global-shortcuts portal and popped GNOME's shortcut dialog. (#615) + const desired = cfg.quickCaptureHotkey; const result = registerQuickCaptureHotkey(desired); if (!result.ok) console.warn(result.error ?? `Failed to bind ${desired}`); } catch (err) { diff --git a/apps/desktop/src/main/remote/server-client.ts b/apps/desktop/src/main/remote/server-client.ts index e1766ebe..9fd43d12 100644 --- a/apps/desktop/src/main/remote/server-client.ts +++ b/apps/desktop/src/main/remote/server-client.ts @@ -39,6 +39,16 @@ export { connectionErrorMessage } /** The server never answered: DNS/refused/timeout. The workspace may be * fine; the network is not. Callers must never read this as "absent". */ +import type { + ApplyWorkflowInput, + WorkflowFile, + WorkflowRunReceipt, + WorkflowRunSummary, + WorkflowUndoResult, + WriteWorkflowInput +} from '@zennotes/bridge-contract/workflows' +import { prepareWorkflowRun } from '@shared/workflows/prepare-run' + export class RemoteConnectionError extends Error {} /** The server answered with a non-2xx status: it is alive and made a @@ -131,6 +141,66 @@ export class RemoteServerClient { return this.jsonRequest(`/api/search/text?${params.toString()}`) } + /** True when the connected server advertises the journalled workflow API + * from #608. Older servers stay read-only, exactly like the web client. */ + async supportsWorkflows(): Promise { + const caps = await this.getCapabilities() + return (caps as { supportsWorkflows?: boolean } | null)?.supportsWorkflows === true + } + + async listWorkflows(): Promise { + return this.jsonRequest('/api/workflows') + } + + async writeWorkflow(input: WriteWorkflowInput): Promise { + return this.jsonRequest('/api/workflows/write', { + method: 'POST', + body: input as unknown as Record + }) + } + + async deleteWorkflow(sourcePath: string): Promise { + await this.jsonRequest('/api/workflows/delete', { method: 'POST', body: { sourcePath } }) + } + + /** Prepare on this side (reads through the server), apply transactionally on + * the server — the same split the web bridge ships for #608. */ + async applyWorkflow(input: ApplyWorkflowInput): Promise { + const settings = await this.getVaultSettings() + const prepared = await prepareWorkflowRun(input, { + read: async (path: string) => { + try { + return (await this.readNote(path)).body + } catch { + return null + } + }, + systemFolderDirs: settings.systemFolderPaths ?? {} + }) + return this.jsonRequest('/api/workflows/apply', { + method: 'POST', + body: prepared as unknown as Record + }) + } + + async undoWorkflowRun(runId: string): Promise { + return this.jsonRequest('/api/workflows/undo', { + method: 'POST', + body: { runId } + }) + } + + async listWorkflowRuns(): Promise { + return this.jsonRequest('/api/workflows/runs') + } + + async deleteWorkflowRuns(workflowId: string): Promise { + return this.jsonRequest('/api/workflows/runs/delete', { + method: 'POST', + body: { workflowId } + }) + } + async readNote(relPath: string): Promise { return this.jsonRequest(`/api/notes/read?path=${encodeURIComponent(relPath)}`) } diff --git a/apps/desktop/src/main/tasklists.test.ts b/apps/desktop/src/main/tasklists.test.ts index 280cfad8..d25e1bd3 100644 --- a/apps/desktop/src/main/tasklists.test.ts +++ b/apps/desktop/src/main/tasklists.test.ts @@ -31,6 +31,16 @@ describe('toggleTaskAtIndex', () => { const md = '- [ ] only one' expect(toggleTaskAtIndex(md, 5, true)).toBe(md) }) + + it('checks an in-progress task off to done', () => { + expect(toggleTaskAtIndex('- [/] started', 0, true)).toBe('- [x] started') + }) + + it('unchecking keeps an in-progress task in progress (#599)', () => { + // set-checked:false arrives from Kanban drops between live columns; + // in-progress already is "not done", so the `/` must survive. + expect(toggleTaskAtIndex('- [/] started', 0, false)).toBe('- [/] started') + }) }) describe('setTaskCheckedAtIndex', () => { diff --git a/apps/desktop/src/mcp/vault-ops.test.ts b/apps/desktop/src/mcp/vault-ops.test.ts index 1417fa68..27fff797 100644 --- a/apps/desktop/src/mcp/vault-ops.test.ts +++ b/apps/desktop/src/mcp/vault-ops.test.ts @@ -3,7 +3,14 @@ import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parseOpenNoteDeepLink } from '../main/deep-links' -import { createNote, listNotes, renameNote, scanAllTasks, searchText } from './vault-ops' +import { + createNote, + listNotes, + renameNote, + scanAllTasks, + searchText, + toggleTaskInBody +} from './vault-ops' // Every note-shaped MCP result carries `link`, the zennotes:// deep link a // model renders as a markdown link so the user can click from chat straight @@ -79,6 +86,15 @@ describe('mcp task states', () => { expect(byContent.get('open')?.inProgress).toBe(false) expect(byContent.get('done')?.checked).toBe(true) }) + + it('toggle follows the app rules: [/] checks off, records stay (#599)', () => { + const body = '- [ ] open\n- [/] started\n- [x] done\n- [-] scrapped\n- [>] gone\n' + expect(toggleTaskInBody(body, 0)).toContain('- [x] open') + expect(toggleTaskInBody(body, 1)).toContain('- [x] started') + expect(toggleTaskInBody(body, 2)).toContain('- [ ] done') + expect(toggleTaskInBody(body, 3)).toContain('- [-] scrapped') + expect(toggleTaskInBody(body, 4)).toContain('- [>] gone') + }) }) describe('remapped system folders (#398)', () => { diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index b38014d2..a6b17548 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -1743,7 +1743,12 @@ export function toggleTaskInBody(body: string, targetIndex: number): string | nu (_m, ch: string, tail: string) => { const fullMatch = original.match(TASK_LINE_RE)! const bracketIdx = original.indexOf('[' + ch + ']') - const next = ch === ' ' ? 'x' : ' ' + // Same rules as the app's toggle (cm-toggle-checkbox / toggleTaskAtIndex): + // open and done flip, in-progress `[/]` checks off to done, and the + // forwarded / cancelled record markers are left alone. `[/]` used to fall + // into the "anything else opens" branch, silently erasing it. (#599) + if (ch === '>' || ch === '-') return fullMatch[0] + const next = /[xX]/.test(ch) ? ' ' : 'x' // Preserve the full prefix (list marker, whitespace) by splicing only // the single character inside the brackets. if (bracketIdx >= 0) { diff --git a/apps/server/package.json b/apps/server/package.json index 0189674a..5bfb15e7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.29.0", + "version": "2.30.0", "scripts": { "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", diff --git a/apps/web/package.json b/apps/web/package.json index c0595dbc..a316d8a6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.29.0", + "version": "2.30.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/docs/ideas/atlas-prototype.html b/docs/ideas/atlas-prototype.html new file mode 100644 index 00000000..bdca7f64 --- /dev/null +++ b/docs/ideas/atlas-prototype.html @@ -0,0 +1,1228 @@ + + +ZenNotes Atlas + + + + +
+
+

Atlas

+

a map of this vault · prototype

+
+
+
/
+
structure
+
+ +
+
+ fjump + /filter + hjklmove + + -zoom + v2d/3d + orings + mtrace + clinks + treplay + 1..5lens + ?help +
+
+
+
+ + + + + +
+ +
+ + + +
+ + diff --git a/docs/ideas/atlas.md b/docs/ideas/atlas.md new file mode 100644 index 00000000..6d54fe3a --- /dev/null +++ b/docs/ideas/atlas.md @@ -0,0 +1,270 @@ +# Atlas + +A map of the vault: every note, every connection, in one navigable place. + +> **Status (August 2026).** Design document, nothing shipped. Written as an +> answer to the recurring request for "a graph view like Obsidian's", from a +> position of not wanting Obsidian's graph. An interactive prototype of the +> feel (fake vault, real interactions) sits next to this doc as +> `atlas-prototype.html`; open it in a browser and press `?`. + +## Problem Statement + +**How might we** let someone see their whole vault at once, follow the +connections between notes, and come away knowing something they did not know +before, without shipping a screensaver? + +People keep asking for "the Obsidian thing". What they are actually asking for +is the promise of that feature: my notes are a body of knowledge, show it to +me. The graph view is the most famous answer and it does not keep the promise. +Naming precisely where it fails tells us what to build instead. + +## Where Obsidian's graph fails + +1. **It is a physics simulation, not a map.** The force layout re-runs every + time the view opens. Nothing is ever where it was yesterday. Spatial + memory, the one superpower humans bring to maps ("the cooking stuff lives + bottom-left"), never gets a chance to form. +2. **Every node is an identical dot.** Which note is a hub, which is abandoned, + which is a week old: invisible. The screen shows topology and hides all + semantics. +3. **It answers no questions.** You can stare at it, zoom it, and post a + screenshot of it. You cannot ask it anything. There is no path from + "pretty" to "I learned something about my own thinking". +4. **It is mouse-only.** Disqualifying for this app on its own. + +At 1,000+ notes these compound into the famous hairball. The antidotes +(filters, groups) are buried in a settings drawer and reset with the layout. + +## The idea: cartography, not physics + +Atlas treats the vault as territory and draws it the way real maps are drawn: +computed once, updated incrementally, stable for years. + +**Pillar 1: a stable map.** The layout is deterministic and cached in the +vault. A new note lands next to its strongest connection; nothing else moves. +Opening Atlas next month shows the same geography as today, grown at the +edges. Reflowing the whole map is an explicit command the user runs on +purpose, never a side effect of opening the view. Stability is the feature; +everything else builds on it. + +**Pillar 2: regions with names.** Notes cluster by their link structure and +tag kinship into regions, each drawn as a soft territory with a name derived +from its dominant tag (or hub note), renamable by the user. The vault reads +like a map: Systems Programming up north, Book Notes to the east, the Journal +running along the south. + +**Pillar 3: semantic zoom.** Like a real map, altitude controls detail. Zoomed +out: region names and hub notes only, a continent view. Mid zoom: every note, +labels on hubs. Close: every label, every edge. There is no zoom level at +which the screen is a cloud of unlabeled dots. + +**Pillar 4: keyboard-first.** Nobody has shipped a graph you can drive without +a mouse. Atlas is fully navigable with vim keys, hint mode included. This is +the part only ZenNotes can build, because the machinery (VimNav, HintOverlay, +which-key) already exists. + +**Pillar 5: lenses, and a map that writes back.** The map is an instrument. +Lenses recolor it to answer questions (what is alive, what is orphaned, what +bridges two fields, how did I get from A to B). And when Atlas notices two +notes that should be connected and are not, it shows the ghost of that edge; +accepting it writes a real wikilink into the note. Insight becomes structure. +Obsidian's graph is a poster. Atlas closes the loop. + +## Vocabulary (ships on the surface) + +| Word | Meaning | +| ---------- | ------------------------------------------------------------ | +| **Atlas** | The view itself. `Space g`, `:atlas`, a Home tile. | +| **Region** | A named cluster territory. Renamable, stable. | +| **Lens** | An overlay that recolors the map to answer one question. | +| **Orbit** | The local view: one note centered, its connections in rings. | +| **Replay** | The time lens: watch the vault grow along a date scrubber. | + +Per house rule, every one of these words appears in the UI, not just in docs. + +## What the repo already has + +This design adds no new parsing and no new synced copy of anything. The +extraction layer exists in app-core and is already the renderer-side sibling +of the synced-copies family: + +- `lib/wikilinks.ts`: `extractWikilinkTargets`, `resolveWikilinkTarget`, + `extractMarkdownLinkHrefs`, `extractMentionSnippet` (unlinked mentions!), + all behind `stripCodeContent`. ConnectionsPanel already computes backlinks, + mentions, and missing links for one note; Atlas is that computation for all + notes at once, cached. +- `lib/tags.ts`: tag extraction with counts, frontmatter included. +- VimNav + HintOverlay + which-key: the entire keyboard model is assembled + from parts that exist. `Space g` is unclaimed (taken today: o a f s t e p v + l q i d w m c). +- NoteHoverPreview: hover/focus previews come free. +- The sidebar virtualization lesson: thousands of DOM nodes are a mistake we + already made once. Atlas renders to a single canvas from day one. +- The perf harness (`perf:desktop-runtime`, 5,000-note vault) is the natural + home for Atlas budgets. + +Because Atlas lives entirely in app-core and reads through the existing +extractors, it ships on desktop and web in the same change, and the Go server +is not involved. + +## Design + +### The index + +A vault-wide connection index, built in a web worker, updated incrementally on +note save/rename/delete events the store already emits: + +- **Link edges**: resolved wikilinks and internal markdown links. Directed, + weighted by occurrence count. These are the only edges drawn by default. +- **Kinship edges**: shared tags (weight by rarity: two notes sharing a + 20-note tag are closer kin than two sharing a 400-note tag). Used by layout + and suggestions, not drawn. +- **Mention edges**: unlinked title mentions, via the existing extractor. + Drawn only as ghosts in the Suggestions lens. + +Cold build for 5,000 notes is a read of bodies the store mostly already has, +plus regexes we already run per-note elsewhere; the budget below keeps us +honest. + +### Layout and persistence + +- Clustering: label propagation over link + kinship edges, deterministic tie + breaks, majority tag naming with hub-title fallback. +- Placement: regions on a golden-angle spiral (big regions claim space first), + notes within a region by phyllotaxis, refined by a short, seeded, damped + relaxation. Then **frozen**. +- Incremental: a new note is placed at the weighted centroid of its neighbors + with deterministic jitter; an unconnected note parks at its folder-mates' + region edge until it earns links. Existing positions never shift. +- Persistence: `.zennotes/atlas.json` in the vault. Positions, region names, + user renames. Small, derived, safe to delete (deleting = voluntary reflow). + In-vault so desktop, web, and any future device share one geography. +- `Atlas: reflow map` is a command with a confirm, and the old file is kept as + a one-step undo. + +### Rendering + +One canvas layer, no per-note DOM. Nodes and relationships, kept deliberately +plain: node radius by degree, hue by region, soft nebula glow per cluster, +small-caps serif region names. Labels appear by zoom band (regions, then +hubs, then everything). Edges are quiet by default (a three-state toggle: +quiet, all, off), brightening when an endpoint is hovered or focused. The +focused note carries a gold ring; hover shows the existing NoteHoverPreview. + +The prototype renders this map in **both dimensions behind one toggle** (`v`): +the **sky** (3D) puts regions as constellations on a flattened sphere with a +perspective camera that orbits and tilts (hand-rolled projection, still one +canvas, no libraries), depth fog, parallax stars, and a gentle idle drift; +the **map** (2D) is the flat cartographic layout. Both layouts are computed +once from the same seed and frozen, and toggling morphs every note between +its two homes while the camera swings level, so the two views feel like one +place seen two ways. In the map, hjkl and drag pan; in the sky they orbit +and tilt. Everything else (lenses, rings, trace, replay, hints, filter) is +identical in both. + +An isometric-city rendering (notes as blocks on district platforms) was +prototyped at length and rejected: even at its most restrained it pulled +attention to the buildings instead of the relationships. The node form stays. +What survives from that detour: links stay quiet until asked for, and the +detail on screen at once is a budget, not an accident. + +### Keyboard model (vim ON) + +| Keys | Action | +| -------------------- | ------------------------------------------------------------ | +| `Space g` / `:atlas` | Open Atlas | +| `h j k l` | Pan | +| `+` / `-` | Zoom in / out (semantic zoom bands) | +| `f` | Hint mode: two-letter labels on visible notes, type to focus | +| `/` | Filter: dims every note not matching title/tag, live | +| `Enter` | Open the focused note in the editor | +| `o` | Orbit the focused note | +| `zz` | Center on the focused note | +| `[` / `]` | Previous / next region | +| `1..5` | Lenses: Structure, Heat, Orphans, Bridges, Suggestions | +| `t` | Replay (time scrubber; `Space` plays/pauses) | +| `m` twice | Trace: mark two notes, shortest link paths light up | +| `?` / `Esc` | Help / unwind (lens, filter, orbit, then leave) | + +With vim OFF, per house rule: arrows pan, Enter opens, Escape unwinds, and +every single-key shortcut above is disabled; the toolbar and mouse carry the +full feature set (wheel zoom, drag pan, click focus, lens buttons). + +### Lenses + +- **Heat**: recency of `updated` as glow. The living edge of the vault in one + glance; six months of neglect reads as a dark continent. +- **Orphans**: notes with no links in or out. The to-connect (or to-archive) + pile, spatially grouped by kinship so it is actionable. +- **Bridges**: approximate betweenness; notes that connect regions glow. + These are reliably the most interesting notes a vault owner owns and no + other tool surfaces them. +- **Suggestions**: ghost edges from kinship + unlinked mentions, ranked. + Focus a ghost, `Enter` accepts: a `[[wikilink]]` is appended under a + `## Related` heading through the normal note-write path (undoable, atomic, + nothing bespoke). The ghost becomes a real edge on screen. +- **Trace**: mark two notes, see the shortest link paths between them. "How + does my Rust reading connect to my cooking notes" has an actual answer. +- **Replay**: scrub the vault through time by `created` date, watch regions + be born. Also, frankly, the demo clip for the release. + +### Orbit + +`o` on any note animates the map away and lays the note's neighborhood in +rings: direct links (ring 1), two hops (ring 2), unlinked mentions as ghosts. +`Esc` animates back to the exact map you left. Orbit is the "local graph" +people ask for, but ranked and readable instead of a star of dots. + +## Performance budgets + +Wired into `perf:desktop-runtime` (5,000-note vault), enforced under +`ZEN_PERF_ENFORCE=1`: + +- Cold index + layout build: under 2s, in the worker, UI never blocks. +- Incremental update on note save: under 16ms applied. +- Steady-state render: 60fps pan/zoom at 5,000 notes, one canvas. +- Atlas code is lazy-loaded (`LazyAtlasView` like Excalidraw/Workflows) and + adds nothing to boot. No named manualChunks rule, per the packaging scars. + +## Phasing + +- **Phase 1, the map.** Index worker, deterministic layout + `atlas.json`, + canvas renderer, regions, semantic zoom, hint-mode focus, `/` filter, + open/preview, `Space g`. Ships alone; already better than the thing people + ask for. +- **Phase 2, the instrument.** Lenses (Heat, Orphans, Bridges, Trace), Orbit, + Suggestions with write-back. +- **Phase 3, time and show.** Replay, PNG/SVG export of the current map view + (people share these; let them be beautiful), perf gates, docs in both + surfaces (help.ts + website). + +## Non-goals + +- No VR, no physics toy mode. The absence of a physics simulation is a + feature of this design, not a savings. (An earlier draft ruled out 3D too; + the maintainer asked for a 3D pass and the prototype now has one. Whether + the shipped view is 2D, 3D, or a toggle is an open question below.) +- No query language. Live Queries (see `live-queries.md`) is the text answer; + Atlas is the spatial one. They can feed each other later (a lens defined by + a query is an obvious Phase 4), but neither waits for the other. +- No folder-hierarchy view. Folders already have the sidebar; Atlas draws the + link structure folders cannot show. +- Nothing here touches the Go server or adds a fourth synced parser copy. + +## Open questions + +- The prototype now ships the sky and the map behind one toggle; the open + question is which is the **default**. 3D is the more evocative first + impression and depth separates clusters; 2D is likely stronger for spatial + memory (positions on a plane are easier to remember than in a volume). A + reasonable answer: open in the map, keep the sky one keypress away, and + remember the user's choice in the portable prefs. + +- Region naming quality on tag-poor vaults (fallback ladder: dominant tag, + hub title, top folder name; needs testing on a real messy vault). +- Whether kinship edges should influence layout by default or only when link + density is too low to cluster on (sparse-vault cold start). +- `atlas.json` merge behavior under sync conflict (positions are derived, so + last-writer-wins is probably fine; region renames are the part worth a + keep-both prompt). diff --git a/package-lock.json b/package-lock.json index e11e4686..4a8e0baf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.29.0", + "version": "2.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.29.0", + "version": "2.30.0", "workspaces": [ "apps/*", "packages/*" @@ -20,7 +20,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.29.0", + "version": "2.30.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -99,11 +99,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.29.0" + "version": "2.30.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.29.0", + "version": "2.30.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -17123,7 +17123,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.29.0", + "version": "2.30.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -17187,11 +17187,11 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.29.0" + "version": "2.30.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.29.0", + "version": "2.30.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -17199,7 +17199,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.29.0" + "version": "2.30.0" } } } diff --git a/package.json b/package.json index e96bc4c1..1a50bf28 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.29.0", + "version": "2.30.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 0fc387eb..724d85c2 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.29.0", + "version": "2.30.0", "type": "module", "exports": { "./main": "./src/main.tsx" diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index a1580154..2f41ba84 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -364,6 +364,7 @@ function App(): JSX.Element { const enabledOverrides = useStore((s) => s.enabledOverrides) const themeTweaks = useStore((s) => s.themeTweaks) const editorFontSize = useStore((s) => s.editorFontSize) + const mathFontScale = useStore((s) => s.mathFontScale) const editorLineHeight = useStore((s) => s.editorLineHeight) const previewMaxWidth = useStore((s) => s.previewMaxWidth) const editorMaxWidth = useStore((s) => s.editorMaxWidth) @@ -605,6 +606,7 @@ function App(): JSX.Element { useEffect(() => { const html = document.documentElement html.style.setProperty('--z-editor-font-size', `${editorFontSize}px`) + html.style.setProperty('--z-math-scale', String(mathFontScale / 100)) html.style.setProperty('--z-editor-line-height', String(editorLineHeight)) html.style.setProperty('--z-preview-max-width', `${previewMaxWidth}px`) html.style.setProperty('--z-editor-max-width', `${editorMaxWidth}px`) @@ -632,7 +634,7 @@ function App(): JSX.Element { monoFont, '"SF Mono", "SFMono-Regular", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace' ) - }, [editorFontSize, editorLineHeight, previewMaxWidth, editorMaxWidth, contentAlign, completedTaskStyle, mathRenderer, lineNumberPosition, interfaceFont, textFont, monoFont]) + }, [editorFontSize, mathFontScale, editorLineHeight, previewMaxWidth, editorMaxWidth, contentAlign, completedTaskStyle, mathRenderer, lineNumberPosition, interfaceFont, textFont, monoFont]) // Keep the markdown/preview pipeline pointed at the active math engine, even // on surfaces that render markdown without the Preview component mounted @@ -705,6 +707,12 @@ function App(): JSX.Element { void state.createAndOpen('quick', '', { title, focusTitle: true }) return } + if (matchesShortcut(e, overrides, 'global.newNoteHere')) { + // ⌘N — new note in the current folder (#614) + e.preventDefault() + void state.createNoteInCurrentFolder() + return + } if (matchesShortcut(e, overrides, 'global.toggleWordWrap')) { // ⌥Z — toggle word wrap (matches VSCode/Sublime convention) e.preventDefault() diff --git a/packages/app-core/src/components/AtlasView.tsx b/packages/app-core/src/components/AtlasView.tsx new file mode 100644 index 00000000..a933fb7d --- /dev/null +++ b/packages/app-core/src/components/AtlasView.tsx @@ -0,0 +1,991 @@ +// Atlas: the vault as a map of notes and links. Canvas-rendered (no per-note +// DOM, per the sidebar-virtualization lesson), theme-driven via CSS variables, +// with a 2D map and a 3D sky behind one toggle. Design: docs/ideas/atlas.md. +import { useEffect, useMemo, useRef, useState } from 'react' +import { useStore, isAtlasViewActive } from '../store' +import { + applyExtraLinkEdges, + buildAtlasGraph, + collectAtlasPositions, + layoutAtlas, + type AtlasGraph, + type AtlasPositions +} from '../lib/atlas' +import { isAppOverlayOpen } from '../lib/overlay-open' +import { extractMarkdownLinkHrefs } from '../lib/wikilinks' +import { resolveInternalNoteHref } from '../lib/internal-links' + +type Lens = 1 | 2 | 3 | 4 +const LENS_NAMES: Record = { 1: 'structure', 2: 'heat', 3: 'orphans', 4: 'bridges' } +const F = 900 +const HINTKEYS = 'asdfghjkl' + +interface ThemeColors { + bg: string + fg: string + muted: string + dim: string + accent: string + accentTriplet: string + regionHues: string[] +} + +// Themes style Atlas through CSS variables. A custom theme can override the +// region palette with --z-atlas-region-1 .. --z-atlas-region-8 (space-separated +// RGB triplets like every other token); otherwise the syntax hues are used. +function readThemeColors(): ThemeColors { + const cs = getComputedStyle(document.documentElement) + const triplet = (name: string, fallback: string): string => { + const v = cs.getPropertyValue(name).trim() + return v.length > 0 ? v : fallback + } + const rgb = (t: string): string => `rgb(${t})` + const regionHues: string[] = [] + for (let i = 1; i <= 8; i++) { + const v = cs.getPropertyValue(`--z-atlas-region-${i}`).trim() + if (v.length > 0) regionHues.push(rgb(v)) + } + if (regionHues.length === 0) { + for (const name of [ + '--z-accent', + '--z-blue', + '--z-purple', + '--z-aqua', + '--z-green', + '--z-yellow', + '--z-red', + '--z-accent-soft' + ]) { + const v = cs.getPropertyValue(name).trim() + if (v.length > 0) regionHues.push(rgb(v)) + } + } + return { + bg: rgb(triplet('--z-atlas-bg', triplet('--z-bg', '40 40 40'))), + fg: rgb(triplet('--z-fg', '235 219 178')), + muted: rgb(triplet('--z-grey-1', '146 131 116')), + dim: rgb(triplet('--z-grey-dim', '124 111 100')), + accent: rgb(triplet('--z-accent', '215 153 33')), + accentTriplet: triplet('--z-accent', '215 153 33'), + regionHues: regionHues.length > 0 ? regionHues : ['rgb(215 153 33)'] + } +} + +interface Cam { + yaw: number + pitch: number + dist: number + cx: number + cy: number + cz: number + yawT: number + pitchT: number + distT: number + cxT: number + cyT: number + czT: number +} + +export function AtlasView(): JSX.Element { + const notes = useStore((s) => s.notes) + const vaultRoot = useStore((s) => s.vault?.root ?? '') + const vimMode = useStore((s) => s.vimMode) + const selectNote = useStore((s) => s.selectNote) + const setFocusedPanel = useStore((s) => s.setFocusedPanel) + const isActive = useStore(isAtlasViewActive) + const canvasRef = useRef(null) + const [mode3d, setMode3d] = useState(false) + const [lens, setLens] = useState(1) + const [edgeMode, setEdgeMode] = useState(0) // 0 quiet · 1 all · 2 off + const [filterQ, setFilterQ] = useState('') + const [filterOpen, setFilterOpen] = useState(false) + const [status, setStatus] = useState('') + const filterRef = useRef(null) + + const graph: AtlasGraph = useMemo(() => { + const g = buildAtlasGraph(notes) + let previous: AtlasPositions | null = null + const cacheKey = `zen-atlas-pos:${vaultRoot}` + try { + const raw = localStorage.getItem(cacheKey) + if (raw) previous = JSON.parse(raw) as AtlasPositions + } catch { + previous = null + } + layoutAtlas(g, previous) + try { + localStorage.setItem(cacheKey, JSON.stringify(collectAtlasPositions(g))) + } catch { + // Cache is an optimization; a full deterministic relayout is the fallback. + } + return g + }, [notes, vaultRoot]) + + // Everything mutable at 60fps lives in refs; React state is for chrome only. + const world = useRef({ + cam: { + yaw: 0, + pitch: 0, + dist: 2600, + cx: 0, + cy: 0, + cz: 0, + yawT: 0, + pitchT: 0, + distT: 2600, + cxT: 0, + cyT: 0, + czT: 0 + } as Cam, + focusPath: null as string | null, + hoverPath: null as string | null, + hint: null as null | { labels: Map; typed: string }, + display: [] as Array<{ dx: number; dy: number; dz: number; alpha: number }>, + betweenness: null as Float64Array | null, + lastFrame: 0, + interacted: 0 + }) + const stateRef = useRef({ mode3d, lens, edgeMode, filterQ, vimMode, graph }) + stateRef.current = { mode3d, lens, edgeMode, filterQ, vimMode, graph } + + useEffect(() => { + world.current.display = graph.nodes.map((n) => ({ dx: 0, dy: 0, dz: 0, alpha: 0 })) + graph.nodes.forEach((n, i) => { + const d = world.current.display[i] + d.dx = stateRef.current.mode3d ? n.x : n.x2 + d.dy = stateRef.current.mode3d ? n.y : n.y2 + d.dz = stateRef.current.mode3d ? n.z : 0 + }) + world.current.betweenness = null + fitAll() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [graph]) + + useEffect(() => { + if (!isActive) return + useStore.getState().setFocusedPanel('atlas') + const el = document.activeElement as HTMLElement | null + el?.blur?.() + }, [isActive]) + + // The map is a flat plane: it must be viewed level or panning slides on + // skewed axes. The sky keeps (and remembers) its oblique angle. + const savedView = useRef({ yaw: 0.6, pitch: 0.3 }) + useEffect(() => { + const cam = world.current.cam + if (mode3d) { + cam.yawT = savedView.current.yaw + cam.pitchT = savedView.current.pitch + } else { + savedView.current = { yaw: cam.yawT, pitch: cam.pitchT } + cam.yawT = Math.round(cam.yaw / (Math.PI * 2)) * Math.PI * 2 + cam.pitchT = 0 + } + }, [mode3d]) + + // Obsidian parity, kept view-local: markdown-style note links become edges + // too, scanned lazily from bodies AFTER first paint through the existing + // read-only bridge, cached per note mtime so the full scan runs once. + const [, setEdgeVersion] = useState(0) + useEffect(() => { + if (graph.nodes.length === 0) return + let cancelled = false + const cacheKey = `zen-atlas-mdlinks:${vaultRoot}` + const run = async (): Promise => { + let cache: Record = {} + try { + cache = JSON.parse(localStorage.getItem(cacheKey) ?? '{}') + } catch { + cache = {} + } + const pairs: Array<[string, string]> = [] + const pending = graph.nodes.filter((n) => cache[n.path]?.u !== n.updatedAt) + for (let i = 0; i < pending.length; i += 40) { + if (cancelled) return + await Promise.all( + pending.slice(i, i + 40).map(async (n) => { + try { + const body = (await window.zen.readNote(n.path)).body + const targets: string[] = [] + for (const href of extractMarkdownLinkHrefs(body)) { + const resolved = resolveInternalNoteHref(n.path, href, notes) + if (resolved) targets.push(resolved.path) + } + cache[n.path] = { u: n.updatedAt, t: targets } + } catch { + cache[n.path] = { u: n.updatedAt, t: [] } + } + }) + ) + await new Promise((r) => setTimeout(r, 0)) + } + for (const n of graph.nodes) { + for (const t of cache[n.path]?.t ?? []) pairs.push([n.path, t]) + } + if (cancelled) return + try { + localStorage.setItem(cacheKey, JSON.stringify(cache)) + } catch { + // cache is an optimization only + } + if (applyExtraLinkEdges(graph, pairs) > 0) { + world.current.betweenness = null + setEdgeVersion((v) => v + 1) + } + } + void run() + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [graph]) + + const colorsRef = useRef(readThemeColors()) + useEffect(() => { + const observer = new MutationObserver(() => { + colorsRef.current = readThemeColors() + }) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme', 'data-theme-mode'] + }) + return () => observer.disconnect() + }, []) + + function target(n: { x: number; y: number; z: number; x2: number; y2: number }): [number, number, number] { + return stateRef.current.mode3d ? [n.x, n.y, n.z] : [n.x2, n.y2, 0] + } + function bounds(): { cx: number; cy: number; cz: number; R: number } { + const g = stateRef.current.graph + if (g.nodes.length === 0) return { cx: 0, cy: 0, cz: 0, R: 400 } + const pts = g.nodes.map((n) => target(n)) + const xs = pts.map((p) => p[0]) + const ys = pts.map((p) => p[1]) + const zs = pts.map((p) => p[2]) + const cx = (Math.min(...xs) + Math.max(...xs)) / 2 + const cy = (Math.min(...ys) + Math.max(...ys)) / 2 + const cz = (Math.min(...zs) + Math.max(...zs)) / 2 + const R = Math.max(...pts.map((p) => Math.hypot(p[0] - cx, p[1] - cy, p[2] - cz))) + 140 + return { cx, cy, cz, R } + } + function fitAll(): void { + const c = canvasRef.current + const w = c?.clientWidth || 1200 + const h = c?.clientHeight || 800 + const B = bounds() + const cam = world.current.cam + cam.cxT = B.cx + cam.cyT = B.cy + cam.czT = B.cz + cam.distT = Math.max(B.R * 1.15, Math.min(B.R * 2.4, (B.R * F) / (Math.min(w, h) * 0.62))) + } + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + let raf = 0 + const draw = (now: number): void => { + raf = requestAnimationFrame(draw) + const st = stateRef.current + const w = world.current + const g = st.graph + const vw = canvas.clientWidth + const vh = canvas.clientHeight + if (vw < 40 || vh < 40) return + const dpr = window.devicePixelRatio || 1 + if (canvas.width !== Math.round(vw * dpr)) { + canvas.width = Math.round(vw * dpr) + canvas.height = Math.round(vh * dpr) + } + const dt = Math.min(500, now - (w.lastFrame || now)) + w.lastFrame = now + const k = 1 - Math.exp(-dt / 150) + const cam = w.cam + if (st.mode3d && now - w.interacted > 4000) cam.yawT += dt * 0.000045 + cam.yaw += (cam.yawT - cam.yaw) * k + cam.pitch += (cam.pitchT - cam.pitch) * k + cam.dist += (cam.distT - cam.dist) * k + cam.cx += (cam.cxT - cam.cx) * k + cam.cy += (cam.cyT - cam.cy) * k + cam.cz += (cam.czT - cam.cz) * k + const cosY = Math.cos(cam.yaw) + const sinY = Math.sin(cam.yaw) + const cosP = Math.cos(cam.pitch) + const sinP = Math.sin(cam.pitch) + const project = (x: number, y: number, z: number): null | { sx: number; sy: number; s: number; fog: number; depth: number } => { + const px = x - cam.cx + const py = y - cam.cy + const pz = z - cam.cz + const xr = px * cosY - pz * sinY + const z1 = px * sinY + pz * cosY + const yr = py * cosP - z1 * sinP + const zr = py * sinP + z1 * cosP + const depth = zr + cam.dist + if (depth < 60) return null + const s = F / depth + const rel = depth / cam.dist + return { + sx: vw / 2 + xr * s, + sy: vh / 2 + yr * s, + s, + fog: Math.max(0.15, Math.min(1, 1.55 - rel * 0.55)), + depth + } + } + const col = colorsRef.current + const q = st.filterQ.toLowerCase() + const emphasis = (i: number): { a: number; glow: number } => { + const n = g.nodes[i] + let a = 1 + let glow = 0 + if (q.length > 0) { + const match = n.title.toLowerCase().includes(q) || n.tags.some((t) => t.includes(q)) + if (!match) return { a: 0.06, glow: 0 } + glow = 0.3 + } + if (st.lens === 2) { + const heat = Math.max(0, 1 - (Date.now() - n.updatedAt) / (240 * 86400000)) + a = 0.25 + 0.75 * heat + glow = heat + } + if (st.lens === 3) { + if (n.degree === 0) glow = 0.8 + else a = Math.min(a, 0.13) + } + if (st.lens === 4 && w.betweenness) { + const max = w.betweenness[g.nodes.length] || 1 + const b = w.betweenness[i] / max + a = 0.18 + 0.82 * Math.sqrt(b) + glow = Math.pow(b, 0.6) + } + return { a, glow } + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.fillStyle = col.bg + ctx.fillRect(0, 0, vw, vh) + const projected: Array> = [] + g.nodes.forEach((n, i) => { + const d = w.display[i] + if (!d) return + const [tx, ty, tz] = target(n) + d.dx += (tx - d.dx) * k + d.dy += (ty - d.dy) * k + d.dz += (tz - d.dz) * k + const em = emphasis(i) + d.alpha += (em.a - d.alpha) * k + projected[i] = project(d.dx, d.dy, d.dz) + }) + // halos give clusters their nebula ground + const order = g.nodes + .map((_, i) => i) + .filter((i) => projected[i] && w.display[i].alpha > 0.02) + .sort((a, b) => projected[b]!.depth - projected[a]!.depth) + for (const i of order) { + const p = projected[i]! + const n = g.nodes[i] + const r = (24 + Math.sqrt(n.degree) * 8) * p.s + if (r < 3 || p.sx < -r || p.sy < -r || p.sx > vw + r || p.sy > vh + r) continue + const hue = col.regionHues[n.region % col.regionHues.length] + const grad = ctx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, r) + grad.addColorStop(0, hue.replace('rgb(', 'rgba(').replace(')', ' / 0.14)')) + grad.addColorStop(1, hue.replace('rgb(', 'rgba(').replace(')', ' / 0)')) + ctx.globalAlpha = 0.9 * w.display[i].alpha * p.fog + ctx.fillStyle = grad + ctx.beginPath() + ctx.arc(p.sx, p.sy, r, 0, 7) + ctx.fill() + } + ctx.globalAlpha = 1 + // links + if (st.edgeMode !== 2) { + for (const [a, b] of g.edges) { + const pa = projected[a] + const pb = projected[b] + if (!pa || !pb) continue + const active = + g.nodes[a].path === w.hoverPath || + g.nodes[b].path === w.hoverPath || + g.nodes[a].path === w.focusPath || + g.nodes[b].path === w.focusPath + const fog = Math.min(pa.fog, pb.fog) + const al = + Math.min(w.display[a].alpha, w.display[b].alpha) * (st.edgeMode === 1 ? 0.5 : 0.22) * fog + if (al < 0.02 && !active) continue + ctx.strokeStyle = active ? col.muted : col.dim + ctx.globalAlpha = active ? 0.75 : al + ctx.lineWidth = Math.max(0.4, (pa.s + pb.s) * (active ? 0.55 : 0.4)) + ctx.beginPath() + ctx.moveTo(pa.sx, pa.sy) + ctx.lineTo(pb.sx, pb.sy) + ctx.stroke() + } + ctx.globalAlpha = 1 + } + // region names + const zl = F / cam.dist + const regionAlpha = Math.max(0, Math.min(1, (1.6 - zl) / 1.0)) + if (regionAlpha > 0.02 && g.regions.length > 1) { + ctx.textAlign = 'center' + g.regions.forEach((reg, ri) => { + if (reg.count === 0) return + const p = st.mode3d ? project(reg.cx, reg.cy, reg.cz) : project(reg.cx2, reg.cy2, 0) + if (!p) return + ctx.font = `500 ${Math.max(10, Math.min(19, 15 * p.s))}px Iowan Old Style, Palatino, Georgia, serif` + ctx.fillStyle = col.regionHues[ri % col.regionHues.length] + ctx.globalAlpha = regionAlpha * 0.8 * p.fog + ctx.fillText(reg.label.toUpperCase().split('').join(' '), p.sx, p.sy - 20 * p.s) + }) + ctx.globalAlpha = 1 + } + // nodes + for (const i of order) { + const p = projected[i]! + if (p.sx < -60 || p.sy < -60 || p.sx > vw + 60 || p.sy > vh + 60) continue + const n = g.nodes[i] + const d = w.display[i] + const em = emphasis(i) + const hue = col.regionHues[n.region % col.regionHues.length] + const base = Math.max(1.4, (3.4 + Math.sqrt(n.degree) * 2.1) * p.s) + if (em.glow > 0.05) { + const grad = ctx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, base * 4.2) + const glowHue = st.lens === 2 ? col.accent : hue + grad.addColorStop(0, glowHue.replace('rgb(', 'rgba(').replace(')', ' / 0.32)')) + grad.addColorStop(1, glowHue.replace('rgb(', 'rgba(').replace(')', ' / 0)')) + ctx.globalAlpha = em.glow * d.alpha * p.fog + ctx.fillStyle = grad + ctx.beginPath() + ctx.arc(p.sx, p.sy, base * 4.2, 0, 7) + ctx.fill() + } + ctx.globalAlpha = d.alpha * p.fog + ctx.fillStyle = hue + ctx.beginPath() + ctx.arc(p.sx, p.sy, base, 0, 7) + ctx.fill() + if (n.path === w.focusPath || n.path === w.hoverPath) { + ctx.strokeStyle = n.path === w.focusPath ? col.accent : col.fg + ctx.lineWidth = n.path === w.focusPath ? 1.7 : 1.1 + ctx.globalAlpha = 0.95 + ctx.beginPath() + ctx.arc(p.sx, p.sy, base + 4, 0, 7) + ctx.stroke() + } + } + ctx.globalAlpha = 1 + // labels by zoom band, with collision skip + const showAll = zl > 1.1 + if (zl > 0.5) { + const placed: Array<{ x: number; y: number, wd: number }> = [] + ctx.textAlign = 'left' + const byDeg = [...order].sort((a, b) => g.nodes[b].degree - g.nodes[a].degree) + for (const i of byDeg) { + const p = projected[i]! + const n = g.nodes[i] + const d = w.display[i] + if (d.alpha < 0.25 || p.fog < 0.4) continue + const isHub = n.degree >= 6 + const forced = n.path === w.focusPath || n.path === w.hoverPath + if (!showAll && !isHub && !forced) continue + if (p.sx < -40 || p.sy < -20 || p.sx > vw + 40 || p.sy > vh + 20) continue + ctx.font = `${isHub || forced ? '600' : '400'} ${(forced ? 12 : isHub ? 11.5 : 10.5).toFixed(1)}px ui-sans-serif, system-ui, sans-serif` + const tw = ctx.measureText(n.title).width + const lx = p.sx + 8 + const ly = p.sy + 4 + let collide = false + for (const pl of placed) + if (Math.abs(pl.y - ly) < 13 && lx < pl.x + pl.wd && lx + tw > pl.x) { + collide = true + break + } + if (collide && !forced) continue + placed.push({ x: lx, y: ly, wd: tw }) + const la = Math.min(1, d.alpha) * Math.max(0.8, p.fog) + ctx.globalAlpha = la * 0.62 + ctx.fillStyle = col.bg + ctx.beginPath() + ctx.roundRect(lx - 3, ly - 10, tw + 6, 13, 3) + ctx.fill() + ctx.fillStyle = forced || isHub ? col.fg : col.muted + ctx.globalAlpha = la + ctx.fillText(n.title, lx, ly) + } + ctx.globalAlpha = 1 + } + // hint labels + if (w.hint) { + ctx.textAlign = 'center' + ctx.font = '700 11px ui-monospace, Menlo, monospace' + for (const [i, code] of w.hint.labels) { + if (w.hint.typed && !code.startsWith(w.hint.typed)) continue + const p = projected[i] + if (!p) continue + ctx.fillStyle = col.accent + ctx.globalAlpha = 0.96 + ctx.beginPath() + ctx.roundRect(p.sx - 12, p.sy - 26, 24, 16, 4) + ctx.fill() + ctx.fillStyle = col.bg + ctx.fillText(code, p.sx, p.sy - 14) + } + ctx.globalAlpha = 1 + } + ;(canvas as unknown as { _proj?: typeof projected })._proj = projected + } + raf = requestAnimationFrame(draw) + return () => cancelAnimationFrame(raf) + }, []) + + // Bridges lens needs betweenness; computed once per graph, on demand. + useEffect(() => { + if (lens !== 4 || world.current.betweenness || graph.nodes.length === 0) return + const t = setTimeout(() => { + const nCount = graph.nodes.length + const adj: number[][] = graph.nodes.map(() => []) + graph.edges.forEach(([a, b]) => { + adj[a].push(b) + adj[b].push(a) + }) + const between = new Float64Array(nCount + 1) + for (let s = 0; s < nCount; s++) { + const stack: number[] = [] + const pred: number[][] = graph.nodes.map(() => []) + const sigma = new Float64Array(nCount) + const dist = new Int32Array(nCount).fill(-1) + const delta = new Float64Array(nCount) + sigma[s] = 1 + dist[s] = 0 + const queue = [s] + while (queue.length > 0) { + const v = queue.shift()! + stack.push(v) + for (const m of adj[v]) { + if (dist[m] < 0) { + dist[m] = dist[v] + 1 + queue.push(m) + } + if (dist[m] === dist[v] + 1) { + sigma[m] += sigma[v] + pred[m].push(v) + } + } + } + while (stack.length > 0) { + const m = stack.pop()! + for (const v of pred[m]) delta[v] += (sigma[v] / sigma[m]) * (1 + delta[m]) + if (m !== s) between[m] += delta[m] + } + } + between[nCount] = Math.max(1, ...Array.from(between.slice(0, nCount))) + world.current.betweenness = between + }, 10) + return () => clearTimeout(t) + }, [lens, graph]) + + function hitTest(sx: number, sy: number): number { + const canvas = canvasRef.current as unknown as { _proj?: Array } | null + const proj = canvas?._proj + if (!proj) return -1 + let best = -1 + let bd = Infinity + stateRef.current.graph.nodes.forEach((n, i) => { + const p = proj[i] + if (!p || world.current.display[i].alpha < 0.1) return + const r = Math.max(1.4, (3.4 + Math.sqrt(n.degree) * 2.1) * p.s) + 6 + const d = (p.sx - sx) ** 2 + (p.sy - sy) ** 2 + if (d < r * r && p.depth < bd) { + bd = p.depth + best = i + } + }) + return best + } + + function touch(): void { + world.current.interacted = performance.now() + } + function enterHint(): void { + const canvas = canvasRef.current as unknown as { _proj?: Array } | null + const proj = canvas?._proj + const c = canvasRef.current + if (!proj || !c) return + const vw = c.clientWidth + const vh = c.clientHeight + const visible = stateRef.current.graph.nodes + .map((_, i) => i) + .filter((i) => { + const p = proj[i] + return p && world.current.display[i].alpha > 0.2 && p.sx > 10 && p.sy > 40 && p.sx < vw - 10 && p.sy < vh - 40 + }) + .sort((a, b) => { + const pa = proj[a]! + const pb = proj[b]! + return ( + (pa.sx - vw / 2) ** 2 + (pa.sy - vh / 2) ** 2 - ((pb.sx - vw / 2) ** 2 + (pb.sy - vh / 2) ** 2) + ) + }) + .slice(0, 81) + const labels = new Map() + visible.forEach((i, idx) => labels.set(i, HINTKEYS[Math.floor(idx / 9)] + HINTKEYS[idx % 9])) + world.current.hint = { labels, typed: '' } + } + function centerOnNode(i: number): void { + const n = stateRef.current.graph.nodes[i] + const cam = world.current.cam + const [x, y, z] = target(n) + cam.cxT = x + cam.cyT = y + cam.czT = z + world.current.focusPath = n.path + touch() + } + function jumpRegion(dir: number): void { + const g = stateRef.current.graph + if (g.regions.length === 0) return + const focusNode = g.nodes.find((n) => n.path === world.current.focusPath) + const cur = focusNode ? focusNode.region : 0 + const next = (cur + dir + g.regions.length) % g.regions.length + const reg = g.regions[next] + const cam = world.current.cam + const c = canvasRef.current + const minSide = Math.min(c?.clientWidth || 1200, c?.clientHeight || 800) + if (stateRef.current.mode3d) { + cam.cxT = reg.cx + cam.cyT = reg.cy + cam.czT = reg.cz + } else { + cam.cxT = reg.cx2 + cam.cyT = reg.cy2 + cam.czT = 0 + } + cam.distT = Math.max(F * 0.5, (reg.r * F) / (minSide * 0.33)) + const hub = g.nodes.find((n) => n.region === next) + if (hub) world.current.focusPath = hub.path + setStatus(reg.label) + touch() + } + function openFocused(): void { + const path = world.current.focusPath + if (path) void selectNote(path) + } + + // Keyboard: capture phase so it beats VimNav; single letters are Vim-only + // per the house rule (arrows, Enter and Escape stay universal). + useEffect(() => { + if (!isActive) return + const handler = (e: KeyboardEvent): void => { + if (isAppOverlayOpen()) return + if (document.querySelector('[data-vim-hint-overlay]')) return + const active = document.activeElement as HTMLElement | null + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) return + const fp = useStore.getState().focusedPanel + if (fp != null && fp !== 'atlas') return + if (e.metaKey || e.ctrlKey || e.altKey) return + const st = stateRef.current + const w = world.current + const cam = w.cam + const consume = (): void => { + e.preventDefault() + e.stopImmediatePropagation() + } + if (w.hint) { + consume() + if (e.key === 'Escape') { + w.hint = null + return + } + if (/^[a-z]$/.test(e.key)) { + w.hint.typed += e.key + const matches = [...w.hint.labels.entries()].filter(([, c]) => c.startsWith(w.hint!.typed)) + if (matches.length === 1 && matches[0][1] === w.hint.typed) { + const i = matches[0][0] + w.hint = null + centerOnNode(i) + } else if (matches.length === 0) w.hint = null + } + return + } + const vim = st.vimMode + const pan = (60 * cam.dist) / F + const universal = (): boolean => { + switch (e.key) { + case 'ArrowLeft': + if (st.mode3d) cam.yawT -= 0.14 + else cam.cxT -= pan + return true + case 'ArrowRight': + if (st.mode3d) cam.yawT += 0.14 + else cam.cxT += pan + return true + case 'ArrowUp': + if (st.mode3d) cam.pitchT = Math.min(1.25, cam.pitchT + 0.1) + else cam.cyT -= pan + return true + case 'ArrowDown': + if (st.mode3d) cam.pitchT = Math.max(-1.25, cam.pitchT - 0.1) + else cam.cyT += pan + return true + case 'Enter': + openFocused() + return true + case 'Escape': + if (filterOpen || st.filterQ) { + setFilterOpen(false) + setFilterQ('') + } else if (st.lens !== 1) setLens(1) + else w.focusPath = null + return true + default: + return false + } + } + if (universal()) { + touch() + consume() + return + } + if (!vim) return + switch (e.key) { + case 'h': + if (st.mode3d) cam.yawT -= 0.14 + else cam.cxT -= pan + break + case 'l': + if (st.mode3d) cam.yawT += 0.14 + else cam.cxT += pan + break + case 'k': + if (st.mode3d) cam.pitchT = Math.min(1.25, cam.pitchT + 0.1) + else cam.cyT -= pan + break + case 'j': + if (st.mode3d) cam.pitchT = Math.max(-1.25, cam.pitchT - 0.1) + else cam.cyT += pan + break + case '+': + case '=': + if (st.mode3d) { + const cosY = Math.cos(cam.yaw) + const sinY = Math.sin(cam.yaw) + const cosP = Math.cos(cam.pitch) + const sinP = Math.sin(cam.pitch) + const travel = cam.distT * 0.18 + cam.cxT += sinY * cosP * travel + cam.cyT += sinP * travel + cam.czT += cosY * cosP * travel + } else cam.distT = Math.max(F * 0.3, cam.distT / 1.22) + break + case '-': + cam.distT = cam.distT * 1.22 + break + case 'f': + enterHint() + break + case '/': + setFilterOpen(true) + setTimeout(() => filterRef.current?.focus(), 30) + break + case 'v': + setMode3d((m) => !m) + break + case 'c': + setEdgeMode((m) => (m + 1) % 3) + break + case '[': + jumpRegion(-1) + break + case ']': + jumpRegion(1) + break + case '0': + fitAll() + break + case '1': + case '2': + case '3': + case '4': + setLens(Number(e.key) as Lens) + break + default: + return + } + touch() + consume() + } + window.addEventListener('keydown', handler, true) + return () => window.removeEventListener('keydown', handler, true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isActive, filterOpen]) + + // Mouse: drag orbits in 3D and pans in 2D; wheel flies; click focuses/opens. + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + let drag: { x: number; y: number } | null = null + let moved = false + const down = (e: MouseEvent): void => { + drag = { x: e.clientX, y: e.clientY } + moved = false + touch() + } + const move = (e: MouseEvent): void => { + const rect = canvas.getBoundingClientRect() + const sx = e.clientX - rect.left + const sy = e.clientY - rect.top + const cam = world.current.cam + if (drag) { + const dx = e.clientX - drag.x + const dy = e.clientY - drag.y + drag = { x: e.clientX, y: e.clientY } + if (Math.abs(dx) + Math.abs(dy) > 2) moved = true + if (stateRef.current.mode3d && !e.shiftKey) { + cam.yawT += dx * 0.005 + cam.yaw = cam.yawT + cam.pitchT = Math.max(-1.25, Math.min(1.25, cam.pitchT + dy * 0.004)) + cam.pitch = cam.pitchT + } else { + const s = F / cam.dist + const cosY = Math.cos(cam.yaw) + const sinY = Math.sin(cam.yaw) + const cosP = Math.cos(cam.pitch) + const sinP = Math.sin(cam.pitch) + cam.cxT -= (cosY * dx + sinY * sinP * dy) / s + cam.cyT -= (cosP * dy) / s + cam.czT -= (-sinY * dx + cosY * sinP * dy) / s + cam.cx = cam.cxT + cam.cy = cam.cyT + cam.cz = cam.czT + } + touch() + } else { + const i = hitTest(sx, sy) + world.current.hoverPath = i >= 0 ? stateRef.current.graph.nodes[i].path : null + canvas.style.cursor = i >= 0 ? 'pointer' : 'grab' + } + } + const up = (e: MouseEvent): void => { + if (drag && !moved) { + const rect = canvas.getBoundingClientRect() + const i = hitTest(e.clientX - rect.left, e.clientY - rect.top) + if (i >= 0) { + const path = stateRef.current.graph.nodes[i].path + if (world.current.focusPath === path) void selectNote(path) + else world.current.focusPath = path + } else world.current.focusPath = null + } + drag = null + } + const wheel = (e: WheelEvent): void => { + e.preventDefault() + touch() + const cam = world.current.cam + const f = Math.exp(e.deltaY * 0.0014) + if (stateRef.current.mode3d && f < 1) { + // Fly forward through the sky toward the cursor instead of shrinking + // the orbit: the camera target rides the view ray, so you pass between + // clusters rather than watching them scale. + const rect = canvas.getBoundingClientRect() + const vx0 = e.clientX - rect.left - rect.width / 2 + const vy0 = e.clientY - rect.top - rect.height / 2 + const len = Math.hypot(vx0, vy0, F) + const vx = vx0 / len + const vy = vy0 / len + const vz = F / len + const cosY = Math.cos(cam.yaw) + const sinY = Math.sin(cam.yaw) + const cosP = Math.cos(cam.pitch) + const sinP = Math.sin(cam.pitch) + const y1 = vy * cosP + vz * sinP + const z1 = -vy * sinP + vz * cosP + const dir = [vx * cosY + z1 * sinY, y1, -vx * sinY + z1 * cosY] + const travel = cam.distT * (1 - f) + cam.cxT += dir[0] * travel + cam.cyT += dir[1] * travel + cam.czT += dir[2] * travel + } else { + cam.distT = Math.max(F * 0.3, cam.distT * f) + } + } + canvas.addEventListener('mousedown', down) + window.addEventListener('mousemove', move) + window.addEventListener('mouseup', up) + canvas.addEventListener('wheel', wheel, { passive: false }) + return () => { + canvas.removeEventListener('mousedown', down) + window.removeEventListener('mousemove', move) + window.removeEventListener('mouseup', up) + canvas.removeEventListener('wheel', wheel) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const chip = + 'cursor-pointer rounded-full border border-paper-300 bg-paper-100 px-2.5 py-1 font-mono text-[11px] text-ink-600 hover:text-ink-800' + return ( +
setFocusedPanel('atlas')} + onFocusCapture={() => setFocusedPanel('atlas')} + tabIndex={0} + > + +
+
Atlas
+
+ {graph.nodes.length} notes · {graph.regions.filter((r) => r.count > 0).length} regions ·{' '} + {graph.edges.length} links{status ? ` · ${status}` : ''} +
+
+ {filterOpen && ( +
+ / + setFilterQ(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Escape') { + setFilterQ('') + setFilterOpen(false) + } + if (e.key === 'Enter') filterRef.current?.blur() + }} + placeholder="filter notes and tags" + className="w-44 bg-transparent text-sm text-ink-900 outline-none placeholder:text-ink-400" + /> +
+ )} +
+ + + + + + {vimMode && ( + + f jump · hjkl move · + - zoom · v 2d/3d · [ ] regions · 1..4 lens · Enter open + + )} +
+
+ ) +} diff --git a/packages/app-core/src/components/BufferPalette.tsx b/packages/app-core/src/components/BufferPalette.tsx index 1e394ff9..d9cd4067 100644 --- a/packages/app-core/src/components/BufferPalette.tsx +++ b/packages/app-core/src/components/BufferPalette.tsx @@ -23,6 +23,7 @@ import { isHelpTabPath } from '@shared/help' import { isTagsTabPath } from '@shared/tags' import { isTasksTabPath } from '@shared/tasks' import { isWorkflowsTabPath } from '@shared/workflows-view' +import { isAtlasTabPath } from '@shared/atlas-view' import { isArchiveTabPath } from '@shared/archive' import { isTrashTabPath } from '@shared/trash' import { isQuickNotesTabPath } from '@shared/quick-notes' @@ -94,6 +95,19 @@ function buildEntries(deps: BuildDeps): BufferEntry[] { }) return } + if (isAtlasTabPath(path)) { + entries.push({ + path, + title: 'Atlas', + subtitle: 'The vault as a map', + keywords: 'atlas map graph vault notes links virtual', + badge, + current: isCurrent, + dirty: false, + virtual: true + }) + return + } if (isWorkflowsTabPath(path)) { entries.push({ path, diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index c66f7029..652d0268 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -200,7 +200,7 @@ function syncVimKeymaps(overrides: KeymapOverrides): void { { id: "vim.bufferNext", contexts: ["normal", "visual"], - action: "nextBuffer", + action: "nextBufferRelative", bindings: [ toVimSequence(getKeymapBinding(overrides, "vim.bufferNext")), ].filter((binding): binding is string => !!binding), @@ -732,6 +732,21 @@ function registerVimCommands(): void { navigateActiveBuffer(useStore.getState(), 1); }, ); + // ]b is RELATIVE with a count ({count}]b walks forward count tabs), unlike + // gt whose {count} is vim's absolute tab number. [b shares previousBuffer + // with gT and was already relative. (#622) + Vim.defineAction( + "nextBufferRelative", + ( + _cm: unknown, + actionArgs?: { repeat?: number; repeatIsExplicit?: boolean }, + ) => { + const repeat = actionArgs?.repeatIsExplicit + ? (actionArgs.repeat ?? 1) + : 1; + navigateActiveBuffer(useStore.getState(), repeat); + }, + ); registerVimNoteCommands(); registerCommandPaletteEx(); diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 96781ddd..6dd69cbd 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -162,7 +162,9 @@ import { QuickNotesView } from './QuickNotesView' import type { MathRenderer } from '@shared/app-config' import { isTasksTabPath } from '@shared/tasks' import { isWorkflowsTabPath } from '@shared/workflows-view' +import { isAtlasTabPath } from '@shared/atlas-view' import { LazyWorkflowsView } from './LazyWorkflowsView' +import { LazyAtlasView } from './LazyAtlasView' import { isDatabaseTabPath, databaseTitleFromTab, databaseTabPath, isDatabaseCsvPath } from '@shared/databases' import { isTagsTabPath } from '@shared/tags' import { isHelpTabPath } from '@shared/help' @@ -417,7 +419,6 @@ function markdownEditingExtensions(showHeadingLevelLabels = false): Extension[] customCodeFenceHighlightExtension, vimAwareMarkdownKeymap, markdownListIndentPlugin, - frontmatterStyle, frontmatterTagExtension, orderedListRenumber, forwardOnCheckboxArrow, @@ -452,6 +453,9 @@ function wysiwygExtensions( ): Extension[] { return [ livePreviewPlugin, + // Frontmatter renders as compact properties only while Live Preview is + // on; with it off the block reads as plain --- markdown. (#616) + frontmatterStyle, codeBlockFlairPlugin, // Table widgets are gated on a setting — off keeps tables as plain editable // markdown for full keyboard/Vim editing (#232). @@ -1841,6 +1845,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const link = markdownLinkAt(doc, pos) if ( link && + useStore.getState().livePreview && pointerOverRange(view, link.from, link.to, event.clientX, event.clientY) ) { const sel = view.state.selection.main @@ -2767,6 +2772,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { isWorkflows: true } } + if (isAtlasTabPath(path)) { + return { + ...base, + title: 'Atlas', + isWorkflows: true + } + } if (isTasksTabPath(path)) { return { ...base, @@ -2909,6 +2921,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { if ( isQuickNotesTabPath(path) || isWorkflowsTabPath(path) || + isAtlasTabPath(path) || isTagsTabPath(path) || isHelpTabPath(path) || isArchiveTabPath(path) || @@ -3750,6 +3763,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { )} {isWorkflowsTabPath(activeTab) ? ( + ) : isAtlasTabPath(activeTab) ? ( + ) : isTasksTabPath(activeTab) ? ( ) : isQuickNotesTabPath(activeTab) ? ( diff --git a/packages/app-core/src/components/LazyAtlasView.tsx b/packages/app-core/src/components/LazyAtlasView.tsx new file mode 100644 index 00000000..71c10a44 --- /dev/null +++ b/packages/app-core/src/components/LazyAtlasView.tsx @@ -0,0 +1,14 @@ +// Same lazy pattern as LazyWorkflowsView: the canvas view stays out of the +// boot path, and deliberately gets NO named manualChunks rule (see the +// packaging scars in electron.vite.config.ts). +import { lazy, Suspense } from 'react' + +const AtlasViewImpl = lazy(() => import('./AtlasView').then((mod) => ({ default: mod.AtlasView }))) + +export function LazyAtlasView(): JSX.Element { + return ( + + + + ) +} diff --git a/packages/app-core/src/components/NoteList.tsx b/packages/app-core/src/components/NoteList.tsx index c4ee127a..33d61253 100644 --- a/packages/app-core/src/components/NoteList.tsx +++ b/packages/app-core/src/components/NoteList.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useStore } from '../store' +import { focusEditorNormalMode } from '../lib/editor-focus' import type { AssetMeta, NoteMeta } from '@shared/ipc' import { isDatabaseCsvPath } from '@shared/databases' import { DENSITY, densityFromTweaks } from '@shared/overrides' @@ -866,9 +867,15 @@ export function NoteList(): JSX.Element { active={entry.note.path === selectedPath} compact={rowDensity === 'compact'} onSelect={() => - void (tabsEnabled ? previewNote : selectNote)(entry.note.path) + // Mouse opens hand the keyboard to the editor, same as + // every keyboard opener already does. (#599) + void (tabsEnabled ? previewNote : selectNote)(entry.note.path).then(() => + focusEditorNormalMode() + ) + } + onOpenPermanent={() => + void selectNote(entry.note.path).then(() => focusEditorNormalMode()) } - onOpenPermanent={() => void selectNote(entry.note.path)} onContextMenu={(e) => { e.preventDefault() setMenu({ x: e.clientX, y: e.clientY, path: entry.note.path }) diff --git a/packages/app-core/src/components/OutlinePalette.tsx b/packages/app-core/src/components/OutlinePalette.tsx index f0f6e68b..6f14be25 100644 --- a/packages/app-core/src/components/OutlinePalette.tsx +++ b/packages/app-core/src/components/OutlinePalette.tsx @@ -17,6 +17,7 @@ import { isArchiveTabPath } from '@shared/archive' import { isTagsTabPath } from '@shared/tags' import { isTasksTabPath } from '@shared/tasks' import { isWorkflowsTabPath } from '@shared/workflows-view' +import { isAtlasTabPath } from '@shared/atlas-view' import { isTrashTabPath } from '@shared/trash' import { isQuickNotesTabPath } from '@shared/quick-notes' import { focusEditorNormalMode } from '../lib/editor-focus' @@ -28,6 +29,7 @@ function isVirtualPath(path: string | null): boolean { isQuickNotesTabPath(path) || isTasksTabPath(path) || isWorkflowsTabPath(path) || + isAtlasTabPath(path) || isTagsTabPath(path) || isHelpTabPath(path) || isArchiveTabPath(path) || diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index fe9a79f8..38abf72d 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -397,6 +397,31 @@ export const Preview = memo(function Preview({ if (!root) return; const onClick = (e: MouseEvent): void => { const target = e.target as HTMLElement; + // A `[/]` task has no checkbox input, but its half-filled marker is + // still a checkbox shape making a checkbox promise: clicking checks the + // task off, matching the editor widget and the Tasks list. The + // forwarded/cancelled markers stay inert records. (#599) + const inProgressMarker = target.closest( + ".zen-task-state-in-progress[data-task-index]", + ); + if (inProgressMarker) { + e.preventDefault(); + e.stopPropagation(); + const taskIndex = Number.parseInt( + inProgressMarker.dataset.taskIndex ?? "-1", + 10, + ); + if (!Number.isFinite(taskIndex) || taskIndex < 0) return; + const nextMarkdown = toggleTaskAtIndex( + markdownRef.current, + taskIndex, + true, + ); + if (nextMarkdown === markdownRef.current) return; + updateActiveBodyRef.current(nextMarkdown); + void persistActiveRef.current(); + return; + } const copyButton = target.closest( CODE_COPY_BUTTON_SELECTOR, ); @@ -706,6 +731,21 @@ export const Preview = memo(function Preview({ input.setAttribute("role", "checkbox"); input.classList.add("cursor-pointer"); li.classList.toggle("task-self-done", input.checked); + } else { + // `[/]` renders a marker span instead of an input; make it a real + // control that checks the task off (see onClick). `[-]`/`[>]` keep + // their inert record markers. (#599) + const marker = li.querySelector( + ":scope > .zen-task-state-in-progress, :scope > p > .zen-task-state-in-progress", + ); + if (marker) { + marker.dataset.taskIndex = String(idx); + marker.setAttribute("role", "checkbox"); + marker.setAttribute("aria-checked", "mixed"); + marker.setAttribute("aria-label", "Mark task done"); + marker.title = "In progress. Click to mark done."; + marker.classList.add("cursor-pointer"); + } } // An item is still open unless its own checkbox is checked; the `[/]` and // `[>]` states have no checkbox and are open by definition, `[-]` is not. diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 931185db..d414a2bb 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -472,6 +472,8 @@ export function SettingsModal(): JSX.Element { const showArchivedTasks = useStore((s) => s.showArchivedTasks); const setShowArchivedTasks = useStore((s) => s.setShowArchivedTasks); const mathRenderer = useStore((s) => s.mathRenderer); + const mathFontScale = useStore((s) => s.mathFontScale); + const setMathFontScale = useStore((s) => s.setMathFontScale); const typstTagPreambles = useStore((s) => s.typstTagPreambles); const setTypstTagPreambles = useStore((s) => s.setTypstTagPreambles); const setMathRenderer = useStore((s) => s.setMathRenderer); @@ -511,6 +513,8 @@ export function SettingsModal(): JSX.Element { const setTabsEnabled = useStore((s) => s.setTabsEnabled); const workflowsEnabled = useStore((s) => s.workflowsEnabled); const setWorkflowsEnabled = useStore((s) => s.setWorkflowsEnabled); + const atlasEnabled = useStore((s) => s.atlasEnabled); + const setAtlasEnabled = useStore((s) => s.setAtlasEnabled); const hiddenWorkflowPresets = useStore((s) => s.hiddenWorkflowPresets); const setHiddenWorkflowPresets = useStore((s) => s.setHiddenWorkflowPresets); const wrapTabs = useStore((s) => s.wrapTabs); @@ -1859,6 +1863,13 @@ export function SettingsModal(): JSX.Element { "source", ], }, + { + id: "math-font-scale", + title: "Math size", + description: + "Scale inline and block math relative to the surrounding text.", + keywords: ["math", "size", "scale", "font", "katex", "typst", "latex", "formula", "equation"], + }, { id: "math-renderer", title: "Math renderer", @@ -2105,6 +2116,13 @@ export function SettingsModal(): JSX.Element { "language", ], }, + { + id: "atlas-enabled", + title: "Atlas", + description: + "The Atlas map: the whole vault as notes and links, in 2D or 3D.", + keywords: ["atlas", "map", "graph", "visualize", "links", "network", "sky", "3d"], + }, { id: "workflows-enabled", title: "Workflows", @@ -2355,6 +2373,17 @@ export function SettingsModal(): JSX.Element { ]} onChange={(next) => setMathRenderer(next)} /> + {mathRenderer === "typst" && ( ), }, + { + id: "atlas", + title: "Atlas", + description: + "The Atlas map of the vault, and whether it appears in the app at all.", + searchIds: ["atlas-enabled"], + content: ( +
+
+ +
+
+ ), + }, { id: "workflows", title: "Workflows", diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index a9e1aa90..0c35c415 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { isTasksViewActive, isTrashViewActive, isWorkflowsViewActive, + isAtlasViewActive, useStore, } from "../store"; import { Button } from "./ui/Button"; @@ -26,6 +27,7 @@ import { confirmMoveToTrash } from "../lib/confirm-trash"; import { buildMoveNotePrompt, parseMoveNoteTarget } from "../lib/move-note"; import { buildTagTree, extractTags, flattenTagTree } from "../lib/tags"; import { isTypstPreamblePath, resolveTypstPreambleFolder } from "../lib/typst-preamble"; +import { focusEditorNormalMode } from "../lib/editor-focus"; import type { AssetMeta, FolderColorId, FolderEntry, FolderIconId, NoteFolder, NoteMeta } from "@shared/ipc"; import type { NoteSortOrder } from "../store"; import { isArchiveTabPath } from "@shared/archive"; @@ -55,6 +57,7 @@ import { TargetIcon, TrashIcon, WorkflowIcon, + AtlasIcon, } from "./icons"; import { ContextMenu, type ContextMenuItem } from "./ContextMenu"; import { ResizeHandle } from "./ResizeHandle"; @@ -460,6 +463,9 @@ export function Sidebar(): JSX.Element { const openWorkflowsView = useStore((s) => s.openWorkflowsView); const workflowsViewActive = useStore(isWorkflowsViewActive); const workflowsEnabled = useStore((s) => s.workflowsEnabled); + const openAtlasView = useStore((s) => s.openAtlasView); + const atlasViewActive = useStore(isAtlasViewActive); + const atlasEnabled = useStore((s) => s.atlasEnabled); const openQuickNotesView = useStore((s) => s.openQuickNotesView); const quickNotesViewActive = useStore(isQuickNotesViewActive); const openHelpView = useStore((s) => s.openHelpView); @@ -591,8 +597,11 @@ export function Sidebar(): JSX.Element { (path: string): void => { // Single click opens a VS Code-style preview tab; without tabs there // is nothing to preview, so fall back to a plain open. - if (tabsEnabled) void previewNote(path); - else void selectNote(path); + const opened = tabsEnabled ? previewNote(path) : selectNote(path); + // Every keyboard opener (VimNav Enter, palettes, gt) hands the keyboard + // to the editor afterwards; the mouse click was the one path that left + // focus on the clicked row, so typing kept driving the sidebar. (#599) + void opened.then(() => focusEditorNormalMode()); }, [previewNote, selectNote, tabsEnabled], ); @@ -965,6 +974,9 @@ export function Sidebar(): JSX.Element { y: number; tag: string; } | null>(null); + // Assets row menu (#621): the row advertised the `m` hint like every other + // sidebar row but had no context menu wired at all. + const [assetsRowMenu, setAssetsRowMenu] = useState<{ x: number; y: number } | null>(null); const [folderMenu, setFolderMenu] = useState<{ x: number; y: number; @@ -2826,6 +2838,11 @@ export function Sidebar(): JSX.Element { [prepareContextSelection], ); + const openAssetsRowMenu = useCallback((e: React.MouseEvent): void => { + e.preventDefault(); + setAssetsRowMenu({ x: e.clientX, y: e.clientY }); + }, []); + const openAssetMenu = useCallback( (e: React.MouseEvent, asset: AssetMeta): void => { e.preventDefault(); @@ -3346,6 +3363,20 @@ export function Sidebar(): JSX.Element { /> )} + {/* Same skip-before-props trick as Workflows above. */} + {atlasEnabled && ( + void openAtlasView()} + label="Atlas" + icon={} + sidebarType="workflows" + sidebarIdx={idxCounter.current.value++} + vimHighlight={vimCursor === idxCounter.current.value - 1} + sidebarFocused={isSidebarFocused} + /> + )} + void openAssetsView()} + onContextMenu={openAssetsRowMenu} sidebarIdx={idxCounter.current.value++} vimHighlight={vimCursor === idxCounter.current.value - 1} sidebarFocused={isSidebarFocused} @@ -3805,6 +3837,43 @@ export function Sidebar(): JSX.Element { onClose={() => setFolderMenu(null)} /> )} + {assetsRowMenu && ( + { + const state = useStore.getState(); + const current = state.assetSortOrder; + const sortItem = ( + label: string, + field: "name" | "used" | "type" | "size" | "modified", + ): ContextMenuItem => { + const activeField = current.startsWith(field + "-"); + const asc = current === field + "-asc"; + return { + label: `Sort by ${label}`, + // Re-picking the active field flips its direction, like a + // list header; a fresh field starts ascending. + hint: activeField ? (asc ? "↑" : "↓") : undefined, + onSelect: () => + state.setAssetSortOrder( + activeField && asc ? `${field}-desc` : `${field}-asc`, + ), + }; + }; + return [ + { label: "Open Assets", onSelect: () => void openAssetsView() }, + { kind: "separator" as const }, + sortItem("name", "name"), + sortItem("type", "type"), + sortItem("size", "size"), + sortItem("last modified", "modified"), + sortItem("times used", "used"), + ]; + })()} + onClose={() => setAssetsRowMenu(null)} + /> + )} {rootMenu && ( { // Double click keeps the note open as a permanent tab (VS Code-style). - void openNotePermanent(note.path); + // The second press re-focused the row after the first click's editor + // hand-off, so hand the keyboard back once more. (#599) + void openNotePermanent(note.path).then(() => focusEditorNormalMode()); }, [note.path, openNotePermanent]); const handleContextMenu = useCallback( (event: React.MouseEvent) => onContextMenuNote(event, note), diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index d20cc0f2..de129676 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -30,6 +30,7 @@ import type { NoteFolder } from '@shared/ipc' import type { VaultTask } from '@shared/tasks' import { groupTasks, isOverdue as isTaskOverdue, toIsoDateLocal } from '@shared/tasks' import { useStore, type KanbanGroupBy, type TaskMutation } from '../store' +import { filterTasks } from '../lib/tasks-filter' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { buildTaskMenuItems } from '../lib/task-context-menu' import { ArrowUpRightIcon, PencilIcon } from './icons' @@ -44,6 +45,10 @@ import { interface Props { tasks: VaultTask[] + /** The Tasks header's filter query. Narrows the cards on the board; the + * column set, group-by options, and card-order persistence keep working + * from the full task list. (#583) */ + filter?: string today: Date onOpenTask: (task: VaultTask) => void onToggleTask: (task: VaultTask) => void @@ -531,7 +536,7 @@ interface DragPreview { const POINTER_DRAG_THRESHOLD = 5 -export function TasksKanban({ tasks, today, onOpenTask, onToggleTask }: Props): JSX.Element { +export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: Props): JSX.Element { const groupBy = useStore((s) => s.kanbanGroupBy) const setGroupBy = useStore((s) => s.setKanbanGroupBy) const kanbanColumnTitles = useStore((s) => s.kanbanColumnTitles) @@ -575,6 +580,7 @@ export function TasksKanban({ tasks, today, onOpenTask, onToggleTask }: Props): }) const columnOrderRef = useRef(initialCardOrder) const columnsRef = useRef([]) + const fullColumnsRef = useRef([]) const columnTitleInputRef = useRef(null) const boardRef = useRef(null) const pointerDragRef = useRef(null) @@ -628,7 +634,7 @@ export function TasksKanban({ tasks, today, onOpenTask, onToggleTask }: Props): setDisplayTasks(mergedTasks) }, [mergeTasksWithPendingMoves, tasks]) - const columns = useMemo( + const fullColumns = useMemo( () => { const orderedColumns = applyColumnOrder( groupBy, @@ -654,7 +660,28 @@ export function TasksKanban({ tasks, today, onOpenTask, onToggleTask }: Props): today ] ) + + // The filter narrows cards, never columns: the board keeps its full column + // set while typing (a discovered-value column doesn't vanish because its + // cards are filtered out), and the unfiltered columns stay the source of + // truth for card-order persistence so a focused board can't prune hidden + // cards out of a hand-made arrangement. (#583) + const filterQuery = (filter ?? '').trim() + const columns = useMemo(() => { + if (!filterQuery) return fullColumns + return fullColumns.map((column) => { + const visible = filterTasks(column.tasks, filterQuery) + if (visible.length === column.tasks.length) return column + let badge = column.badge + if (badge?.kind === 'overdue') { + const value = visible.filter((t) => isTaskOverdue(t, today)).length + badge = value > 0 ? { kind: 'overdue', value } : undefined + } + return { ...column, tasks: visible, badge } + }) + }, [fullColumns, filterQuery, today]) columnsRef.current = columns + fullColumnsRef.current = fullColumns // Group-by options: the three static boards, the default custom-status field, // then one option per `@key:` field discovered across the current tasks. This @@ -920,17 +947,29 @@ export function TasksKanban({ tasks, today, onOpenTask, onToggleTask }: Props): if (targetIndex == null) return const movingKey = taskIdentityKey(task) + // The drop index counts the VISIBLE cards (the DOM the user aimed at), + // which the filter may have narrowed. Resolve it to the visible card the + // drop landed in front of, then splice next to that card in the FULL + // column, so filtered-out cards keep their hand-arranged positions + // instead of being pruned by the persist below. Unfiltered, the two + // boards are identical and this is the plain bounded insert. + const visibleTarget = columnsRef.current.find((column) => column.id === targetColumnId) + const visibleKeys = (visibleTarget?.tasks ?? []) + .map((columnTask) => taskIdentityKey(columnTask)) + .filter((key) => key !== movingKey) + const anchorKey = visibleKeys[targetIndex] ?? null + const nextOrderMap = new Map(columnOrderRef.current) const persistedEntries: Record = {} - for (const column of columnsRef.current) { + for (const column of fullColumnsRef.current) { const keys = column.tasks .map((columnTask) => taskIdentityKey(columnTask)) .filter((key) => key !== movingKey) if (column.id === targetColumnId) { - const boundedIndex = Math.max(0, Math.min(targetIndex, keys.length)) - keys.splice(boundedIndex, 0, movingKey) + const anchorIndex = anchorKey ? keys.indexOf(anchorKey) : -1 + keys.splice(anchorIndex === -1 ? keys.length : anchorIndex, 0, movingKey) } nextOrderMap.set(columnOrderKey(groupBy, column.id), keys) diff --git a/packages/app-core/src/components/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx index e2aa7f3f..8911d4fa 100644 --- a/packages/app-core/src/components/TasksView.tsx +++ b/packages/app-core/src/components/TasksView.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { isTasksViewActive, useStore, type TasksViewMode } from '../store' import { filterTasksForDisplay, inferDailyTaskDueDates, type VaultTask } from '@shared/tasks' import { buildDailyNoteDateByPath } from '../lib/vault-layout' -import { computeTasksRender, isOverdue } from '../lib/tasks-filter' +import { computeTasksRender, filterTasks, isOverdue } from '../lib/tasks-filter' import { forwardTaskWithPicker } from '../lib/forward-task' import { TasksRow } from './TasksRow' import { TasksCalendar } from './TasksCalendar' @@ -60,6 +60,7 @@ export function TasksView(): JSX.Element { const closeTasksView = useStore((s) => s.closeTasksView) const reorderTaskInNote = useStore((s) => s.reorderTaskInNote) const newTaskFile = useStore((s) => s.newTaskFile) + const setFocusedPanel = useStore((s) => s.setFocusedPanel) // Tasks written inside a daily note inherit that note's date as an implicit // due date (a clean line, no `due:` token) so they appear on the calendar. @@ -74,6 +75,11 @@ export function TasksView(): JSX.Element { () => inferDailyTaskDueDates(filterTasksForDisplay(rawTasks, showArchivedTasks), dueByPath), [rawTasks, showArchivedTasks, dueByPath] ) + // One filter, three sub-views: the list applies it inside computeTasksRender, + // the calendar takes this pre-filtered list, and the Kanban receives the raw + // query so it can narrow cards without losing the full board (its group-by + // options and card-order persistence must keep seeing every task). + const filteredTasks = useMemo(() => filterTasks(tasks, filter), [tasks, filter]) const keymapOverrides = useStore((s) => s.keymapOverrides) const vimMode = useStore((s) => s.vimMode) const viewMode = useStore((s) => s.tasksViewMode) @@ -299,8 +305,18 @@ export function TasksView(): JSX.Element { const runExCommand = useCallback( (raw: string): void => { - const cmd = raw.trim().replace(/^:/, '').toLowerCase() - if (!cmd) return + const input = raw.trim().replace(/^:/, '') + if (!input) return + // `:filter ` sets the shared filter (all three sub-views); + // bare `:filter` (or `:f`) clears it. Parsed off the un-lowercased + // input so the query lands in the box as typed. + const spaceIdx = input.indexOf(' ') + const head = (spaceIdx === -1 ? input : input.slice(0, spaceIdx)).toLowerCase() + if (head === 'filter' || head === 'f') { + setFilter(spaceIdx === -1 ? '' : input.slice(spaceIdx + 1).trim()) + return + } + const cmd = input.toLowerCase() const store = useStore.getState() const path = store.selectedPath switch (cmd) { @@ -368,7 +384,7 @@ export function TasksView(): JSX.Element { return } }, - [closeTasksView, refreshTasks, setViewMode] + [closeTasksView, refreshTasks, setFilter, setViewMode] ) // Window-level handler with two responsibilities: @@ -573,12 +589,21 @@ export function TasksView(): JSX.Element {
setFocusedPanel('tasks')} + onFocusCapture={() => setFocusedPanel('tasks')} >

Tasks

- {tasks.length} total + {filter.trim() ? `${filteredTasks.length} of ${tasks.length}` : `${tasks.length} total`} {loading && scanning…} @@ -606,28 +631,29 @@ export function TasksView(): JSX.Element {
- {viewMode === 'list' && ( - setFilter(e.target.value)} - onKeyDown={(e) => { - // While composing (IME), let the input own Enter/Arrows. (#183) - if (isImeComposing(e)) return - if (e.key === 'Escape') { - e.stopPropagation() - if (filter) setFilter('') - else e.currentTarget.blur() - } - if (e.key === 'Enter') { - e.currentTarget.blur() - } - }} - className="w-56 rounded-md border border-paper-300/60 bg-paper-200/60 px-2 py-1 text-xs outline-none focus:border-paper-400/70" - /> - )} + {/* Mounted in every sub-view: the same query narrows the list, the + calendar, and the Kanban board, so switching views keeps the + focus you typed. (#583) */} + setFilter(e.target.value)} + onKeyDown={(e) => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return + if (e.key === 'Escape') { + e.stopPropagation() + if (filter) setFilter('') + else e.currentTarget.blur() + } + if (e.key === 'Enter') { + e.currentTarget.blur() + } + }} + className="w-56 rounded-md border border-paper-300/60 bg-paper-200/60 px-2 py-1 text-xs outline-none focus:border-paper-400/70" + />