diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c73ea65e..d4c561bc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.56.0", + "version": "2.57.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/cli/vault-target.test.ts b/apps/desktop/src/cli/vault-target.test.ts index 2639caf2..be91f67c 100644 --- a/apps/desktop/src/cli/vault-target.test.ts +++ b/apps/desktop/src/cli/vault-target.test.ts @@ -9,7 +9,9 @@ import { resolveDefaultTarget, resolveTarget, resolveServerTarget, - resolveVaultTarget + resolveVaultTarget, + sameVault, + type VaultTarget } from './vault-target' let tmpDir: string @@ -290,3 +292,57 @@ describe('the default target follows the workspace the app has open (#688)', () expect(await resolveDefaultTarget(process.env)).toEqual({ kind: 'local', root: workVault }) }) }) + +describe('sameVault: a vault switch is a different vault, not new credentials', () => { + const local = (root: string): VaultTarget => ({ kind: 'local', root }) + const server = (baseUrl: string, name = '', authToken: string | null = null): VaultTarget => ({ + kind: 'remote', + name, + baseUrl, + authToken + }) + + it('matches a directory however its path is spelled, symlinks included', async () => { + const other = path.join(tmpDir, 'other') + await fsp.mkdir(other, { recursive: true }) + // Windows only allows symlinks with developer mode or elevation. + const link = path.join(tmpDir, 'work-link') + const linked = await fsp.symlink(workVault, link, 'dir').then( + () => link, + () => workVault + ) + expect(await sameVault(local(workVault), local(workVault))).toBe(true) + expect(await sameVault(local(workVault), local(`${other}/../work/`))).toBe(true) + expect(await sameVault(local(workVault), local(linked))).toBe(true) + expect(await sameVault(local(workVault), local(other))).toBe(false) + expect(await sameVault(local(path.join(tmpDir, 'a')), local(path.join(tmpDir, 'b')))).toBe(false) + }) + + it('matches a server by URL whatever the token or profile name', async () => { + expect( + await sameVault( + server('https://notes.example.com', 'home', 'old'), + server('https://notes.example.com', '', 'new') + ) + ).toBe(true) + expect(await sameVault(server('https://Notes.Example.com/'), server('https://notes.example.com'))).toBe(true) + expect(await sameVault(server('notes.example.com:7878'), server('http://notes.example.com:7878'))).toBe(true) + expect(await sameVault(server('http://notes.example.com:7878'), server('http://notes.example.com:7879'))).toBe(false) + expect(await sameVault(local(workVault), server('https://notes.example.com'))).toBe(false) + }) + + it('normalizes the origin without changing the case of a reverse-proxy path', async () => { + expect( + await sameVault(server('https://notes.example.com/Work'), server('https://notes.example.com/work')) + ).toBe(false) + expect( + await sameVault(server('HTTPS://Notes.Example.com:443/Work/'), server('https://notes.example.com/Work')) + ).toBe(true) + }) + + it('keeps query values case-sensitive too', async () => { + expect( + await sameVault(server('https://notes.example.com/?vault=Work'), server('https://notes.example.com/?vault=work')) + ).toBe(false) + }) +}) diff --git a/apps/desktop/src/cli/vault-target.ts b/apps/desktop/src/cli/vault-target.ts index 6a3cc79c..ed125549 100644 --- a/apps/desktop/src/cli/vault-target.ts +++ b/apps/desktop/src/cli/vault-target.ts @@ -18,6 +18,8 @@ * is immediately nameable from the CLI with nothing else to configure. */ +import { promises as fsp } from 'node:fs' +import path from 'node:path' import { normalizeBaseUrl } from '../main/remote/connection.js' import { readActiveWorkspaceFromConfig, @@ -32,6 +34,31 @@ export type VaultTarget = | { kind: 'local'; root: string } | { kind: 'remote'; name: string; baseUrl: string; authToken: string | null } +/** Two targets reach the same vault: the same server URL, or the same + * directory however its path is spelled. Tokens and profile names do not + * matter. */ +export async function sameVault(a: VaultTarget, b: VaultTarget): Promise { + if (a.kind === 'remote' || b.kind === 'remote') { + // URL normalizes the origin while preserving case-sensitive paths and + // query values: a reverse proxy may serve different vaults at /Work and /work. + return ( + a.kind === 'remote' && + b.kind === 'remote' && + new URL(normalizeBaseUrl(a.baseUrl)).href === new URL(normalizeBaseUrl(b.baseUrl)).href + ) + } + if (path.resolve(a.root) === path.resolve(b.root)) return true + try { + const [left, right] = await Promise.all([ + fsp.stat(a.root, { bigint: true }), + fsp.stat(b.root, { bigint: true }) + ]) + return left.dev === right.dev && left.ino === right.ino + } catch { + return false + } +} + /** Token precedence, loudest first: an explicit `--token`, then the * environment (the CI / headless case, where nothing is stored on disk), * then whatever the saved profile carries. Mirrors how `--vault` beats diff --git a/apps/desktop/src/mcp/instructions.ts b/apps/desktop/src/mcp/instructions.ts index aab786c5..10a41e8d 100644 --- a/apps/desktop/src/mcp/instructions.ts +++ b/apps/desktop/src/mcp/instructions.ts @@ -194,7 +194,9 @@ when the folder is \`Linear Algebra/\`), synonyms, and feeling tags \`due:YYYY-MM-DD\`, \`!high\` / \`!med\` / \`!low\`, \`@waiting\`, and \`#tag\`. -**Callouts**: \`> [!note]\`, \`> [!tip]\`, \`> [!warning]\`. +**Callouts**: \`> [!note]\`, \`> [!tip]\`, \`> [!warning]\`. A \`-\` right after the +type (\`> [!example]- Screenshots\`) makes the callout start collapsed, a \`+\` +starts it expanded; use \`-\` for long blocks a reader opens on demand. ## Tool etiquette diff --git a/apps/desktop/src/mcp/server.test.ts b/apps/desktop/src/mcp/server.test.ts index c1d08ee7..3e63d32e 100644 --- a/apps/desktop/src/mcp/server.test.ts +++ b/apps/desktop/src/mcp/server.test.ts @@ -15,6 +15,7 @@ import { describeToolError, listToolNames, runMcpServer, + VaultSwitchedError, type McpServerOptions } from './server' @@ -154,6 +155,19 @@ describe('describeToolError', () => { }) }) +describe('VaultSwitchedError', () => { + it('names a saved server by its quoted name and URL, and never the token', () => { + const err = new VaultSwitchedError( + { kind: 'remote', name: 'Server Vault (10.0.0.5:7878)', baseUrl: 'http://10.0.0.5:7878', authToken: 'tok-secret' }, + { kind: 'local', root: '/notes' } + ) + expect(err.message).toContain( + 'it was the server "Server Vault (10.0.0.5:7878)" at http://10.0.0.5:7878 and is now the local vault /notes.' + ) + expect(err.message).not.toContain('tok-secret') + }) +}) + describe('comment tools (#738)', () => { it('lists the four comment tools', () => { const names = listToolNames() @@ -270,12 +284,16 @@ describe('runMcpServer follows the target it is given (#831)', () => { await runMcpServer({ ...options, transport: serverTransport }) const client = new Client({ name: 'server-test', version: '0' }) await client.connect(clientTransport) + const call = async (name: string, args: Record = {}) => { + const result = await client.callTool({ name, arguments: args }) + return { text: (result.content as Array<{ text: string }>)[0].text, isError: !!result.isError } + } return { stderr, + call, vaultInfo: async () => { - const result = await client.callTool({ name: 'vault_info', arguments: {} }) - const text = (result.content as Array<{ text: string }>)[0].text - return result.isError ? { error: text } : { info: JSON.parse(text) as Record } + const { text, isError } = await call('vault_info') + return isError ? { error: text } : { info: JSON.parse(text) as Record } }, close: () => client.close() } @@ -347,13 +365,13 @@ describe('runMcpServer follows the target it is given (#831)', () => { } }) - it('pins the first vault that resolves and retries only after a failure', async () => { - // One attempt at startup (warned), one per failing tool call, then the - // session keeps the first vault that resolved. + it('retries until a vault resolves, and starts the session at the first call it serves', async () => { + // One attempt at startup (warned), then one per tool call. const outcomes: Array = [ new Error('not at startup'), new Error('not yet'), { kind: 'local', root: beta }, + { kind: 'local', root: beta }, { kind: 'local', root: alpha } ] let calls = 0 @@ -366,10 +384,114 @@ describe('runMcpServer follows the target it is given (#831)', () => { }) expect(s.stderr.join('')).toContain('not at startup') expect((await s.vaultInfo()).error).toBe('Error: not yet') - expect((await s.vaultInfo()).info).toMatchObject({ vaultRoot: beta }) - // A further call must not move the session to alpha: the target is pinned. - expect((await s.vaultInfo()).info).toMatchObject({ vaultRoot: beta }) - expect(calls).toBe(3) + // Nothing was served before, so beta is no switch. + const first = (await s.vaultInfo()).info + expect(first).toMatchObject({ vaultRoot: beta }) + expect(String(first?.notes)).not.toContain('The vault changed') + expect((await s.call('list_notes')).isError).toBe(false) + // The app moved to alpha: the next call stops instead of running there. + const moved = await s.call('list_notes') + expect(moved.isError).toBe(true) + expect(moved.text).toContain(`it was the local vault ${beta} and is now the local vault ${alpha}`) + expect(calls).toBe(5) + await s.close() + }) + + it('a vault the app left before the first call is no switch', async () => { + const s = await session() + await writeConfig({ vaultRoot: beta, localVaults: [{ root: beta, name: 'beta' }] }) + const listed = await s.call('list_notes') + expect(listed.isError).toBe(false) await s.close() }) + + it('refuses writes between case-sensitive server paths until vault_info confirms the switch', async () => { + const writes: string[] = [] + const fake = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost') + const prefix = url.pathname.startsWith('/Work/') ? '/Work' : '/work' + const route = url.pathname.slice(prefix.length) + if (req.method === 'POST') writes.push(url.pathname) + const bodies: Record = { + '/api/vault': { root: `/srv${prefix}`, name: prefix.slice(1) }, + '/api/vault/settings': { primaryNotesLocation: 'inbox', systemFolderPaths: null }, + '/api/folders': [], + '/api/notes/write': { path: 'inbox/Plan.md', title: 'Plan' } + } + res.writeHead(route in bodies ? 200 : 404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(bodies[route] ?? null)) + }) + await new Promise((resolve) => fake.listen(0, '127.0.0.1', resolve)) + const address = fake.address() + if (address == null || typeof address === 'string') throw new Error('no port') + const origin = `http://127.0.0.1:${address.port}` + let target: VaultTarget = { kind: 'remote', baseUrl: `${origin}/Work`, name: '', authToken: null } + let s: Awaited> | undefined + + try { + s = await session({ resolveTarget: async () => target }) + expect((await s.vaultInfo()).info).toMatchObject({ server: `${origin}/Work` }) + + target = { ...target, baseUrl: `${origin}/work` } + const refused = await s.call('write_note', { path: 'inbox/Plan.md', body: 'planned for Work' }) + expect(writes).toEqual([]) + expect(refused.isError).toBe(true) + expect(refused.text).toContain('The ZenNotes vault changed') + + const { info } = await s.vaultInfo() + expect(info).toMatchObject({ server: `${origin}/work` }) + expect(String(info?.notes)).toContain(`until this call the session worked in the server at ${origin}/Work`) + expect((await s.call('write_note', { path: 'inbox/Plan.md', body: 'planned for work' })).isError).toBe(false) + expect(writes).toEqual(['/work/api/notes/write']) + } finally { + await s?.close() + await new Promise((resolve) => fake.close(() => resolve())) + } + }) + + it('follows the app to another vault only after vault_info confirms it', async () => { + await fsp.writeFile(path.join(alpha, 'inbox', 'Alpha.md'), '# Alpha\n') + await fsp.writeFile(path.join(beta, 'inbox', 'Beta.md'), '# Beta\n') + try { + const s = await session() + const before = await s.call('list_notes') + expect(before.isError).toBe(false) + expect(before.text).toContain('inbox/Alpha.md') + + await writeConfig({ vaultRoot: beta, localVaults: [{ root: beta, name: 'beta' }] }) + // A write planned against alpha must not land in beta, and a whole + // batch stops, not only its first call. + const batch = await Promise.all([ + s.call('write_note', { path: 'inbox/Alpha.md', body: 'planned for alpha' }), + s.call('list_notes'), + s.call('read_note', { path: 'inbox/Alpha.md' }) + ]) + for (const result of batch) { + expect(result.isError).toBe(true) + expect(result.text).toContain('The ZenNotes vault changed') + expect(result.text).toContain(alpha) + expect(result.text).toContain(beta) + expect(result.text).toContain('vault_info') + } + await expect(fsp.access(path.join(beta, 'inbox', 'Alpha.md'))).rejects.toThrow() + expect(await fsp.readFile(path.join(alpha, 'inbox', 'Alpha.md'), 'utf8')).toBe('# Alpha\n') + + const { info } = await s.vaultInfo() + expect(info).toMatchObject({ kind: 'local', vaultRoot: beta }) + expect( + String(info?.notes).startsWith( + `The vault changed: until this call the session worked in the local vault ${alpha}.` + ) + ).toBe(true) + const after = await s.call('list_notes') + expect(after.isError).toBe(false) + expect(after.text).toContain('inbox/Beta.md') + expect(after.text).not.toContain('Alpha.md') + expect(String((await s.vaultInfo()).info?.notes)).not.toContain('The vault changed') + await s.close() + } finally { + await fsp.rm(path.join(alpha, 'inbox', 'Alpha.md'), { force: true }) + await fsp.rm(path.join(beta, 'inbox', 'Beta.md'), { force: true }) + } + }) }) diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index 8f227dce..3fed6e0f 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -20,14 +20,24 @@ import { import { resolveInstructions } from './instructions-store.js' import { createBackend, type VaultBackend } from '../cli/backend.js' -import { resolveDefaultTarget, type VaultTarget } from '../cli/vault-target.js' +import { resolveDefaultTarget, sameVault, type VaultTarget } from '../cli/vault-target.js' import { RemoteRequestError } from '../main/remote/connection.js' import type { NoteFolder } from './vault-ops.js' import { addComment, listCommentThreads, replyToComment, resolveComment } from './comment-ops.js' +interface ToolContext { + /** Set on the vault_info call that moved the session to a new vault: the + * vault it left. */ + previous?: VaultTarget | null +} + interface ToolDef { schema: Tool - handler: (args: Record, backend: VaultBackend) => Promise + handler: ( + args: Record, + backend: VaultBackend, + context?: ToolContext + ) => Promise } /* ---------- Comment authorship ---------------------------------------- */ @@ -122,10 +132,13 @@ const TOOLS: ToolDef[] = [ 'Return where the currently configured ZenNotes vault lives (a folder on this machine, or a self-hosted server the desktop app is connected to) and its top-level layout. Call this once at the start of a session to confirm you are pointing at the right vault, and to learn whether the user runs in `inbox` or `root` primary mode (the answer changes how every other tool behaves).', inputSchema: { type: 'object', properties: {} } }, - handler: async (_args, backend) => { + handler: async (_args, backend, context) => { const [description, folders] = await Promise.all([backend.describe(), backend.listFolders()]) const primaryNotesLocation = description.primaryNotesLocation const isRootMode = primaryNotesLocation === 'root' + const switchNote = context?.previous + ? `The vault changed: until this call the session worked in ${describeTarget(context.previous)}. Every tool runs here now, and paths or content you got from the previous vault do not apply to this one. ` + : '' const pathNotes = 'IMPORTANT: Always use the `path` returned by other tools verbatim. Never prepend `inbox/` to a path you got back from list_notes / create_note / read_note. ' + (isRootMode @@ -146,6 +159,7 @@ const TOOLS: ToolDef[] = [ subfolders: folders, authConfigured: description.authConfigured, notes: + switchNote + 'This vault lives on a self-hosted ZenNotes server, the workspace the desktop app currently has open. Every tool works on it through the server API; paths are vault-relative POSIX paths exactly as the server reports them. ' + pathNotes + (description.authConfigured @@ -164,7 +178,7 @@ const TOOLS: ToolDef[] = [ inboxAbsolutePath: isRootMode ? vault : `${vault}/inbox`, topFolders: ['inbox', 'quick', 'archive', 'trash'], subfolders: folders, - notes: pathNotes + notes: switchNote + pathNotes } } }, @@ -966,12 +980,96 @@ export function describeToolError(err: unknown): string { return message } +/** A vault named in a sentence. The app often names a saved server after its + * host already ("Server Vault (10.0.0.5:7878)"), so the name is quoted and + * the URL follows "at" rather than a second parenthesis. */ +function describeTarget(target: VaultTarget): string { + if (target.kind === 'local') return `the local vault ${target.root}` + return target.name + ? `the server "${target.name}" at ${target.baseUrl}` + : `the server at ${target.baseUrl}` +} + +function sameTarget(a: VaultTarget, b: VaultTarget): boolean { + if (a.kind === 'local' || b.kind === 'local') { + return a.kind === 'local' && b.kind === 'local' && a.root === b.root + } + return a.baseUrl === b.baseUrl && a.name === b.name && a.authToken === b.authToken +} + +/** The vault changed after the session last served a call. */ +export class VaultSwitchedError extends Error { + constructor( + readonly from: VaultTarget, + readonly to: VaultTarget + ) { + super( + `The ZenNotes vault changed since this session's last call: it was ${describeTarget(from)} ` + + `and is now ${describeTarget(to)}. Nothing ran. Call vault_info to confirm the new vault and ` + + 'tell the user, then re-read anything you meant to change before you retry.' + ) + } +} + +/** + * The vault the tools run against, resolved again before every call so the + * server follows the desktop app the way a fresh `zn` does. Pinning the first + * vault for the life of the process left an agent working on a server after + * the app had moved back to a local vault, until the client restarted the + * server. + * + * A switch still never redirects work silently. An agent that read notes in + * one vault and writes after the user switched would change a note at the + * same path in the other one, so once the vault changes, every call except + * vault_info fails with VaultSwitchedError until vault_info confirms the new + * vault. That holds for a whole batch of parallel calls, not only the first. + * The Go `zn mcp` (ZenNotes/tui, internal/mcp/session.go) behaves the same. + */ +export class VaultSession { + private target: VaultTarget | null = null + private backend: VaultBackend | null = null + private queue: Promise = Promise.resolve() + + constructor( + private readonly resolve: () => Promise, + private readonly open: (target: VaultTarget) => VaultBackend = createBackend + ) {} + + /** The backend for one call. `confirm` marks vault_info, the only call that + * moves the session to a new vault; `previous` is the vault it moved away + * from, null when the vault did not change. Calls resolve one at a time so + * a batch sees one consistent session. */ + backendFor(confirm: boolean): Promise<{ backend: VaultBackend; previous: VaultTarget | null }> { + const next = this.queue.then(() => this.step(confirm)) + this.queue = next.catch(() => undefined) + return next + } + + private async step( + confirm: boolean + ): Promise<{ backend: VaultBackend; previous: VaultTarget | null }> { + const target = await this.resolve() + let previous: VaultTarget | null = null + if (this.target && this.backend && !(await sameVault(this.target, target))) { + if (!confirm) throw new VaultSwitchedError(this.target, target) + previous = this.target + } + // A new token or profile name for the same vault reopens quietly. + if (!this.target || !this.backend || !sameTarget(this.target, target)) { + this.backend = this.open(target) + this.target = target + } + return { backend: this.backend, previous } + } +} + export interface McpServerOptions { /** - * Which vault the tools run against. `zn mcp` passes the target its - * `--vault` / `--server` / `--token` flags name (#831); the legacy stdio - * entry has no flags and follows the environment, then the workspace the - * desktop app has open (#688), which is also the default here. + * Which vault the tools run against, asked again before every tool call + * (see VaultSession). `zn mcp` passes the target its `--vault` / + * `--server` / `--token` flags name (#831); the legacy stdio entry has no + * flags and follows the environment, then the workspace the desktop app + * has open (#688), which is also the default here. */ resolveTarget?: () => Promise /** Defaults to stdio, the only transport the clients speak. Tests bind an @@ -981,28 +1079,18 @@ export interface McpServerOptions { export async function runMcpServer(options: McpServerOptions = {}): Promise { const resolveTarget = options.resolveTarget ?? (() => resolveDefaultTarget()) - - // Resolve the vault once and keep it for the session. When nothing resolves - // yet we still boot so the client surface stays consistent; every tool call - // then reports the error, and the next call tries again rather than - // repeating a stale failure. - let backendPromise: Promise | null = null - const getBackend = (): Promise => { - if (!backendPromise) { - backendPromise = resolveTarget().then(createBackend) - backendPromise.catch(() => { - backendPromise = null - }) - } - return backendPromise - } + // When nothing resolves yet we still boot so the client surface stays + // consistent; every tool call then reports the error, and the next call + // tries again rather than repeating a stale failure. + const vaults = new VaultSession(resolveTarget) // Try at startup and say so on stderr when it fails: a terminal shows it at // once and MCP clients keep it in their server logs, whereas waiting for the // first tool call hid a `--vault` typo in a client config until the // assistant tripped over it (#831). stdout is the protocol channel and - // stays clean. - await getBackend().catch((err: unknown) => { + // stays clean. The session starts at the first call it serves, so a vault + // the app left before that call never counts as a switch. + await resolveTarget().catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) process.stderr.write( `[zennotes-mcp] ${message} The MCP server is running anyway; every tool call returns this error until a vault resolves.\n` @@ -1036,8 +1124,10 @@ export async function runMcpServer(options: McpServerOptions = {}): Promise, backend) + const { backend, previous } = await vaults.backendFor(name === 'vault_info') + const result = await tool.handler((args ?? {}) as Record, backend, { + previous + }) const payload = typeof result === 'string' ? result : JSON.stringify(result, null, 2) return { content: [{ type: 'text', text: payload }] } diff --git a/apps/desktop/src/renderer/export-window.tsx b/apps/desktop/src/renderer/export-window.tsx index bc053635..529302ac 100644 --- a/apps/desktop/src/renderer/export-window.tsx +++ b/apps/desktop/src/renderer/export-window.tsx @@ -460,6 +460,14 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element { markdown={withExportTitle(note.body, note.title).markdown} notePath={note.path} onRendered={() => { + // A PDF cannot be clicked open: print collapsed callouts open, + // before the image wait below, since their images load only + // once they show (#853). + document + .querySelectorAll('[data-preview-content] details.callout') + .forEach((callout) => { + callout.open = true + }) // Images load after the DOM is in place, and the preview defers // the ones below the viewport; print only once they have all // settled (#769). diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index a3ffbfaf..2b98d750 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.4.0", - "commit": "6481d171916f6d84de05a50a81c93ffef4273ff7", + "version": "0.4.1", + "commit": "86e2149b94fc27587a704fc02afd25e5a42c5cc2", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.0/zn_0.4.0_darwin_arm64.tar.gz", - "sha256": "1991df8d6893c4bab37ceb0eb34d3aab9026c7d69d265f12212e7ec05af887c3" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.1/zn_0.4.1_darwin_arm64.tar.gz", + "sha256": "3c83f847268c26279121537c807785dfab0403fa0cae248f149d09a11a2e2eda" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.0/zn_0.4.0_darwin_amd64.tar.gz", - "sha256": "69861fbdfedca6710b0b44d9a39ab781ba3073bab6567cca5d92cedfed0c041f" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.1/zn_0.4.1_darwin_amd64.tar.gz", + "sha256": "d541dcc3fbfd15d1f702a71ff4d629d1a1907a21d055c23df27934925e9f4557" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.0/zn_0.4.0_linux_arm64.tar.gz", - "sha256": "3fda655425d9f41b7961e1a8f119bb9282712c7877a31d19b3ac1548a478a2dd" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.1/zn_0.4.1_linux_arm64.tar.gz", + "sha256": "55d6999163b66fc0e29a3a35a449f37175c448bb7e6e22072ee1c970769fd739" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.0/zn_0.4.0_linux_amd64.tar.gz", - "sha256": "03032ff80b14effcecb065533186e72416030b2b69b4c4d3fd67d8cadb73cede" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.4.1/zn_0.4.1_linux_amd64.tar.gz", + "sha256": "42e5e64edde08f3145883ffcfeaf197d6290d966074ce0e28f054d254bb806c1" } } } diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index de598711..1db36cbf 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.56.0", + "version": "2.57.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index 833ec664..f59949b9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.56.0", + "version": "2.57.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/docs/ideas/atlas.md b/docs/ideas/atlas.md index 6d54fe3a..51c63b66 100644 --- a/docs/ideas/atlas.md +++ b/docs/ideas/atlas.md @@ -2,11 +2,18 @@ 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 `?`. +> **Status (September 2026).** Atlas v1 shipped in 2.30.0 (`Space g`): the +> map and the sky behind `v`, lenses 1 to 4, hint jump, filter, and regions +> taken from top-level folders, with positions cached per vault by the app +> rather than in `.zennotes/atlas.json`. Since 2.57.0 a note added after the +> map was drawn takes the nearest free spot beside what it links to (#861). +> The rest of this document (clustering and named regions, trace, replay, +> suggestions, the reflow command) is still design. +> +> Written in August 2026 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 @@ -134,9 +141,13 @@ honest. - 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. +- Incremental: a new note takes the nearest free spot around the notes it + links to (around its region's slot while none of them is on the map yet), + clear of every dot already there. Existing positions never shift, with one + exception: a note sitting on a note it links to moves to the nearest free + spot. That is what versions before 2.57.0 cached for notes written after the + first visit, when a newcomer landed on its neighbors' centroid with only + jitter to separate it (#861). - 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. diff --git a/package-lock.json b/package-lock.json index 76bbe24b..877b7067 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.56.0", + "version": "2.57.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.56.0", + "version": "2.57.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.56.0", + "version": "2.57.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -874,7 +874,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.56.0", + "version": "2.57.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -945,7 +945,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.56.0", + "version": "2.57.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16382,7 +16382,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.56.0", + "version": "2.57.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16469,14 +16469,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.56.0", + "version": "2.57.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.56.0", + "version": "2.57.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16488,7 +16488,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.56.0" + "version": "2.57.0" } } } diff --git a/package.json b/package.json index 891815a5..d8adcde1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.56.0", + "version": "2.57.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 db9d902c..2a6e732b 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.56.0", + "version": "2.57.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/app-core/src/components/AssetsView.test.ts b/packages/app-core/src/components/AssetsView.test.ts new file mode 100644 index 00000000..056c7976 --- /dev/null +++ b/packages/app-core/src/components/AssetsView.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AssetMeta, NoteMeta } from '@shared/ipc' +import { useStore } from '../store' +import { AssetsView } from './AssetsView' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const focusEditorNormalMode = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode })) + +function note(title: string, assetEmbeds: string[]): NoteMeta { + return { + path: `inbox/${title}.md`, + title, + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + hasAttachments: false, + assetEmbeds, + excerpt: '' + } +} + +function asset(name: string): AssetMeta { + return { path: `assets/${name}`, name, kind: 'image', siblingOrder: 0, size: 1, updatedAt: 0 } +} + +describe('AssetsView: a note opened through the asset it uses takes the keyboard (#863)', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType + let openNoteInTab: ReturnType + + beforeEach(() => { + originalState = useStore.getState() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + focusEditorNormalMode.mockClear() + openNoteInTab = vi.fn(async () => {}) + useStore.setState({ + vault: { ...(originalState.vault ?? {}), root: '/vault', name: 'Vault' } as NonNullable, + assetFiles: [asset('solo.png'), asset('shared.png')], + notes: [note('Alpha', ['solo.png', 'shared.png']), note('Beta', ['shared.png'])], + openNoteInTab + }) + act(() => root.render(createElement(AssetsView))) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + }) + + const usedButton = (assetPath: string): HTMLButtonElement => { + const row = host.querySelector(`[title="${assetPath}"]`) + const button = row && [...row.querySelectorAll('button')].find((b) => !b.disabled) + expect(button).toBeTruthy() + return button as HTMLButtonElement + } + + it('from the Used count, when one note uses the asset', async () => { + await act(async () => usedButton('assets/solo.png').click()) + expect(openNoteInTab).toHaveBeenCalledWith('inbox/Alpha.md') + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) + + it('from the menu of notes, when several use it', async () => { + await act(async () => usedButton('assets/shared.png').click()) + const item = [...document.querySelectorAll('[data-ctx-menu] button, [data-ctx-menu] [role="menuitem"]')].find( + (el) => el.textContent?.includes('Beta') + ) + expect(item).toBeTruthy() + await act(async () => item!.click()) + expect(openNoteInTab).toHaveBeenCalledWith('inbox/Beta.md') + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) + + it('but opening the asset itself leaves the keyboard alone', async () => { + const row = host.querySelector('[title="assets/solo.png"]') + await act(async () => row!.click()) + expect(openNoteInTab).toHaveBeenCalledWith(expect.stringContaining('solo.png')) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/components/AssetsView.tsx b/packages/app-core/src/components/AssetsView.tsx index 406e0020..3a4e2798 100644 --- a/packages/app-core/src/components/AssetsView.tsx +++ b/packages/app-core/src/components/AssetsView.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react' import type { AssetMeta } from '@shared/ipc' import { useStore, type AssetSortColumn, type AssetSortOrder } from '../store' import { assetTabPath } from '../lib/asset-tabs' +import { focusEditorNormalMode } from '../lib/editor-focus' import { confirmMoveToTrash } from '../lib/confirm-trash' import { promptApp } from '../lib/prompt-requests' import { naturalCompare } from '../lib/natural-sort' @@ -126,6 +127,12 @@ export function AssetsView(): JSX.Element { const setAssetSortOrder = useStore((s) => s.setAssetSortOrder) const sort = useMemo(() => parseAssetSortOrder(assetSortOrder), [assetSortOrder]) + // A note found through the asset it uses opens for editing: hand it the + // keyboard, since the click left focus on this view's button (#863). + const openUsingNote = (path: string): void => { + void openNoteInTab(path).then(() => focusEditorNormalMode()) + } + // assetPath → note paths that embed it (resolved via relative-path + the // unique-basename fallback, matching how embeds render). (#185) const usage = useMemo(() => { @@ -293,7 +300,7 @@ export function AssetsView(): JSX.Element { onClick={(e) => { e.stopPropagation() const notePaths = usage.get(asset.path) ?? [] - if (notePaths.length === 1) void openNoteInTab(notePaths[0]!) + if (notePaths.length === 1) openUsingNote(notePaths[0]!) else if (notePaths.length > 1) setUsageMenu({ x: e.clientX, y: e.clientY, notes: notePaths }) }} @@ -356,7 +363,7 @@ export function AssetsView(): JSX.Element { y={usageMenu.y} items={usageMenu.notes.map((notePath) => ({ label: noteLabel(notePath), - onSelect: () => void openNoteInTab(notePath) + onSelect: () => openUsingNote(notePath) }))} onClose={() => setUsageMenu(null)} /> diff --git a/packages/app-core/src/components/AtlasView.test.ts b/packages/app-core/src/components/AtlasView.test.ts new file mode 100644 index 00000000..22e1ef3a --- /dev/null +++ b/packages/app-core/src/components/AtlasView.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NoteMeta } from '@shared/ipc' +import { ATLAS_TAB_PATH } from '@shared/atlas-view' +import { useStore } from '../store' +import { makeLeaf } from '../lib/pane-layout' +import { AtlasView } from './AtlasView' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const focusEditorNormalMode = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode })) + +function note(title: string): NoteMeta { + return { + path: `inbox/${title}.md`, + title, + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + hasAttachments: false, + assetEmbeds: [], + excerpt: '' + } +} + +const press = (key: string, code: string): void => { + window.dispatchEvent(new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true })) +} + +// The map is a canvas jsdom cannot draw, so a note is picked the keyboard's +// way (a region jump focuses one, Enter opens it); the second click on a node +// goes through the same opener and is checked in the built app. +describe('AtlasView: opening a note from the map hands the keyboard to the editor (#863)', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType + let selectNote: ReturnType + + beforeEach(() => { + originalState = useStore.getState() + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + focusEditorNormalMode.mockClear() + selectNote = vi.fn(async () => {}) + ;(window as unknown as { zen: unknown }).zen = { readNote: vi.fn(async () => ({ body: '' })) } + const leaf = makeLeaf([ATLAS_TAB_PATH], ATLAS_TAB_PATH) + useStore.setState({ + vault: { ...(originalState.vault ?? {}), root: '/vault', name: 'Vault' } as NonNullable, + notes: [note('Alpha')], + vimMode: true, + paneLayout: leaf, + activePaneId: leaf.id, + selectNote + }) + act(() => root.render(createElement(AtlasView))) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + vi.restoreAllMocks() + }) + + it('on Enter, once the note has opened', async () => { + let opened: () => void = () => {} + selectNote.mockImplementation(() => new Promise((resolve) => (opened = resolve))) + await act(async () => { + press(']', 'BracketRight') + press('Enter', 'Enter') + }) + expect(selectNote).toHaveBeenCalledWith('inbox/Alpha.md') + expect(focusEditorNormalMode).not.toHaveBeenCalled() + await act(async () => opened()) + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/app-core/src/components/AtlasView.tsx b/packages/app-core/src/components/AtlasView.tsx index 5aa4706b..015e2d14 100644 --- a/packages/app-core/src/components/AtlasView.tsx +++ b/packages/app-core/src/components/AtlasView.tsx @@ -6,6 +6,7 @@ import { useStore, isAtlasViewActive } from '../store' import { applyExtraLinkEdges, atlasHoldsKeyboard, + atlasNodeRadius, atlasRegionDirection, buildAtlasGraph, collectAtlasPositions, @@ -14,6 +15,7 @@ import { type AtlasPositions } from '../lib/atlas' import { isAppOverlayOpen } from '../lib/overlay-open' +import { focusEditorNormalMode } from '../lib/editor-focus' import { extractMarkdownLinkHrefs } from '../lib/wikilinks' import { resolveInternalNoteHref } from '../lib/internal-links' @@ -451,7 +453,7 @@ export function AtlasView(): JSX.Element { 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) + const base = Math.max(1.4, atlasNodeRadius(n.degree) * 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 @@ -596,7 +598,7 @@ export function AtlasView(): JSX.Element { 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 r = Math.max(1.4, atlasNodeRadius(n.degree) * p.s) + 6 const d = (p.sx - sx) ** 2 + (p.sy - sy) ** 2 if (d < r * r && p.depth < bd) { bd = p.depth @@ -669,9 +671,15 @@ export function AtlasView(): JSX.Element { setStatus(reg.label) touch() } + // Opening a note leaves the map, and the map held the keyboard: without a + // hand-off typing went nowhere with Vim mode off and to the sidebar with it + // on (#863). + function openNote(path: string): void { + void selectNote(path).then(() => focusEditorNormalMode()) + } function openFocused(): void { const path = world.current.focusPath - if (path) void selectNote(path) + if (path) openNote(path) } // Keyboard: capture phase so it beats VimNav; single letters are Vim-only @@ -892,7 +900,7 @@ export function AtlasView(): JSX.Element { 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) + if (world.current.focusPath === path) openNote(path) else world.current.focusPath = path } else world.current.focusPath = null } diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts index fa5a7f12..5e04abc1 100644 --- a/packages/app-core/src/components/CloudSettings.test.ts +++ b/packages/app-core/src/components/CloudSettings.test.ts @@ -49,6 +49,7 @@ const mocks = vi.hoisted(() => ({ requestCloudAutoSync: vi.fn(), syncCloudVaultWithStatus: vi.fn(), confirmApp: vi.fn(async () => true), + focusEditorNormalMode: vi.fn(), })); vi.mock("@zennotes/bridge-contract/bridge", () => ({ @@ -59,6 +60,10 @@ vi.mock("../lib/confirm-requests", () => ({ confirmApp: mocks.confirmApp, })); +vi.mock("../lib/editor-focus", () => ({ + focusEditorNormalMode: mocks.focusEditorNormalMode, +})); + vi.mock("../lib/cloud-auto-sync", async (importOriginal) => ({ ...(await importOriginal()), requestCloudAutoSync: mocks.requestCloudAutoSync, @@ -617,9 +622,12 @@ describe("CloudSettings", () => { const openCopy = [...list!.querySelectorAll("button")].find( (button) => button.textContent?.trim() === "Open copy", ); + mocks.focusEditorNormalMode.mockClear(); await act(async () => openCopy!.click()); expect(setSettingsOpen).toHaveBeenCalledWith(false); expect(openNoteInTab).toHaveBeenCalledWith("inbox/Plan (cloud conflict).md"); + // Settings held the keyboard; the note opened behind it takes it (#863). + expect(mocks.focusEditorNormalMode).toHaveBeenCalledTimes(1); } finally { useStore.setState(previous); } diff --git a/packages/app-core/src/components/CloudSettings.tsx b/packages/app-core/src/components/CloudSettings.tsx index ed85aa4a..8827cab6 100644 --- a/packages/app-core/src/components/CloudSettings.tsx +++ b/packages/app-core/src/components/CloudSettings.tsx @@ -36,6 +36,7 @@ import { notifyPublishedNoteChanged } from "../lib/published-note-events"; import { requestPublishNote } from "../lib/publish-note-requests"; import { Button } from "./ui/Button"; import { useStore } from "../store"; +import { focusEditorNormalMode } from "../lib/editor-focus"; import { CloudPendingConflictResolver } from "./CloudPendingConflictResolver"; type CloudAction = @@ -2020,7 +2021,9 @@ function CloudSyncSummary({ const openPath = (path: string): void => { const store = useStore.getState(); store.setSettingsOpen(false); - void store.openNoteInTab(path); + // Settings held the keyboard; the note opened behind it takes it now, + // or typing went nowhere once the modal closed (#863). + void store.openNoteInTab(path).then(() => focusEditorNormalMode()); }; return (
vi.fn()) +vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode })) +const promptApp = vi.hoisted(() => vi.fn()) +vi.mock('../lib/prompt-requests', () => ({ promptApp })) + +function meta(title: string): NoteMeta { + return { + path: `inbox/${title}.md`, + title, + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + hasAttachments: false, + assetEmbeds: [], + excerpt: '' + } +} + +const bodies: Record = { + 'inbox/Alpha.md': '# Alpha\n\nSee [[Beta]], and [[Gamma]] which does not exist yet.\n', + 'inbox/Beta.md': '# Beta\n\nBack to [[Alpha]].\n' +} + +describe('ConnectionsPanel: following a connection hands the keyboard to the editor (#863)', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType + let selectNote: ReturnType + let createAndOpen: ReturnType + + beforeEach(async () => { + originalState = useStore.getState() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + focusEditorNormalMode.mockClear() + promptApp.mockReset() + selectNote = vi.fn(async () => {}) + createAndOpen = vi.fn(async () => {}) + ;(window as unknown as { zen: unknown }).zen = { + readNote: vi.fn(async (path: string) => ({ + ...meta(path.replace(/^inbox\/|\.md$/g, '')), + body: bodies[path] ?? '' + })) + } + useStore.setState({ notes: [meta('Alpha'), meta('Beta')], assetFiles: [], selectNote, createAndOpen }) + const note: NoteContent = { ...meta('Alpha'), body: bodies['inbox/Alpha.md'] } + await act(async () => root.render(createElement(ConnectionsPanel, { note }))) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + }) + + const rowsFor = (path: string): HTMLButtonElement[] => [ + ...host.querySelectorAll(`[data-connections-type="note"][data-connections-path="${path}"]`) + ] + + it('from a link out of the note', async () => { + await act(async () => rowsFor('inbox/Beta.md')[0]!.click()) + expect(selectNote).toHaveBeenCalledWith('inbox/Beta.md') + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) + + it('from a backlink', async () => { + // Beta shows twice: linked from Alpha, and linking back to it. + const rows = rowsFor('inbox/Beta.md') + expect(rows).toHaveLength(2) + await act(async () => rows[1]!.click()) + expect(selectNote).toHaveBeenCalledWith('inbox/Beta.md') + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) + + it('only once the note has opened', async () => { + let opened: () => void = () => {} + selectNote.mockImplementation(() => new Promise((resolve) => (opened = resolve))) + await act(async () => rowsFor('inbox/Beta.md')[0]!.click()) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + await act(async () => opened()) + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) + + it('after creating the note a missing link names', async () => { + promptApp.mockResolvedValue('inbox/Gamma.md') + const missing = host.querySelector('[data-connections-type="missing"]') + expect(missing).not.toBeNull() + await act(async () => missing!.click()) + expect(createAndOpen).toHaveBeenCalledWith('inbox', '', { title: 'Gamma' }) + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/app-core/src/components/ConnectionsPanel.tsx b/packages/app-core/src/components/ConnectionsPanel.tsx index f046ea85..6e51c931 100644 --- a/packages/app-core/src/components/ConnectionsPanel.tsx +++ b/packages/app-core/src/components/ConnectionsPanel.tsx @@ -14,6 +14,7 @@ import { classifyOutgoingWikilinks, type AttachmentLink } from '../lib/connectio import { assetTabPath } from '../lib/asset-tabs' import { LazyNoteHoverPreview as NoteHoverPreview } from './LazyNoteHoverPreview' import { promptApp } from '../lib/prompt-requests' +import { focusEditorNormalMode } from '../lib/editor-focus' import { usePanelResize } from '../lib/use-panel-resize' import { PanelResizeHandle } from './PanelResizeHandle' @@ -137,6 +138,15 @@ export function ConnectionsPanel({ [connectionPreview, notes] ) + // Following a connection opens that note in the editor beside the panel, + // but a click left DOM focus on the row, or on once the panel + // re-rendered for the new note, so typing never reached it: nowhere with + // Vim mode off, the sidebar with it on. Enter on a row already hands the + // keyboard over through VimNav; a click does the same now (#863). + const openConnection = (path: string): void => { + void selectNote(path).then(() => focusEditorNormalMode()) + } + const handleCreateMissingLink = async (item: MissingLinkItem): Promise => { const value = await promptApp({ title: `Create note for "${item.target}"?`, @@ -164,9 +174,11 @@ export function ConnectionsPanel({ ) if (existing) { await selectNote(existing.path) + focusEditorNormalMode() return } await createAndOpen(parsed.folder, parsed.subpath, { title: parsed.title }) + focusEditorNormalMode() } useEffect(() => { @@ -298,7 +310,7 @@ export function ConnectionsPanel({ key={item.path} note={item} summary={item.excerpt || 'No excerpt available yet.'} - onOpen={() => void selectNote(item.path)} + onOpen={() => openConnection(item.path)} onHover={(rect) => { cancelScheduledClose() setPreviewFromRect(item, rect) @@ -353,7 +365,7 @@ export function ConnectionsPanel({ .filter(Boolean) .join(' · ') || 'No excerpt available yet.' } - onOpen={() => void selectNote(item.path)} + onOpen={() => openConnection(item.path)} onHover={(rect) => { cancelScheduledClose() setPreviewFromRect(item, rect) @@ -378,7 +390,7 @@ export function ConnectionsPanel({ note={item.note} summary={item.snippet} tone="mention" - onOpen={() => void selectNote(item.note.path)} + onOpen={() => openConnection(item.note.path)} onHover={(rect) => { cancelScheduledClose() setPreviewFromRect(item.note, rect) diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index e586cad6..9bbbc375 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -27,7 +27,8 @@ import { } from "../lib/cm-harper"; import { harperEditorConfig } from "../lib/harper-runtime"; import { moveLineDown, moveLineUp } from "@codemirror/commands"; -import { foldAll, unfoldAll, foldCode, unfoldCode } from "@codemirror/language"; +import { unfoldAll } from "@codemirror/language"; +import { foldAllOutline, foldAtCursor, unfoldAtCursor } from "../lib/cm-heading-fold"; import { isTagsViewActive, isTasksViewActive, useStore } from "../store"; import { buildCommands, type Command } from "../lib/commands"; import { rankItems } from "../lib/fuzzy-score"; @@ -1284,27 +1285,26 @@ function registerVimNoteCommands(): void { void useStore.getState().openTrashView(); }); - // Heading fold helpers — wrap CodeMirror's commands so they work on - // whichever pane currently owns the editor. `:fold` / `:unfold` act - // on the current heading; `:foldall` / `:unfoldall` cover the whole - // note. We map vim's `zc` / `zo` / `zM` / `zR` keys explicitly so the - // advertised fold chords work regardless of what CM-Vim ships by default. - const runFold = ( - cmd: (view: { state: unknown; dispatch: unknown }) => boolean, - ): void => { + // Fold helpers: run on whichever pane currently owns the editor. + // `:fold` / `:unfold` act on the heading or list item at the cursor + // (#848); `:foldall` / `:unfoldall` cover the whole note, headings and + // lists nested. We map vim's `zc` / `zo` / `zM` / `zR` keys explicitly so + // the advertised fold chords work regardless of what CM-Vim ships by + // default. The action ids keep their heading names; config refers to them. + const runFold = (cmd: (view: EditorView) => boolean): void => { const view = useStore.getState().editorViewRef; if (!view) return; - cmd(view as unknown as Parameters[0]); + cmd(view); view.focus(); }; - Vim.defineAction("foldHeadingAtCursor", () => runFold(foldCode as never)); - Vim.defineAction("unfoldHeadingAtCursor", () => runFold(unfoldCode as never)); - Vim.defineAction("foldAllHeadings", () => runFold(foldAll as never)); - Vim.defineAction("unfoldAllHeadings", () => runFold(unfoldAll as never)); - Vim.defineEx("fold", "fold", () => runFold(foldCode as never)); - Vim.defineEx("unfold", "unfold", () => runFold(unfoldCode as never)); - Vim.defineEx("foldall", "foldall", () => runFold(foldAll as never)); - Vim.defineEx("unfoldall", "unfoldall", () => runFold(unfoldAll as never)); + Vim.defineAction("foldHeadingAtCursor", () => runFold(foldAtCursor)); + Vim.defineAction("unfoldHeadingAtCursor", () => runFold(unfoldAtCursor)); + Vim.defineAction("foldAllHeadings", () => runFold(foldAllOutline)); + Vim.defineAction("unfoldAllHeadings", () => runFold(unfoldAll)); + Vim.defineEx("fold", "fold", () => runFold(foldAtCursor)); + Vim.defineEx("unfold", "unfold", () => runFold(unfoldAtCursor)); + Vim.defineEx("foldall", "foldall", () => runFold(foldAllOutline)); + Vim.defineEx("unfoldall", "unfoldall", () => runFold(unfoldAll)); } /** Flatten the pane tree to a list of leaves, independent of the store's diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 2ed3b5cf..65c6d150 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -94,9 +94,9 @@ import { defaultHighlightStyle } from '@codemirror/language' import { - foldHeadingAtCursor, + foldAtCursor, headingFolding, - unfoldHeadingAtCursor + unfoldAtCursor } from '../lib/cm-heading-fold' import { tags as t } from '@lezer/highlight' import { autocompletion } from '@codemirror/autocomplete' @@ -108,6 +108,7 @@ import { livePreviewPlugin } from '../lib/cm-live-preview' import { codeBlockFlairPlugin } from '../lib/cm-code-block-flair' import { tablePlugin, tableVimEntry } from '../lib/cm-table' import { wysiwygBlocksPlugin } from '../lib/cm-wysiwyg-blocks' +import { calloutFolding } from '../lib/cm-callout-fold' import { hashtagExtension } from '../lib/cm-hashtags' import { taskMetadataExtension } from '../lib/cm-task-metadata' import { liveTemplateTokenExtension } from '../lib/cm-live-template-tokens' @@ -222,10 +223,18 @@ import { previewShowsSourceLine, previewVisibleSourceLines, scrollTopForElementRelativeTop, - scrollTopForScrollRatio, shouldSyncPreviewFromEditorViewport, type PreviewEditRequest } from '../lib/preview-outline-jump' +import { + mapSplitScrollTop, + SPLIT_SCROLL_LEAD_MS, + splitScrollAnchors, + splitScrollEchoes, + type SplitScrollAnchor, + type SplitScrollLead, + type SplitScrollPane +} from '../lib/split-scroll-sync' import { ArchiveIcon, ArrowLeftIcon, @@ -368,15 +377,16 @@ function buildEditorKeymap(vimMode: boolean, overrides: KeymapOverrides): Extens // edge and misland on wrapped lines under fractional display scaling // (#591, the same resolution #575 removed from `$`). ...displayRowBoundaryKeymap, - { - key: 'Mod-f', - run: () => { - const state = useStore.getState() - if (state.vimMode) return false - state.setSearchOpen(true) - return true - } - }, + // Note search's non-Vim shortcut has to win over the find bar's Mod-f in + // here, but it is the user's binding: unbound or moved under Settings → + // Keymap, Mod+F falls through to the find bar instead of still opening + // note search. (#860) + ...keyBindingsFor(getKeymapBinding(overrides, 'global.searchNotesNonVim'), () => { + const state = useStore.getState() + if (state.vimMode) return false + state.setSearchOpen(true) + return true + }), // Move the current line (or selection) up/down — reorders the markdown so // it persists in the file. Listed before defaultKeymap so the configured // binding wins; works in Vim normal/insert and non-Vim alike. @@ -392,8 +402,8 @@ function buildEditorKeymap(vimMode: boolean, overrides: KeymapOverrides): Extens // reaching for the arrow keys. Mode-agnostic like the line moves. (#490) ...keyBindingsFor(getKeymapBinding(overrides, 'editor.hopMarkerForward'), markerHop.forward), ...keyBindingsFor(getKeymapBinding(overrides, 'editor.hopMarkerBackward'), markerHop.backward), - ...keyBindingsFor(getKeymapBinding(overrides, 'editor.foldHeading'), foldHeadingAtCursor), - ...keyBindingsFor(getKeymapBinding(overrides, 'editor.unfoldHeading'), unfoldHeadingAtCursor), + ...keyBindingsFor(getKeymapBinding(overrides, 'editor.foldHeading'), foldAtCursor), + ...keyBindingsFor(getKeymapBinding(overrides, 'editor.unfoldHeading'), unfoldAtCursor), // Inline-format shortcuts (bold/italic/code/strike/highlight/math/link). In // Vim mode VimNav owns these (its window handler also resolves the Ctrl+I // jumplist collision on Linux); in non-Vim mode that handler is disabled, so @@ -470,6 +480,8 @@ function wysiwygExtensions( // markdown for full keyboard/Vim editing (#232). ...(renderTables ? [tablePlugin, tableVimEntry] : []), wysiwygBlocksPlugin, + // Collapsed callouts (`> [!type]-`) start folded when a note opens (#853). + calloutFolding(), ...hashtagExtension, ...taskMetadataExtension, ...taskRollupExtension, @@ -624,6 +636,32 @@ function landEditorOnLine(view: EditorView, line: number, topMargin: number): vo }) } +// Where each top-level block of the note starts, as a scroll position in the +// editor and in the split preview. Only the article's own children count: a +// nested element stamped with some other note's lines (an embed) would put an +// anchor in the wrong place. +function splitScrollAnchorsFor(view: EditorView, previewEl: HTMLElement): SplitScrollAnchor[] { + const editorEl = view.scrollDOM + const doc = view.state.doc + // The document starts below the scroller's top padding. + const editorOffset = view.documentTop - editorEl.getBoundingClientRect().top + editorEl.scrollTop + const previewOffset = previewEl.scrollTop - previewEl.getBoundingClientRect().top + const blocks: SplitScrollAnchor[] = [] + for (const el of previewEl.querySelectorAll('[data-preview-content] > [data-source-line]')) { + const line = Number(el.dataset.sourceLine) + if (!Number.isInteger(line) || line < 1 || line > doc.lines) continue + blocks.push({ + editor: view.lineBlockAt(doc.line(line).from).top + editorOffset, + preview: el.getBoundingClientRect().top + previewOffset + }) + } + return splitScrollAnchors( + blocks, + editorEl.scrollHeight - editorEl.clientHeight, + previewEl.scrollHeight - previewEl.clientHeight + ) +} + const EMPTY_COMMENTS: NoteComment[] = [] const taskJumpHighlightEffect = StateEffect.define() const taskJumpHighlightDecoration = Decoration.line({ class: 'cm-task-jump-highlight' }) @@ -1099,8 +1137,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { // The outgoing note's history is set aside first and handed back when that // note returns, see lib/note-undo-history. (#793) const historyCompartmentRef = useRef(null) - const ignoreEditorScrollRef = useRef(false) const ignorePreviewScrollRef = useRef(false) + // Split mode: which pane the reader is driving and until when, and which + // one they drove last. The follower's scroll events are echoes while the + // other pane leads, and an editor re-measure only moves the preview when + // the editor was the pane last driven. (#859) + const splitScrollLeadRef = useRef(null) + const splitScrollDriverRef = useRef('editor') const pendingOutlineJumpLineRef = useRef(null) const pendingPreviewOutlineJumpLineRef = useRef(null) const outlinePreviewJumpFrameRef = useRef(null) @@ -1110,6 +1153,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const hasContentRef = useRef(false) const editorViewportSyncFrameRef = useRef(null) const syncPreviewToEditorScrollRef = useRef<() => boolean>(() => false) + const syncEditorToPreviewScrollRef = useRef<() => void>(() => {}) const activeOutlineLineRef = useRef(null) const activeOutlineFrameRef = useRef(null) const selectionActionFrameRef = useRef(null) @@ -1438,59 +1482,43 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { return true }, [content?.body, lockOutlinePreviewSync, mode]) + // The pane the reader drives leads for a moment; `byReader` also makes it + // the pane they drove last. Our own sync takes the lead for the pane it + // copies from, so the follower's echo is recognized as one. + const leadSplitScroll = useCallback((pane: SplitScrollPane, byReader: boolean): void => { + splitScrollLeadRef.current = { pane, until: performance.now() + SPLIT_SCROLL_LEAD_MS } + if (byReader) splitScrollDriverRef.current = pane + }, []) + + // Both directions map through the same anchors (see split-scroll-sync), so + // the same content sits at the top of both panes however differently a + // block renders, and a round trip lands where it started. With no anchored + // block the mapping is the plain scroll ratio. const syncPreviewToEditorScroll = useCallback((): boolean => { const view = viewRef.current const editorEl = view?.scrollDOM const previewEl = previewScrollRef.current if (!view || !editorEl || !previewEl) return false - - // Line-based sync: map the source line at the top of the editor viewport to - // the rendered preview block stamped with that line (data-source-line), so - // the same content sits at the top of both panes even when their heights - // differ. Falls back to a scroll ratio when no line data is available. - const ratioTop = (): number => - scrollTopForScrollRatio( - editorEl.scrollTop, - editorEl.scrollHeight, - editorEl.clientHeight, - previewEl.scrollHeight, - previewEl.clientHeight - ) - - const blocks = previewEl.querySelectorAll('[data-source-line]') - let nextTop: number - if (blocks.length === 0) { - nextTop = ratioTop() - } else { - const topLine = view.state.doc.lineAt(view.lineBlockAtHeight(editorEl.scrollTop).from).number - let anchor: HTMLElement | null = null - let anchorLine = 0 - for (const el of blocks) { - const ln = Number(el.dataset.sourceLine) - if (Number.isFinite(ln) && ln <= topLine) { - anchor = el - anchorLine = ln - } else if (ln > topLine) { - break - } - } - if (!anchor) { - nextTop = 0 - } else { - // Shift the anchor by how far the editor has scrolled past its start - // line, so a mid-block scroll position carries over too. - const anchorEditorTop = view.lineBlockAt(view.state.doc.line(anchorLine).from).top - nextTop = scrollTopForElementRelativeTop(previewEl, anchor, anchorEditorTop - editorEl.scrollTop) - } - } - + const nextTop = mapSplitScrollTop(splitScrollAnchorsFor(view, previewEl), 'editor', editorEl.scrollTop) if (Math.abs(previewEl.scrollTop - nextTop) < 1) return true - ignorePreviewScrollRef.current = true + leadSplitScroll('editor', false) previewEl.scrollTop = nextTop return true - }, []) + }, [leadSplitScroll]) syncPreviewToEditorScrollRef.current = syncPreviewToEditorScroll + const syncEditorToPreviewScroll = useCallback((): void => { + const view = viewRef.current + const editorEl = view?.scrollDOM + const previewEl = previewScrollRef.current + if (!view || !editorEl || !previewEl) return + const nextTop = mapSplitScrollTop(splitScrollAnchorsFor(view, previewEl), 'preview', previewEl.scrollTop) + if (Math.abs(editorEl.scrollTop - nextTop) < 1) return + leadSplitScroll('preview', false) + editorEl.scrollTop = nextTop + }, [leadSplitScroll]) + syncEditorToPreviewScrollRef.current = syncEditorToPreviewScroll + const canSyncPreviewFromEditorViewport = useCallback((): boolean => { return shouldSyncPreviewFromEditorViewport( modeRef.current, @@ -1506,6 +1534,16 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { editorViewportSyncFrameRef.current = requestAnimationFrame(() => { editorViewportSyncFrameRef.current = null if (!canSyncPreviewFromEditorViewport()) return + // The reader is scrolling the preview: the editor measured the lines the + // sync just revealed, so it is the editor that settles onto the preview. + if (splitScrollEchoes(splitScrollLeadRef.current, 'editor', performance.now())) { + syncEditorToPreviewScrollRef.current() + return + } + // The reader last drove the preview. A click in the editor re-measures + // the line it reveals, and that must not drag the preview away from what + // they were reading. (#859) + if (splitScrollDriverRef.current === 'preview') return syncPreviewToEditorScrollRef.current() }) }, [canSyncPreviewFromEditorViewport]) @@ -1590,6 +1628,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { return } if (!canSyncPreviewFromEditorViewport()) return + // A re-render while the reader drives the preview keeps their place. + if ( + splitScrollDriverRef.current === 'preview' || + splitScrollEchoes(splitScrollLeadRef.current, 'editor', performance.now()) + ) { + return + } syncPreviewToEditorScroll() }, [ canSyncPreviewFromEditorViewport, @@ -2682,28 +2727,15 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const previewEl = previewScrollRef.current if (!editorEl || !previewEl) return - const syncByRatio = ( - source: HTMLElement, - target: HTMLElement, - targetKind: 'editor' | 'preview' - ): void => { - const nextTop = scrollTopForScrollRatio( - source.scrollTop, - source.scrollHeight, - source.clientHeight, - target.scrollHeight, - target.clientHeight - ) - if (Math.abs(target.scrollTop - nextTop) < 1) return - if (targetKind === 'editor') ignoreEditorScrollRef.current = true - else ignorePreviewScrollRef.current = true - target.scrollTop = nextTop - } + splitScrollLeadRef.current = null + splitScrollDriverRef.current = 'editor' + // A scroll in the pane that does not lead is the echo of our own sync, or + // of the editor settling onto it, and goes no further. Any other scroll is + // the reader's (wheel, scrollbar, keys, the caret scrolled into view): that + // pane leads and the other follows it. const onEditorScroll = (): void => { - if (ignoreEditorScrollRef.current) { - ignoreEditorScrollRef.current = false - return - } + if (splitScrollEchoes(splitScrollLeadRef.current, 'editor', performance.now())) return + leadSplitScroll('editor', true) if (!canSyncPreviewFromEditorViewport()) return syncPreviewToEditorScroll() } @@ -2712,11 +2744,28 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { ignorePreviewScrollRef.current = false return } + if (splitScrollEchoes(splitScrollLeadRef.current, 'preview', performance.now())) return + leadSplitScroll('preview', true) if (!canSyncPreviewFromEditorViewport()) return - syncByRatio(previewEl, editorEl, 'editor') + syncEditorToPreviewScroll() + } + // Input that scrolls a pane claims it before the scroll events arrive, so + // an echo landing in the same frame is already known for one. Typing makes + // the editor the pane last driven without taking the lead; a click does + // neither. + const leadEditor = (): void => leadSplitScroll('editor', true) + const leadPreview = (): void => leadSplitScroll('preview', true) + const typedInEditor = (): void => { + splitScrollDriverRef.current = 'editor' } editorEl.addEventListener('scroll', onEditorScroll, { passive: true }) previewEl.addEventListener('scroll', onPreviewScroll, { passive: true }) + editorEl.addEventListener('wheel', leadEditor, { passive: true }) + previewEl.addEventListener('wheel', leadPreview, { passive: true }) + editorEl.addEventListener('touchstart', leadEditor, { passive: true }) + previewEl.addEventListener('touchstart', leadPreview, { passive: true }) + editorEl.addEventListener('keydown', typedInEditor) + previewEl.addEventListener('keydown', leadPreview) const raf = requestAnimationFrame(() => { if (!canSyncPreviewFromEditorViewport()) return syncPreviewToEditorScroll() @@ -2725,8 +2774,14 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { cancelAnimationFrame(raf) editorEl.removeEventListener('scroll', onEditorScroll) previewEl.removeEventListener('scroll', onPreviewScroll) - ignoreEditorScrollRef.current = false + editorEl.removeEventListener('wheel', leadEditor) + previewEl.removeEventListener('wheel', leadPreview) + editorEl.removeEventListener('touchstart', leadEditor) + previewEl.removeEventListener('touchstart', leadPreview) + editorEl.removeEventListener('keydown', typedInEditor) + previewEl.removeEventListener('keydown', leadPreview) ignorePreviewScrollRef.current = false + splitScrollLeadRef.current = null outlinePreviewSyncLockUntilRef.current = 0 } }, [ @@ -2734,7 +2789,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { content?.path, editorHydration?.path, editorHydration?.ready, + leadSplitScroll, mode, + syncEditorToPreviewScroll, syncPreviewToEditorScroll ]) diff --git a/packages/app-core/src/components/HelpView.tsx b/packages/app-core/src/components/HelpView.tsx index c36751a0..cc7a37a8 100644 --- a/packages/app-core/src/components/HelpView.tsx +++ b/packages/app-core/src/components/HelpView.tsx @@ -105,9 +105,12 @@ function resolveShortcutKeys( return leader === UNBOUND_LABEL ? leader : `${leader}, then pause` } if (action === 'Toggle outline panel') return shortcut(overrides, 'global.toggleOutlinePanel') - if (action === 'Fold / unfold heading') { + if (action === 'Fold / unfold heading or list item') { return alternatives([shortcut(overrides, 'vim.foldCurrent'), shortcut(overrides, 'vim.unfoldCurrent')]) } + if (action === 'Fold / unfold, Vim on or off') { + return alternatives([shortcut(overrides, 'editor.foldHeading'), shortcut(overrides, 'editor.unfoldHeading')]) + } if (action === 'Fold / unfold all') { return alternatives([shortcut(overrides, 'vim.foldAll'), shortcut(overrides, 'vim.unfoldAll')]) } diff --git a/packages/app-core/src/components/HintOverlay.tsx b/packages/app-core/src/components/HintOverlay.tsx index c1bd308a..f4f2d89d 100644 --- a/packages/app-core/src/components/HintOverlay.tsx +++ b/packages/app-core/src/components/HintOverlay.tsx @@ -14,6 +14,8 @@ function getVisibleInteractiveElements(): HTMLElement[] { const selectors = [ 'button:not([disabled])', 'a[href]', + // A
toggle, such as a foldable callout's title in Preview (#853). + 'summary', '[role="button"]', '[role="link"]', 'input:not([disabled])', diff --git a/packages/app-core/src/components/HomeView.test.ts b/packages/app-core/src/components/HomeView.test.ts new file mode 100644 index 00000000..ccc4bbdc --- /dev/null +++ b/packages/app-core/src/components/HomeView.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NoteMeta } from '@shared/ipc' +import { useStore } from '../store' +import { HomeView } from './HomeView' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const focusEditorNormalMode = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode })) + +function note(title: string, updatedAt: number): NoteMeta { + return { + path: `inbox/${title}.md`, + title, + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt, + size: 0, + tags: [], + wikilinks: [], + hasAttachments: false, + assetEmbeds: [], + excerpt: '' + } +} + +describe('HomeView: opening a note hands the keyboard to the editor (#863)', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType + let selectNote: ReturnType + + beforeEach(() => { + originalState = useStore.getState() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + focusEditorNormalMode.mockClear() + selectNote = vi.fn(async () => {}) + useStore.setState({ + notes: [note('Support', 2), note('Plans', 1)], + folders: [], + vaultTasks: [], + tasksLoading: true, + refreshTasks: vi.fn(async () => {}), + selectNote, + vaultSettings: { ...originalState.vaultSettings, favorites: ['inbox/Plans.md'] } + }) + act(() => root.render(createElement(HomeView, { sidebarOpen: true, onShowSidebar: () => {} }))) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + }) + + const click = async (selector: string): Promise => { + const row = host.querySelector(selector) + expect(row).not.toBeNull() + await act(async () => { + row!.click() + }) + } + + it('from a recent note', async () => { + await click('[data-home-section="recent"] [data-home-note-path="inbox/Support.md"]') + expect(selectNote).toHaveBeenCalledWith('inbox/Support.md') + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) + + it('from a favorite note', async () => { + await click('[data-home-favorite="note"][data-home-note-path="inbox/Plans.md"]') + expect(selectNote).toHaveBeenCalledWith('inbox/Plans.md') + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) + + it('names a new note first: New note focuses its title field, like every other New note button', async () => { + const createAndOpen = vi.fn(async () => {}) + act(() => useStore.setState({ createAndOpen })) + const button = [...host.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'New note') + expect(button).toBeDefined() + await act(async () => { + button!.click() + }) + expect(createAndOpen).toHaveBeenCalledWith('inbox', '', { focusTitle: true }) + }) + + it('only once the note has opened', async () => { + let opened: () => void = () => {} + selectNote.mockImplementation(() => new Promise((resolve) => (opened = resolve))) + await click('[data-home-section="recent"] [data-home-note-path="inbox/Support.md"]') + expect(focusEditorNormalMode).not.toHaveBeenCalled() + await act(async () => opened()) + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/app-core/src/components/HomeView.tsx b/packages/app-core/src/components/HomeView.tsx index ba117d0f..c2e33792 100644 --- a/packages/app-core/src/components/HomeView.tsx +++ b/packages/app-core/src/components/HomeView.tsx @@ -3,6 +3,7 @@ import type { NoteMeta } from '@shared/ipc' import { filterTasksForDisplay, type VaultTask } from '@shared/tasks' import { useStore } from '../store' import { computeTasksRender } from '../lib/tasks-filter' +import { focusEditorNormalMode } from '../lib/editor-focus' import { InlineMarkdown } from '../lib/inline-markdown' import { getSystemFolderLabel } from '../lib/system-folder-labels' import { @@ -91,6 +92,18 @@ export function HomeView({ const containerRef = useRef(null) + // Opening a note from here swaps this view out, button and all, so DOM + // focus fell to : the note showed, but with Vim mode off typing went + // nowhere, and with it on the keys drove the sidebar and the leader + // instead of the note (#863). Hand the keyboard to the editor the way the + // sidebar and the palettes do (#599). + const openNote = useCallback( + (path: string): void => { + void selectNote(path).then(() => focusEditorNormalMode()) + }, + [selectNote] + ) + // Quick-create actions. Daily/weekly only appear when enabled in settings // (they default off), matching the command palette's gating. const actions = useMemo void }>>(() => { @@ -98,7 +111,7 @@ export function HomeView({ { label: 'New note', icon: , - run: () => void createAndOpen('inbox', '') + run: () => void createAndOpen('inbox', '', { focusTitle: true }) }, { label: 'Database', @@ -306,7 +319,7 @@ export function HomeView({ type="button" data-home-item data-home-note-path={note.path} - onClick={() => void selectNote(note.path)} + onClick={() => openNote(note.path)} className="group flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-paper-200/60 focus:bg-paper-200/70 focus:outline-none" > void createAndOpen('inbox', '') + run: () => void createAndOpen('inbox', '', { focusTitle: true }) }, { label: 'New from template', @@ -397,7 +410,7 @@ export function HomeView({ data-home-favorite={item.kind} {...(item.kind === 'note' ? { 'data-home-note-path': item.path } : {})} onClick={() => - item.kind === 'note' ? void selectNote(item.path) : openFavoriteFolder(item) + item.kind === 'note' ? openNote(item.path) : openFavoriteFolder(item) } className="group flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-paper-200/60 focus:bg-paper-200/70 focus:outline-none" > diff --git a/packages/app-core/src/components/PinnedReferencePane.test.ts b/packages/app-core/src/components/PinnedReferencePane.test.ts new file mode 100644 index 00000000..d71cbc5a --- /dev/null +++ b/packages/app-core/src/components/PinnedReferencePane.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { EditorView } from '@codemirror/view' +import { undo } from '@codemirror/commands' +import { closeSearchPanel, searchPanelOpen } from '@codemirror/search' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NoteContent } from '@shared/ipc' +import { useStore } from '../store' +import { PinnedReferencePane } from './PinnedReferencePane' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const note: NoteContent = { + path: 'inbox/Reference.md', + title: 'Reference', + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 10, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: 'alpha beta', + body: 'alpha beta' +} + +describe('PinnedReferencePane search bindings (#860)', () => { + let host: HTMLDivElement + let root: Root + let view: EditorView + let originalState: ReturnType + + beforeEach(async () => { + // jsdom has no layout; CodeMirror still measures the caret after a key. + Object.defineProperty(Range.prototype, 'getClientRects', { + configurable: true, + value: () => [] + }) + Object.defineProperty(Range.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect() + }) + originalState = useStore.getState() + useStore.setState({ + vault: { root: '/vault', name: 'Vault' }, + selectedPath: note.path, + pinnedRefPath: note.path, + pinnedRefKind: 'note', + pinnedRefVisible: true, + pinnedRefMode: 'edit', + noteRefs: {}, + noteContents: { [note.path]: note }, + noteDirty: {}, + vimMode: false, + livePreview: false, + keymapOverrides: {}, + searchOpen: false, + updateNoteBody: vi.fn() + }) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + await act(async () => root.render(createElement(PinnedReferencePane))) + view = EditorView.findFromDOM(host.querySelector('.cm-editor')!)! + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState(originalState, true) + }) + + async function press(key: string, modifiers: KeyboardEventInit = {}): Promise { + await act(async () => { + view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { + key, bubbles: true, cancelable: true, ...modifiers + })) + }) + } + + it('hands an unbound Mod+F to the find bar without replacing the editor or its undo history', async () => { + await press('f', { ctrlKey: true }) + expect(useStore.getState().searchOpen).toBe(true) + act(() => { + useStore.getState().setSearchOpen(false) + view.dispatch({ changes: { from: note.body.length, insert: ' draft' }, selection: { anchor: 16 } }) + }) + const selection = view.state.selection + + await act(async () => useStore.setState({ keymapOverrides: { 'global.searchNotesNonVim': '' } })) + + expect(EditorView.findFromDOM(host.querySelector('.cm-editor')!)).toBe(view) + expect(view.state.doc.toString()).toBe('alpha beta draft') + expect(view.state.selection.eq(selection)).toBe(true) + await press('f', { ctrlKey: true }) + expect(useStore.getState().searchOpen).toBe(false) + expect(searchPanelOpen(view.state)).toBe(true) + act(() => { undo(view) }) + expect(view.state.doc.toString()).toBe(note.body) + }) + + it('uses the new shortcut immediately and releases it when the default is restored', async () => { + await act(async () => useStore.setState({ keymapOverrides: { 'global.searchNotesNonVim': 'F6' } })) + await press('F6') + expect(useStore.getState().searchOpen).toBe(true) + act(() => useStore.getState().setSearchOpen(false)) + + await press('f', { ctrlKey: true }) + expect(searchPanelOpen(view.state)).toBe(true) + expect(useStore.getState().searchOpen).toBe(false) + act(() => { closeSearchPanel(view) }) + + await act(async () => useStore.setState({ keymapOverrides: {} })) + await press('F6') + expect(useStore.getState().searchOpen).toBe(false) + await press('f', { ctrlKey: true }) + expect(useStore.getState().searchOpen).toBe(true) + expect(searchPanelOpen(view.state)).toBe(false) + }) +}) diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index ca62d8f9..f7d5c6cf 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -29,6 +29,8 @@ import { import { vim } from '@replit/codemirror-vim' import { history, historyKeymap, indentWithTab } from '@codemirror/commands' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from '../lib/cm-vim-default-keymap' +import { getKeymapBinding, type KeymapOverrides } from '../lib/keymaps' +import { keyBindingsFor } from '../lib/vim-half-page-keymap' import { vimVisualHighlightExtension } from '../lib/cm-vim-visual-highlight' import { noteMarkdown } from '../lib/cm-markdown-language' import { customCodeFenceHighlightExtension } from '../lib/cm-custom-code-languages' @@ -128,6 +130,22 @@ function lineNumberExtension(mode: LineNumberMode): Extension { ] } +function buildReferenceKeymap(vimMode: boolean, overrides: KeymapOverrides): Extension { + return keymap.of([ + // Note search must precede the find bar only while its binding is set. + ...keyBindingsFor(getKeymapBinding(overrides, 'global.searchNotesNonVim'), () => { + const state = useStore.getState() + if (state.vimMode) return false + state.setSearchOpen(true) + return true + }), + indentWithTab, + ...vimAwareDefaultKeymap(vimMode), + ...historyKeymap, + ...vimAwareSearchKeymap(vimMode) + ]) +} + export function PinnedReferencePane(): JSX.Element | null { const globalRefPath = useStore((s) => s.pinnedRefPath) const globalRefKind = useStore((s) => s.pinnedRefKind) @@ -165,6 +183,7 @@ export function PinnedReferencePane(): JSX.Element | null { const updateNoteBody = useStore((s) => s.updateNoteBody) const persistNote = useStore((s) => s.persistNote) const vimMode = useStore((s) => s.vimMode) + const keymapOverrides = useStore((s) => s.keymapOverrides) const livePreview = useStore((s) => s.livePreview) const showHeadingLevelLabels = useStore((s) => s.showHeadingLevelLabels) const lineNumberMode = useStore((s) => s.lineNumberMode) @@ -178,6 +197,7 @@ export function PinnedReferencePane(): JSX.Element | null { const viewRef = useRef(null) const viewPathRef = useRef(null) const vimCompartmentRef = useRef(null) + const keymapCompartmentRef = useRef(null) const livePreviewCompartmentRef = useRef(null) const lineNumbersCompartmentRef = useRef(null) const headingCompartmentRef = useRef(null) @@ -196,11 +216,13 @@ export function PinnedReferencePane(): JSX.Element | null { } if (viewRef.current) return const vimCompartment = new Compartment() + const keymapCompartment = new Compartment() const livePreviewCompartment = new Compartment() const lineNumbersCompartment = new Compartment() const headingCompartment = new Compartment() const tabSizeCompartment = new Compartment() vimCompartmentRef.current = vimCompartment + keymapCompartmentRef.current = keymapCompartment livePreviewCompartmentRef.current = livePreviewCompartment lineNumbersCompartmentRef.current = lineNumbersCompartment headingCompartmentRef.current = headingCompartment @@ -265,21 +287,7 @@ export function PinnedReferencePane(): JSX.Element | null { }), completionNavKeymap, completionKeymapExtension, - keymap.of([ - { - key: 'Mod-f', - run: () => { - const state = useStore.getState() - if (state.vimMode) return false - state.setSearchOpen(true) - return true - } - }, - indentWithTab, - ...vimAwareDefaultKeymap(s0.vimMode), - ...historyKeymap, - ...vimAwareSearchKeymap(s0.vimMode) - ]), + keymapCompartment.of(buildReferenceKeymap(s0.vimMode, s0.keymapOverrides)), EditorView.updateListener.of((upd) => { if (!upd.docChanged) return if (upd.transactions.some((tr: Transaction) => tr.annotation(programmatic))) return @@ -325,6 +333,12 @@ export function PinnedReferencePane(): JSX.Element | null { if (!view || !comp) return view.dispatch({ effects: comp.reconfigure(vimMode ? vim() : []) }) }, [vimMode]) + useEffect(() => { + const view = viewRef.current + const comp = keymapCompartmentRef.current + if (!view || !comp) return + view.dispatch({ effects: comp.reconfigure(buildReferenceKeymap(vimMode, keymapOverrides)) }) + }, [vimMode, keymapOverrides]) useEffect(() => { const view = viewRef.current const comp = livePreviewCompartmentRef.current diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index b40c607e..888a92b1 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -38,6 +38,7 @@ import { isExcalidrawPath, isObsidianExcalidrawPath } from "@shared/excalidraw"; import { resolveExcalidrawEmbedPath } from "../lib/excalidraw-preview"; import { LazyExcalidrawPreview } from "./LazyExcalidrawPreview"; import { enhancePreviewHeadingFolds } from "../lib/preview-heading-fold"; +import { enhancePreviewListFolds } from "../lib/preview-list-fold"; import { wrapTaskItemOwnText } from "../lib/preview-task-body"; import { previewEditRequestForTarget, @@ -849,6 +850,9 @@ export const Preview = memo(function Preview({ // alone, never on a sub-task or a block such as a code block. (#512, #849) wrapTaskItemOwnText(li, input); }); + // After the task pass: wrapTaskItemOwnText gathers every inline node of a + // task item into its body span, and would take a fold arrow in with them. + enhancePreviewListFolds(stage); const applyRenderedDom = async (): Promise => { try { diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index e001c6fa..84f22855 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -4152,9 +4152,9 @@ export function SettingsModal(): JSX.Element { {workspaceMode !== "remote" && vault && !vault.temporary && ( void renameVault(next)} diff --git a/packages/app-core/src/lib/atlas.test.ts b/packages/app-core/src/lib/atlas.test.ts index a0a5794c..534b8a03 100644 --- a/packages/app-core/src/lib/atlas.test.ts +++ b/packages/app-core/src/lib/atlas.test.ts @@ -1,7 +1,17 @@ // @vitest-environment jsdom import { describe, expect, it } from 'vitest' -import { atlasHoldsKeyboard, atlasRegionDirection } from './atlas' +import type { NoteMeta } from '@shared/ipc' +import { + atlasHoldsKeyboard, + atlasNodeRadius, + atlasRegionDirection, + buildAtlasGraph, + collectAtlasPositions, + layoutAtlas, + type AtlasGraph, + type AtlasPositions +} from './atlas' import { SELF_KEYED_SURFACES } from './self-keyed-surfaces' function keyboardEvent( @@ -83,3 +93,135 @@ describe('atlasHoldsKeyboard (#670)', () => { expect(atlasHoldsKeyboard(null, false)).toBe(false) }) }) + +function note(path: string, links: string[] = [], createdAt = 0): NoteMeta { + const title = path.slice(path.lastIndexOf('/') + 1).replace(/\.md$/, '') + return { + path, + title, + folder: 'inbox', + tags: [], + createdAt, + updatedAt: createdAt, + wikilinks: links + } as unknown as NoteMeta +} + +// A small vault with a hub, the shape of #861's: folders as regions and a few +// links across them. +function vault(): NoteMeta[] { + const notes = [note('Zettelkasten/Zettelkasten.md')] + const folders = ['Topics', 'Projects', 'Areas', 'Ressources', 'Indexes'] + for (let f = 0; f < folders.length; f++) + for (let i = 0; i < 10; i++) { + const links = i === 0 ? ['Zettelkasten'] : [`${folders[f]} ${i - 1}`] + notes.push(note(`${folders[f]}/${folders[f]} ${i}.md`, links, f * 10 + i)) + } + return notes +} + +function laidOut(notes: NoteMeta[], previous: AtlasPositions | null = null): AtlasGraph { + const graph = buildAtlasGraph(notes) + layoutAtlas(graph, previous) + return graph +} + +// Edge-to-edge room between two notes' dots in the flat map or the sky. +function gap(graph: AtlasGraph, a: string, b: string, dims: 2 | 3): number { + const na = graph.nodes.find((n) => n.path === a)! + const nb = graph.nodes.find((n) => n.path === b)! + const d = + dims === 2 ? Math.hypot(na.x2 - nb.x2, na.y2 - nb.y2) : Math.hypot(na.x - nb.x, na.y - nb.y, na.z - nb.z) + return d - atlasNodeRadius(na.degree) - atlasNodeRadius(nb.degree) +} + +function tightest(graph: AtlasGraph, paths: string[], dims: 2 | 3): number { + let least = Infinity + for (const a of paths) + for (const b of graph.nodes) + if (b.path !== a) least = Math.min(least, gap(graph, a, b.path, dims)) + return least +} + +describe('layoutAtlas keeps notes apart (#861)', () => { + const later = [ + note('Projects/Slip Box Setup.md', ['Zettelkasten'], 1000), + note('Areas/Knowledge Work.md', ['Zettelkasten'], 1001), + note('Ressources/Luhmann Archive.md', ['Zettelkasten'], 1002), + note('Indexes/Method Index.md', ['Zettelkasten'], 1003), + note('Topics/Zettelkasten Method.md', ['Zettelkasten'], 1004), + note('Zettelkasten/Index Cards.md', ['Zettelkasten'], 1005), + note('Projects/Pasta al Limone.md', ['Topics 4'], 1006), + note('Projects/Apfelkuchen.md', ['Pasta al Limone'], 1007) + ] + const laterPaths = later.map((n) => n.path) + + it('places notes written after the map was drawn beside what they link to, never on a note', () => { + const first = collectAtlasPositions(laidOut(vault())) + const graph = laidOut([...vault(), ...later], first) + for (const dims of [2, 3] as const) { + expect(tightest(graph, laterPaths, dims)).toBeGreaterThan(0) + for (const n of later) { + const target = graph.nodes.find((m) => m.title === n.wikilinks[0])! + const room = gap(graph, n.path, target.path, dims) + // Clear of its link, and still beside it. + expect(room).toBeGreaterThan(0) + expect(room).toBeLessThan(150) + } + } + // Nothing that was already on the map moved. + for (const [path, at] of Object.entries(first)) { + expect(collectAtlasPositions(graph)[path]).toEqual(at) + } + }) + + it('finds room for many notes written at once around one hub', () => { + const base = vault() + const crowd = Array.from({ length: 30 }, (_, i) => + note(`Zettelkasten/Card ${i}.md`, ['Zettelkasten'], 2000 + i) + ) + const graph = laidOut([...base, ...crowd], collectAtlasPositions(laidOut(base))) + for (const dims of [2, 3] as const) { + expect(tightest(graph, crowd.map((n) => n.path), dims)).toBeGreaterThan(0) + } + }) + + it('moves a note the old placement stacked on its link, and nothing else', () => { + const first = laidOut(vault()) + const hub = first.nodes.find((n) => n.path === 'Zettelkasten/Zettelkasten.md')! + const clumped: AtlasPositions = collectAtlasPositions(first) + // What 2.56 cached for these notes: the hub's position, give or take 11. + later.slice(0, 6).forEach((n, i) => { + const dx = ((i * 7) % 11) - 5 + const dy = ((i * 5) % 9) - 4 + clumped[n.path] = { x: hub.x + dx, y: hub.y - dy, z: hub.z + dy, x2: hub.x2 + dx, y2: hub.y2 + dy } + }) + const graph = laidOut([...vault(), ...later.slice(0, 6)], clumped) + for (const dims of [2, 3] as const) { + for (const path of laterPaths.slice(0, 6)) { + expect(gap(graph, path, hub.path, dims)).toBeGreaterThan(0) + } + expect(tightest(graph, laterPaths.slice(0, 6), dims)).toBeGreaterThan(0) + } + const now = collectAtlasPositions(graph) + for (const n of vault()) expect(now[n.path]).toEqual(clumped[n.path]) + }) + + it('settles to itself: opening the map again moves nothing', () => { + const fresh = laidOut(vault()) + expect(collectAtlasPositions(laidOut(vault(), collectAtlasPositions(fresh)))).toEqual( + collectAtlasPositions(fresh) + ) + const grown = laidOut([...vault(), ...later], collectAtlasPositions(fresh)) + expect(collectAtlasPositions(laidOut([...vault(), ...later], collectAtlasPositions(grown)))).toEqual( + collectAtlasPositions(grown) + ) + }) + + it('is deterministic: the same notes and cache give the same map', () => { + const cache = collectAtlasPositions(laidOut(vault())) + expect(collectAtlasPositions(laidOut([...vault(), ...later], cache))).toEqual( + collectAtlasPositions(laidOut([...vault(), ...later], cache)) + ) + }) +}) diff --git a/packages/app-core/src/lib/atlas.ts b/packages/app-core/src/lib/atlas.ts index 6c87f1b0..6dfecaf2 100644 --- a/packages/app-core/src/lib/atlas.ts +++ b/packages/app-core/src/lib/atlas.ts @@ -3,9 +3,11 @@ // // Two hard rules from the design (docs/ideas/atlas.md): // - The layout is DETERMINISTIC and FROZEN. Same notes + links = same map. -// - Notes already on the map never move when new notes arrive; newcomers are -// placed at the centroid of their linked neighbors (or their region) so the -// geography stays a place the user knows. +// - Notes already on the map never move when new notes arrive; a newcomer +// takes the nearest free spot beside what it links to (or in its region), +// so the geography stays a place the user knows. The one exception is a +// note sitting on a note it links to, which is no place anyone could know +// (settleAtlasSpace, below). import type { NoteMeta } from '@shared/ipc' import { resolveWikilinkTarget } from './wikilinks' @@ -60,6 +62,11 @@ function mulberry32(a: number): () => number { } } +/** A note's dot radius in world units; the view scales it with the zoom. */ +export function atlasNodeRadius(degree: number): number { + return 3.4 + Math.sqrt(degree) * 2.1 +} + /** The region a note belongs to: its top-level folder (clustering can come later). */ export function atlasRegionKey(path: string): string { const slash = path.indexOf('/') @@ -242,10 +249,197 @@ function relax( } } +// Spacing for notes placed after the map was drawn, edge to edge between +// dots, in world units. A newcomer takes the nearest spot PLACE_GAP clear of +// every other dot when one lies within PLACE_NEAR of the notes it links to; +// on a map too crowded for that it takes the nearest spot that is MIN_GAP +// clear, and LINK_GAP clear of what it links to. Relaxation leaves linked +// notes about 90 apart, so PLACE_GAP is snug but readable. +const PLACE_GAP = 24 +const PLACE_NEAR = 120 +const MIN_GAP = 4 +// Newcomers used to land on the centroid of what they link to, give or take +// 11 units a side, with no look at what was already there: a note written +// after the map was first drawn sat on its link, and the cache froze it there +// (#861). A cached note this close to a note it links to (edge to edge) is one +// of those, since relaxation pushes linked notes much further apart. +const LINK_GAP = 12 +const CELL = 96 +const PROBES = 200000 + +/** + * The k-th probe around an anchor, and its distance from it: a sunflower in + * 2D, a filled ball in 3D. Probes widen evenly from the anchor itself, so the + * first one that fits is about the nearest spot that does. + */ +function probeOffset(k: number, dims: 2 | 3): [number, number, number, number] { + if (dims === 2) { + const r = 5 * Math.sqrt(k) + return [Math.cos(k * GOLD) * r, Math.sin(k * GOLD) * r, 0, r] + } + const r = 6 * Math.cbrt(k) + const z = 1 - 2 * ((k * 0.7548776662466927) % 1) + const a = 2 * Math.PI * ((k * 0.5698402909980532) % 1) + const s = Math.sqrt(Math.max(0, 1 - z * z)) + return [Math.cos(a) * s * r, Math.sin(a) * s * r, z * r, r] +} + +/** + * Settle one layout space: every note gets a position, and no note sits on + * a note it links to. + * + * `placed` marks the notes that already have a position here (cached, or just + * relaxed). They keep it, except a note within LINK_GAP of a note it links + * to: of the two, the heavier one (more links, then older, then path order) + * stays and the other moves to the nearest spot that fits, from where it was. + * Notes without a position (newcomers) then take the nearest spot that fits + * around the notes they link to, or around their region slot while none of + * those is on the map. Every placement meets the rule the first pass checks, + * so a settled space settles to itself and reopening the map moves nothing. + */ +function settleAtlasSpace( + nodes: AtlasNode[], + adjacency: ReadonlyMap, + dims: 2 | 3, + placed: boolean[], + get: (n: AtlasNode) => [number, number, number], + set: (n: AtlasNode, x: number, y: number, z: number) => void, + regionSlot: (i: number) => [number, number, number] +): void { + const count = nodes.length + const radius = nodes.map((n) => atlasNodeRadius(n.degree)) + let maxRadius = 0 + for (const r of radius) maxRadius = Math.max(maxRadius, r) + const linked = new Set() + adjacency.forEach((list, a) => list.forEach((b) => linked.add(a * count + b))) + const px = new Float64Array(count) + const py = new Float64Array(count) + const pz = new Float64Array(count) + nodes.forEach((n, i) => { + if (!placed[i]) return + const [x, y, z] = get(n) + px[i] = x + py[i] = y + pz[i] = dims === 3 ? z : 0 + }) + const grid = new Map() + const add = (i: number): void => { + const key = + Math.floor(px[i] / CELL) + + ',' + + Math.floor(py[i] / CELL) + + ',' + + (dims === 3 ? Math.floor(pz[i] / CELL) : 0) + const cell = grid.get(key) + if (cell) cell.push(i) + else grid.set(key, [i]) + } + // How note i would sit at (x, y, z) among the notes in the grid: 0 when it + // would cover or crowd one (under MIN_GAP, or under LINK_GAP from a note it + // links to), 1 when it fits but snugly, 2 when it is PLACE_GAP clear. + const fit = (i: number, x: number, y: number, z: number, strict: boolean): 0 | 1 | 2 => { + const reach = Math.ceil((radius[i] + maxRadius + PLACE_GAP) / CELL) + const gx = Math.floor(x / CELL) + const gy = Math.floor(y / CELL) + const gz = dims === 3 ? Math.floor(z / CELL) : 0 + let roomy = true + for (let ax = gx - reach; ax <= gx + reach; ax++) + for (let ay = gy - reach; ay <= gy + reach; ay++) + for (let az = dims === 3 ? gz - reach : 0; az <= (dims === 3 ? gz + reach : 0); az++) { + const cell = grid.get(ax + ',' + ay + ',' + az) + if (!cell) continue + for (const j of cell) { + const dx = px[j] - x + const dy = py[j] - y + const dz = pz[j] - z + const gap = Math.sqrt(dx * dx + dy * dy + dz * dz) - radius[i] - radius[j] + if (gap < LINK_GAP && linked.has(i * count + j)) return 0 + if (!strict) continue + if (gap < MIN_GAP) return 0 + if (gap < PLACE_GAP) roomy = false + } + } + return roomy ? 2 : 1 + } + + const heavier = (a: number, b: number): number => + nodes[b].degree - nodes[a].degree || + nodes[a].createdAt - nodes[b].createdAt || + (nodes[a].path < nodes[b].path ? -1 : nodes[a].path > nodes[b].path ? 1 : 0) + const moved: number[] = [] + for (const i of nodes.map((_, i) => i).filter((i) => placed[i]).sort(heavier)) { + if (fit(i, px[i], py[i], pz[i], false) === 0) { + placed[i] = false + moved.push(i) + } else add(i) + } + + const placeNear = (i: number, ax: number, ay: number, az: number): void => { + let snug: [number, number, number] | null = null + let at: [number, number, number] = [ax, ay, az] + for (let k = 0; k < PROBES; k++) { + const [ox, oy, oz, reach] = probeOffset(k, dims) + if (snug && reach > PLACE_NEAR) { + at = snug + break + } + at = [ax + ox, ay + oy, dims === 3 ? az + oz : 0] + const f = fit(i, at[0], at[1], at[2], true) + if (f === 2) break + if (f === 1 && !snug) snug = at + } + ;[px[i], py[i], pz[i]] = at + set(nodes[i], at[0], at[1], at[2]) + placed[i] = true + add(i) + } + for (const i of moved) placeNear(i, px[i], py[i], pz[i]) + + // Newcomers, breadth-first from the map, so a chain of new notes grows off + // the note it hangs from instead of parking in its region. + const queue: number[] = [] + const queued = new Uint8Array(count) + const offer = (i: number): void => { + if (placed[i] || queued[i]) return + if (!(adjacency.get(i) ?? []).some((m) => placed[m])) return + queued[i] = 1 + queue.push(i) + } + nodes.forEach((_, i) => offer(i)) + let head = 0 + let next = 0 + for (;;) { + let i: number + if (head < queue.length) i = queue[head++] + else { + while (next < count && placed[next]) next++ + if (next === count) break + i = next + } + const around = (adjacency.get(i) ?? []).filter((m) => placed[m]) + if (around.length > 0) { + let sx = 0 + let sy = 0 + let sz = 0 + for (const m of around) { + sx += px[m] + sy += py[m] + sz += pz[m] + } + placeNear(i, sx / around.length, sy / around.length, sz / around.length) + } else { + const [x, y, z] = regionSlot(i) + placeNear(i, x, y, dims === 3 ? z : 0) + } + for (const m of adjacency.get(i) ?? []) offer(m) + } +} + /** * Lay out the graph in both dimensions. Nodes whose path appears in `previous` * keep those positions verbatim; only newcomers are computed. When most of the * vault is new (or nothing is cached) the full deterministic layout runs. + * Either way the result is settled (settleAtlasSpace). */ export function layoutAtlas(graph: AtlasGraph, previous?: AtlasPositions | null): void { const { nodes, edges, regions } = graph @@ -278,51 +472,47 @@ export function layoutAtlas(graph: AtlasGraph, previous?: AtlasPositions | null) } const fresh = nodes.length - known.size const fullLayout = known.size === 0 || fresh / Math.max(1, nodes.length) > 0.4 - const perRegionIndex = new Map() const adjacency = new Map() edges.forEach(([a, b]) => { ;(adjacency.get(a) ?? adjacency.set(a, []).get(a)!).push(b) ;(adjacency.get(b) ?? adjacency.set(b, []).get(b)!).push(a) }) - nodes.forEach((n, i) => { + // Each note's slot in its region: a flattened sphere (3D) and a + // phyllotaxis disc (2D) around the region's center. + const perRegionIndex = new Map() + const slotIndex = nodes.map((n) => { const j = perRegionIndex.get(n.region) ?? 0 perRegionIndex.set(n.region, j + 1) - if (!fullLayout && known.has(n.path)) return + return j + }) + const slot = (i: number): { x: number; y: number; z: number; x2: number; y2: number } => { + const n = nodes[i] const reg = regions[n.region] - const jitter = (): number => (rng() - 0.5) * 22 - const placedNeighbors = fullLayout - ? [] - : (adjacency.get(i) ?? []).filter((m) => known.has(nodes[m].path)) - if (!fullLayout && placedNeighbors.length > 0) { - // A newcomer lands beside what it links to; nothing else moves. - const sum = placedNeighbors.reduce( - (s, m) => { - const nb = nodes[m] - return [s[0] + nb.x, s[1] + nb.y, s[2] + nb.z, s[3] + nb.x2, s[4] + nb.y2] - }, - [0, 0, 0, 0, 0] - ) - const c = placedNeighbors.length - n.x = sum[0] / c + jitter() - n.y = sum[1] / c + jitter() - n.z = sum[2] / c + jitter() - n.x2 = sum[3] / c + jitter() - n.y2 = sum[4] / c + jitter() - return - } + const j = slotIndex[i] const count = Math.max(1, reg.count) const yy = 1 - (2 * (j + 0.5)) / count const rr = Math.sqrt(Math.max(0, 1 - yy * yy)) const th = j * GOLD + n.region * 1.7 const rad = reg.r * 0.85 - n.x = reg.cx + Math.cos(th) * rr * rad + jitter() - n.y = reg.cy + yy * rad * 0.85 + jitter() - n.z = reg.cz + Math.sin(th) * rr * rad + jitter() const rr2 = reg.r * 0.9 * Math.sqrt((j + 0.6) / count) - n.x2 = reg.cx2 + Math.cos(th) * rr2 + jitter() - n.y2 = reg.cy2 + Math.sin(th) * rr2 + jitter() - }) + return { + x: reg.cx + Math.cos(th) * rr * rad, + y: reg.cy + yy * rad * 0.85, + z: reg.cz + Math.sin(th) * rr * rad, + x2: reg.cx2 + Math.cos(th) * rr2, + y2: reg.cy2 + Math.sin(th) * rr2 + } + } if (fullLayout) { + const jitter = (): number => (rng() - 0.5) * 22 + nodes.forEach((n, i) => { + const s = slot(i) + n.x = s.x + jitter() + n.y = s.y + jitter() + n.z = s.z + jitter() + n.x2 = s.x2 + jitter() + n.y2 = s.y2 + jitter() + }) const iterations = nodes.length > 1500 ? 50 : 120 relax( nodes, @@ -350,6 +540,38 @@ export function layoutAtlas(graph: AtlasGraph, previous?: AtlasPositions | null) (n) => [regions[n.region].cx2, regions[n.region].cy2, 0] ) } + const onMap = (): boolean[] => nodes.map((n) => fullLayout || known.has(n.path)) + settleAtlasSpace( + nodes, + adjacency, + 3, + onMap(), + (n) => [n.x, n.y, n.z], + (n, x, y, z) => { + n.x = x + n.y = y + n.z = z + }, + (i) => { + const s = slot(i) + return [s.x, s.y, s.z] + } + ) + settleAtlasSpace( + nodes, + adjacency, + 2, + onMap(), + (n) => [n.x2, n.y2, 0], + (n, x, y) => { + n.x2 = x + n.y2 = y + }, + (i) => { + const s = slot(i) + return [s.x2, s.y2, 0] + } + ) // Label anchors follow the notes, wherever they ended up. regions.forEach((reg, ri) => { const rn = nodes.filter((n) => n.region === ri) diff --git a/packages/app-core/src/lib/cm-callout-fold.test.ts b/packages/app-core/src/lib/cm-callout-fold.test.ts new file mode 100644 index 00000000..64d4057d --- /dev/null +++ b/packages/app-core/src/lib/cm-callout-fold.test.ts @@ -0,0 +1,180 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing, foldedRanges } from '@codemirror/language' +import { Compartment, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, describe, expect, it } from 'vitest' +import { calloutFolding } from './cm-callout-fold' +import { foldAllOutline, foldAtCursor, headingFolding } from './cm-heading-fold' +import { wysiwygBlocksPlugin } from './cm-wysiwyg-blocks' +import { noteEditingSync } from './note-lifecycle-lock' + +const DOC = [ + '# Launch', // 1 + '', // 2 + '> [!example]- Screenshots', // 3 + '> first shot', // 4 + '>', // 5 + '> second shot', // 6 + '', // 7 + '> [!tip]+ Open by default', // 8 + '> tip body', // 9 + '', // 10 + '> [!note] Plain', // 11 + '> plain body', // 12 + '', // 13 + 'End.' // 14 +].join('\n') + +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) + +let views: EditorView[] = [] +afterEach(() => { + for (const view of views) view.destroy() + views = [] + document.body.innerHTML = '' +}) + +function mount(doc = DOC, { caretLine = 14, livePreview = new Compartment() } = {}): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const state = EditorState.create({ + doc, + extensions: [ + markdown({ base: markdownLanguage }), + headingFolding(), + livePreview.of([wysiwygBlocksPlugin, calloutFolding()]) + ] + }) + const view = new EditorView({ + parent, + state: state.update({ selection: { anchor: state.doc.line(Math.min(caretLine, state.doc.lines)).from } }).state + }) + forceParsing(view, doc.length, 5000) + views.push(view) + return view +} + +/** Folded ranges as [first line, last line] pairs. */ +function folds(view: EditorView): [number, number][] { + const out: [number, number][] = [] + foldedRanges(view.state).between(0, view.state.doc.length, (from, to) => { + out.push([view.state.doc.lineAt(from).number, view.state.doc.lineAt(to).number]) + }) + return out +} + +describe('foldable callouts in the editor (#853)', () => { + it('folds a collapsed callout when the note opens, and leaves + and plain callouts open', async () => { + const view = mount() + await settle() + expect(folds(view)).toEqual([[3, 6]]) + }) + + it('hides the fold marker and puts a chevron after a foldable title only', async () => { + const view = mount() + await settle() + const text = view.dom.textContent ?? '' + expect(text).not.toContain('[!example]-') + expect(text).not.toContain('[!tip]+') + expect(text).toContain('Screenshots') + const chevrons = [...view.dom.querySelectorAll('.cm-callout-fold')] + expect(chevrons.map((c) => c.textContent)).toEqual(['▸', '▾']) + expect(chevrons[0].getAttribute('aria-expanded')).toBe('false') + // Folded, the title line closes the card on its own. + const head = view.dom.querySelector('.cm-callout-question.cm-callout-head')! + expect(head.classList.contains('cm-callout-foot')).toBe(true) + }) + + it('toggles from the chevron', async () => { + const view = mount() + await settle() + const chevron = () => view.dom.querySelectorAll('.cm-callout-fold') + chevron()[0].click() + expect(folds(view)).toEqual([]) + chevron()[1].click() + expect(folds(view)).toEqual([[8, 9]]) + chevron()[1].click() + expect(folds(view)).toEqual([]) + }) + + it('shows no chevron on the line holding the caret, where the title is source', async () => { + const view = mount(DOC, { caretLine: 8 }) + await settle() + expect(view.dom.querySelectorAll('.cm-callout-fold')).toHaveLength(1) + expect(view.dom.textContent).toContain('[!tip]+') + }) + + it('does not close again what the reader opened when live preview is reconfigured', async () => { + const livePreview = new Compartment() + const view = mount(DOC, { livePreview }) + await settle() + view.dom.querySelector('.cm-callout-fold')!.click() + expect(folds(view)).toEqual([]) + // A theme switch rebuilds the live-preview extensions. + view.dispatch({ effects: livePreview.reconfigure([wysiwygBlocksPlugin, calloutFolding()]) }) + await settle() + expect(folds(view)).toEqual([]) + }) + + it('folds the next note’s collapsed callouts after a note swap, and not after typing', async () => { + const view = mount() + await settle() + view.dom.querySelector('.cm-callout-fold')!.click() + // Typing a marker into a title does not fold it. + const tip = view.state.doc.line(8) + view.dispatch({ changes: { from: tip.from + '> [!tip]'.length, to: tip.from + '> [!tip]+'.length, insert: '-' } }) + await settle() + expect(folds(view)).toEqual([]) + // The editor swaps in another note: a whole-document replace under + // noteEditingSync, as EditorPane does. + const next = '> [!warning]- Later\n> hidden\n\ntext' + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + annotations: noteEditingSync.of(true), + selection: { anchor: next.length } + }) + await settle() + expect(folds(view)).toEqual([[1, 2]]) + }) + + it('folds a callout from its title line with the fold keys, the whole body', async () => { + const view = mount() + await settle() + view.dom.querySelector('.cm-callout-fold')!.click() + view.dispatch({ selection: { anchor: view.state.doc.line(3).from + 4 } }) + expect(foldAtCursor(view)).toBe(true) + expect(folds(view)).toEqual([[3, 6]]) + }) + + it('folds a foldable callout from a line inside it and moves the caret to its title', async () => { + const view = mount() + await settle() + view.dom.querySelector('.cm-callout-fold')!.click() + view.dispatch({ selection: { anchor: view.state.doc.line(6).from + 4 } }) + expect(foldAtCursor(view)).toBe(true) + expect(folds(view)).toEqual([[3, 6]]) + expect(view.state.doc.lineAt(view.state.selection.main.head).number).toBe(3) + }) + + it('leaves a plain callout alone from a line inside it', async () => { + const view = mount() + await settle() + view.dispatch({ selection: { anchor: view.state.doc.line(12).from + 4 } }) + expect(foldAtCursor(view)).toBe(false) + }) + + it('includes foldable callouts and ordinary blockquote folds in Fold All', async () => { + const view = mount() + await settle() + view.dom.querySelector('.cm-callout-fold')!.click() + expect(foldAllOutline(view)).toBe(true) + expect(folds(view)).toEqual([ + [1, 14], + [3, 6], + [8, 9], + [11, 12] + ]) + }) +}) diff --git a/packages/app-core/src/lib/cm-callout-fold.ts b/packages/app-core/src/lib/cm-callout-fold.ts new file mode 100644 index 00000000..253de367 --- /dev/null +++ b/packages/app-core/src/lib/cm-callout-fold.ts @@ -0,0 +1,217 @@ +/** + * Foldable callouts (#853), as in Obsidian: `> [!type]-` starts collapsed and + * `> [!type]+` expanded; a callout with no marker is not foldable by click. + * + * The fold range is the callout's whole blockquote below its title line, so + * the chevron, the fold keys and Fold All close the same span and any of them + * reopens what another closed. Collapsed callouts fold once each time a note + * opens in the editor. That rides a state field which a note swap resets (a + * whole-document replace under `noteEditingSync`) and which outlives the + * live-preview compartment's reconfigures: a theme switch must not close + * again what the reader opened. + */ +import { ensureSyntaxTree, foldEffect, syntaxTree, unfoldEffect } from '@codemirror/language' +import { + type EditorState, + type Extension, + StateEffect, + StateField, + type Transaction +} from '@codemirror/state' +import { type EditorView, ViewPlugin, type ViewUpdate, WidgetType } from '@codemirror/view' +import type { SyntaxNode, Tree } from '@lezer/common' +import { type FoldRange, foldedExactly } from './cm-list-fold' +import { noteEditingSync } from './note-lifecycle-lock' + +/** A callout's title line: `> [!type]`, an optional fold marker, the title. */ +export const CALLOUT_HEAD_RE = /^(\s*>\s?)\[!(\w+)\]([-+])?\s?(.*)$/ + +export type CalloutFoldMarker = '-' | '+' + +export interface Callout { + node: SyntaxNode + /** `-` collapsed by default, `+` expanded, null for a plain callout. */ + marker: CalloutFoldMarker | null + /** Everything below the title line; null when there is nothing below. */ + range: FoldRange | null +} + +function calloutOf(state: EditorState, node: SyntaxNode): Callout | null { + if (node.name !== 'Blockquote') return null + const head = state.doc.lineAt(node.from) + const match = head.text.match(CALLOUT_HEAD_RE) + if (!match) return null + return { + node, + marker: (match[3] as CalloutFoldMarker | undefined) ?? null, + range: node.to > head.to ? { from: head.to, to: node.to } : null + } +} + +/** The callout whose title is on `lineNumber`, marker or not. */ +export function calloutAtLine(state: EditorState, lineNumber: number): Callout | null { + if (lineNumber < 1 || lineNumber > state.doc.lines) return null + const line = state.doc.line(lineNumber) + let found: Callout | null = null + syntaxTree(state).iterate({ + from: line.from, + to: line.to, + enter: (ref) => { + if (found) return false + if (ref.name !== 'Blockquote' || ref.from < line.from || ref.from > line.to) return undefined + found = calloutOf(state, ref.node) + return false + } + }) + return found +} + +/** The innermost foldable callout (one with a marker) holding `pos` below its + * title line. */ +export function enclosingFoldableCallout(state: EditorState, pos: number): Callout | null { + for ( + let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, -1); + node; + node = node.parent + ) { + const callout = calloutOf(state, node) + if (callout?.marker && callout.range && pos > callout.range.from && pos <= callout.range.to) { + return callout + } + } + return null +} + +/** Fold ranges of every callout with a marker, or only the collapsed ones. + * Pass a fully parsed tree (ensureSyntaxTree) to reach past the viewport. */ +export function foldableCalloutRanges( + state: EditorState, + tree: Tree = syntaxTree(state), + only?: CalloutFoldMarker +): FoldRange[] { + const ranges: FoldRange[] = [] + tree.iterate({ + enter: (ref) => { + if (ref.name !== 'Blockquote') return undefined + const callout = calloutOf(state, ref.node) + if (callout?.marker && callout.range && (!only || callout.marker === only)) { + ranges.push(callout.range) + } + return undefined + } + }) + return ranges +} + +/** Toggle the callout whose title line holds `pos`. */ +export function toggleCalloutFold(view: EditorView, pos: number): boolean { + const { state } = view + const callout = calloutAtLine(state, state.doc.lineAt(pos).number) + if (!callout?.range) return false + const existing = foldedExactly(state, callout.range) + view.dispatch({ effects: existing ? unfoldEffect.of(existing) : foldEffect.of(callout.range) }) + return true +} + +/** + * The chevron after a foldable callout's title: ▾ open, ▸ folded. Clicking it + * toggles the callout; the press is eaten so the caret stays where it was and + * the title line does not turn into source under the pointer. + */ +export class CalloutFoldChevron extends WidgetType { + constructor(private readonly folded: boolean) { + super() + } + + eq(other: CalloutFoldChevron): boolean { + return other.folded === this.folded + } + + toDOM(view: EditorView): HTMLElement { + const chevron = document.createElement('span') + chevron.className = `cm-callout-fold ${this.folded ? 'is-folded' : 'is-open'}` + chevron.setAttribute('role', 'button') + chevron.setAttribute('aria-label', this.folded ? 'Expand callout' : 'Collapse callout') + chevron.setAttribute('aria-expanded', String(!this.folded)) + chevron.textContent = this.folded ? '▸' : '▾' + const swallow = (event: Event): void => { + event.preventDefault() + event.stopPropagation() + } + chevron.addEventListener('mousedown', swallow) + chevron.addEventListener('pointerdown', swallow) + chevron.addEventListener('click', (event) => { + swallow(event) + toggleCalloutFold(view, view.posAtDOM(chevron)) + }) + return chevron + } + + ignoreEvent(): boolean { + return true + } +} + +const markCalloutDefaultsApplied = StateEffect.define() + +function replacesWholeDocument(tr: Transaction): boolean { + let whole = false + tr.changes.iterChangedRanges((fromA, toA) => { + if (fromA === 0 && toA === tr.startState.doc.length) whole = true + }) + return whole +} + +/** Whether this document's collapsed callouts have been folded yet. */ +const calloutDefaultsApplied = StateField.define({ + create: () => false, + update(applied, tr) { + if (tr.effects.some((effect) => effect.is(markCalloutDefaultsApplied))) return true + // Another note swapped in: its collapsed callouts start folded too. + if (applied && tr.docChanged && tr.annotation(noteEditingSync) && replacesWholeDocument(tr)) { + return false + } + return applied + } +}) + +const applyCalloutDefaults = ViewPlugin.fromClass( + class { + private scheduled = false + private destroyed = false + + constructor(view: EditorView) { + this.check(view) + } + + update(update: ViewUpdate): void { + this.check(update.view) + } + + destroy(): void { + this.destroyed = true + } + + private check(view: EditorView): void { + if (this.scheduled || view.state.field(calloutDefaultsApplied, false) !== false) return + this.scheduled = true + // Not from inside a view update: CodeMirror refuses a dispatch there. + queueMicrotask(() => { + this.scheduled = false + const { state } = view + if (this.destroyed || state.field(calloutDefaultsApplied, false) !== false) return + const tree = ensureSyntaxTree(state, state.doc.length, 200) ?? syntaxTree(state) + const folds = foldableCalloutRanges(state, tree, '-') + .filter((range) => !foldedExactly(state, range)) + .map((range) => foldEffect.of(range)) + view.dispatch({ effects: [...folds, markCalloutDefaultsApplied.of(null)] }) + }) + } + } +) + +/** Collapsed callouts start folded when a note opens. Live preview only, with + * the chevrons in `cm-wysiwyg-blocks.ts`; folding itself is `codeFolding()`. */ +export function calloutFolding(): Extension { + return [calloutDefaultsApplied, applyCalloutDefaults] +} diff --git a/packages/app-core/src/lib/cm-heading-fold.test.ts b/packages/app-core/src/lib/cm-heading-fold.test.ts index 0f983c75..1427cdfb 100644 --- a/packages/app-core/src/lib/cm-heading-fold.test.ts +++ b/packages/app-core/src/lib/cm-heading-fold.test.ts @@ -2,14 +2,10 @@ import { describe, it, expect } from 'vitest' import { EditorState } from '@codemirror/state' -import { foldService } from '@codemirror/language' +import { foldedRanges, foldService, unfoldEffect } from '@codemirror/language' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { EditorView } from '@codemirror/view' -import { - foldHeadingAtCursor, - headingFolding, - unfoldHeadingAtCursor -} from './cm-heading-fold' +import { foldAllOutline, foldAtCursor, headingFolding, unfoldAtCursor } from './cm-heading-fold' /** Run the heading fold service over a given line; returns its fold range. */ function foldRangeAtLine(doc: string, lineNumber: number): { from: number; to: number } | null { @@ -98,16 +94,156 @@ describe('heading folding', () => { }) }) - expect(foldHeadingAtCursor(view)).toBe(true) + expect(foldAtCursor(view)).toBe(true) expect( Array.from(parent.querySelectorAll('.cm-heading-line-folded')).some((line) => line.textContent?.includes('Fold me') ) ).toBe(true) - expect(unfoldHeadingAtCursor(view)).toBe(true) + expect(unfoldAtCursor(view)).toBe(true) expect(parent.querySelector('.cm-heading-line-folded')).toBeNull() view.destroy() parent.remove() }) }) + +describe('list item folding (#848)', () => { + const doc = [ + '# Todo', // 1 + '', // 2 + '- [ ] **Task title** #tag', // 3 + ' - detail line', // 4 + ' - another detail line', // 5 + '- [ ] **Next task** #tag', // 6 + '- plain bullet', // 7 + ' 1. numbered child', // 8 + '1. First step', // 9 + ' - step detail', // 10 + '2. Second step', // 11 + '', // 12 + '```', // 13 + 'code', // 14 + '```', // 15 + '' + ].join('\n') + + function mount(text: string, line: number): { view: EditorView; done: () => void } { + const parent = document.createElement('div') + document.body.append(parent) + const state = EditorState.create({ + doc: text, + extensions: [markdown({ base: markdownLanguage }), headingFolding()] + }) + const view = new EditorView({ + parent, + state: state.update({ selection: { anchor: state.doc.line(line).from } }).state + }) + return { + view, + done: () => { + view.destroy() + parent.remove() + } + } + } + + /** Folded ranges as [first line, last hidden line]. */ + const folds = (view: EditorView): Array<[number, number]> => { + const out: Array<[number, number]> = [] + foldedRanges(view.state).between(0, view.state.doc.length, (from, to) => { + out.push([view.state.doc.lineAt(from).number, view.state.doc.lineAt(to).number]) + }) + return out.sort((a, b) => a[0] - b[0]) + } + + it('puts an arrow beside every item with children and none beside the rest', () => { + const { view, done } = mount(doc, 1) + const arrows = [...view.dom.querySelectorAll('.cm-list-fold-arrow')] + const lines = arrows.map((arrow) => view.state.doc.lineAt(view.posAtDOM(arrow)).number) + expect(lines).toEqual([3, 7, 9]) + done() + }) + + it('gives a hard-wrapped item with nothing under it no arrow', () => { + const { view, done } = mount('- a long item\n that wraps onto a second line\n- next\n', 1) + expect(view.dom.querySelector('.cm-list-fold-arrow')).toBeNull() + done() + }) + + it('folds from the arrow and opens again from it', () => { + const { view, done } = mount(doc, 1) + const arrow = () => view.dom.querySelector('.cm-list-fold-arrow')! + arrow().click() + expect(folds(view)).toEqual([[3, 5]]) + expect(arrow().classList.contains('is-folded')).toBe(true) + expect(arrow().getAttribute('aria-expanded')).toBe('false') + arrow().click() + expect(folds(view)).toEqual([]) + done() + }) + + it('folds the list item on the cursor line, tasks, bullets and numbers alike', () => { + for (const [line, expected] of [ + [3, [3, 5]], + [7, [7, 8]], + [9, [9, 10]] + ] as const) { + const { view, done } = mount(doc, line) + expect(foldAtCursor(view)).toBe(true) + expect(folds(view)).toEqual([expected]) + expect(unfoldAtCursor(view)).toBe(true) + expect(folds(view)).toEqual([]) + done() + } + }) + + it('from a child line, folds the item it belongs to and moves the caret to its marker', () => { + const { view, done } = mount(doc, 4) + expect(foldAtCursor(view)).toBe(true) + expect(folds(view)).toEqual([[3, 5]]) + expect(view.state.selection.main.head).toBe(view.state.doc.line(3).from) + done() + }) + + it('still folds a code block from its fence line', () => { + const { view, done } = mount(doc, 13) + expect(foldAtCursor(view)).toBe(true) + expect(folds(view)).toEqual([[13, 15]]) + done() + }) + + it('folds standalone code blocks with Fold All, without needing a heading or list', () => { + const text = '```js\nconst answer = 42\n```\n\n~~~text\nlog line\n~~~\n' + const { view, done } = mount(text, 1) + expect(foldAllOutline(view)).toBe(true) + expect(folds(view)).toEqual([[1, 3], [5, 7]]) + expect(foldAllOutline(view)).toBe(false) + expect(folds(view)).toEqual([[1, 3], [5, 7]]) + expect(view.state.doc.toString()).toBe(text) + done() + }) + + it('folds every heading and list item, nested, so opening the heading shows the items still folded', () => { + const { view, done } = mount(doc, 4) + expect(foldAllOutline(view)).toBe(true) + expect(folds(view)).toEqual([ + [1, 16], + [3, 5], + [7, 8], + [9, 10], + [13, 15] + ]) + // The caret moved out of the hidden text, onto the heading's line. + expect(view.state.selection.main.head).toBe(0) + const heading = { from: view.state.doc.line(1).to, to: view.state.doc.line(16).to } + view.dispatch({ effects: unfoldEffect.of(heading) }) + expect(folds(view)).toEqual([ + [3, 5], + [7, 8], + [9, 10], + [13, 15] + ]) + done() + }) +}) diff --git a/packages/app-core/src/lib/cm-heading-fold.ts b/packages/app-core/src/lib/cm-heading-fold.ts index 24d6d03e..2d1d5c7f 100644 --- a/packages/app-core/src/lib/cm-heading-fold.ts +++ b/packages/app-core/src/lib/cm-heading-fold.ts @@ -1,19 +1,22 @@ /** - * Obsidian-style heading folding for CodeMirror 6 markdown editors. + * Obsidian-style heading and list folding for CodeMirror 6 markdown editors. * * A fold on a heading hides everything from the end of the heading * line up to (but not including) the next heading of equal-or-higher - * level — or the end of the document when none follows. + * level, or the end of the document when none follows. A list item + * with indented children folds the same way (lib/cm-list-fold, #848), and + * so does a callout below its title line (lib/cm-callout-fold, #853). * - * The exported extension bundles three pieces: + * The exported extension bundles four pieces: * - `foldService`: the semantic range calculator so CodeMirror's * fold commands (our vim `zc` / `zo` mappings, the `:fold` * ex command) know what to collapse. * - A `ViewPlugin` that adds an inline ▾ arrow to each heading's * line start and a line-decoration class marking the cursor line. - * - No fold gutter — the full-document gutter would show chevrons - * next to every foldable range (lists, code blocks, frontmatter) - * which clutters the minimalist editor surface. + * - The list item arrows, one beside each foldable item's marker. + * - No fold gutter: the full-document gutter would show chevrons + * next to every foldable range (paragraphs, code blocks, + * frontmatter), which clutters the minimalist editor surface. * * CSS rules in styles/index.css hide the arrow by default and only * reveal it when the heading line is hovered or holds the caret, @@ -21,8 +24,11 @@ */ import { codeFolding, + ensureSyntaxTree, + foldable, foldService, foldEffect, + unfoldCode, unfoldEffect, foldedRanges, syntaxTree @@ -37,6 +43,15 @@ import { ViewUpdate, WidgetType } from '@codemirror/view' +import { + allListItemFoldRanges, + enclosingListItem, + foldedExactly, + listItemAtLine, + listItemFoldArrows, + type FoldRange +} from './cm-list-fold' +import { calloutAtLine, enclosingFoldableCallout, foldableCalloutRanges } from './cm-callout-fold' const HEADING_RE = /^(#{1,6})\s+/ @@ -154,36 +169,91 @@ function exactFoldAtRange( state: EditorState, range: { from: number; to: number } ): { from: number; to: number } | null { - let existing: { from: number; to: number } | null = null - foldedRanges(state).between(range.from, range.to, (from, to) => { - if (from === range.from && to === range.to) { - existing = { from, to } - return false - } - return undefined - }) - return existing + return foldedExactly(state, range) } -function headingRangeAtCursor(view: EditorView): { from: number; to: number } | null { - const line = view.state.doc.lineAt(view.state.selection.main.head) - const level = headingLevelAt(view.state, line.number) - return level === null ? null : rangeForHeading(view.state, line.number, level) +/** + * Fold what the cursor is on: the heading or list item its line starts, + * else anything CodeMirror folds from that line (a code block, a table), + * else the list item the line sits in, the way Vim's zc closes the fold + * around the cursor; the caret then moves to that item's marker (#848). + * Mod+Alt+F, zc, :fold and the palette all run this. + */ +export function foldAtCursor(view: EditorView): boolean { + const { state } = view + const head = state.selection.main.head + const line = state.doc.lineAt(head) + const level = headingLevelAt(state, line.number) + let range: FoldRange | null = + level !== null + ? rangeForHeading(state, line.number, level) + : (listItemAtLine(state, line.number)?.range ?? + calloutAtLine(state, line.number)?.range ?? + null) + let caret: number | null = null + if (!range) range = foldable(state, line.from, line.to) + if (!range) { + // On a line inside a list item or a foldable callout, fold the innermost + // of the two and bring the caret up to its first line. + const item = enclosingListItem(state, head) + const found = enclosingFoldableCallout(state, head) + const callout = found?.range ? { node: found.node, range: found.range } : null + const parent = item && callout ? (item.range.from >= callout.range.from ? item : callout) : (item ?? callout) + if (parent) { + range = parent.range + caret = parent.node.from + } + } + if (!range || exactFoldAtRange(state, range)) return false + view.dispatch({ + effects: foldEffect.of(range), + ...(caret !== null ? { selection: { anchor: caret } } : {}) + }) + return true } -export function foldHeadingAtCursor(view: EditorView): boolean { - const range = headingRangeAtCursor(view) - if (!range || exactFoldAtRange(view.state, range)) return false - view.dispatch({ effects: foldEffect.of(range) }) - return true +/** Unfold the fold that starts on the cursor's line, a heading's or a list + * item's alike. */ +export function unfoldAtCursor(view: EditorView): boolean { + return unfoldCode(view) } -export function unfoldHeadingAtCursor(view: EditorView): boolean { - const range = headingRangeAtCursor(view) - if (!range) return false - const existing = exactFoldAtRange(view.state, range) - if (!existing) return false - view.dispatch({ effects: unfoldEffect.of(existing) }) +/** + * Fold every heading section, list item, callout and syntax fold, nested, + * the way Vim's zM closes every fold: opening a heading then shows its lists + * still folded, one line per item. CodeMirror's foldAll folds only the + * outermost ranges, so a heading swallowed the tasks under it (#848), but + * its syntax folds (code blocks, tables, quotes) must still participate. + */ +export function foldAllOutline(view: EditorView): boolean { + const { state } = view + const tree = ensureSyntaxTree(state, state.doc.length, 500) ?? syntaxTree(state) + const ranges = new Map() + for (let n = 1; n <= state.doc.lines; n++) { + const line = state.doc.line(n) + const level = headingLevelAt(state, n) + const range = + level === null + ? foldable(state, line.from, line.to) + : rangeForHeading(state, n, level) + if (range) ranges.set(`${range.from}:${range.to}`, range) + } + for (const range of [...allListItemFoldRanges(state, tree), ...foldableCalloutRanges(state, tree)]) { + ranges.set(`${range.from}:${range.to}`, range) + } + const fresh = [...ranges.values()].filter((range) => !exactFoldAtRange(state, range)) + if (fresh.length === 0) return false + // Keep the caret in sight: on the line that opens the outermost fold + // around it, as zM leaves it in Vim. + const head = state.selection.main.head + let outer: FoldRange | null = null + for (const range of ranges.values()) { + if (head > range.from && head <= range.to && (!outer || range.from < outer.from)) outer = range + } + view.dispatch({ + effects: fresh.map((range) => foldEffect.of(range)), + ...(outer ? { selection: { anchor: state.doc.lineAt(outer.from).from } } : {}) + }) return true } @@ -404,6 +474,7 @@ export function headingFolding(options: HeadingFoldingOptions = {}): Extension { codeFolding(), service, headingArrowPlugin(showLevelLabels), + listItemFoldArrows(), // The level chips sit LEFT of the arrow slot, wider than .cm-content's // default 32px padding. The centered column's auto margin cannot host // them: it is zero whenever the pane is narrower than the column cap, diff --git a/packages/app-core/src/lib/cm-list-fold.ts b/packages/app-core/src/lib/cm-list-fold.ts new file mode 100644 index 00000000..255f8c1b --- /dev/null +++ b/packages/app-core/src/lib/cm-list-fold.ts @@ -0,0 +1,226 @@ +/** + * List item folding (#848): a bullet, numbered or task item with indented + * children folds like a heading, from a disclosure arrow beside its marker. + * + * The range is the one @codemirror/lang-markdown already gives a ListItem + * node (the end of the item's first line to the end of the item), so the + * arrow, the fold commands and Vim's `zc` all fold the same span and any of + * them reopens what another closed. Only an item with a child block below + * its first line counts: a nested list, another paragraph, a code block. A + * hard-wrapped item with nothing under it gets no arrow. + * + * CSS in styles/index.css hides the arrow until its line is hovered or holds + * the caret, and keeps it visible while the item is folded. + */ +import { foldEffect, foldedRanges, syntaxTree, unfoldEffect } from '@codemirror/language' +import type { EditorState, Extension } from '@codemirror/state' +import type { SyntaxNode, Tree } from '@lezer/common' +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate, + WidgetType +} from '@codemirror/view' + +export interface FoldRange { + from: number + to: number +} + +/** A ListItem's fold range when it has a child block below its first line. */ +export function listItemFoldRange(state: EditorState, node: SyntaxNode): FoldRange | null { + if (node.name !== 'ListItem') return null + const firstLineEnd = state.doc.lineAt(node.from).to + let hasChild = false + for (let child = node.firstChild; child; child = child.nextSibling) { + if (child.from > firstLineEnd && child.name !== 'ListMark') { + hasChild = true + break + } + } + if (!hasChild || node.to <= firstLineEnd) return null + return { from: firstLineEnd, to: node.to } +} + +/** The outermost foldable list item whose marker sits on `lineNumber`. */ +export function listItemAtLine( + state: EditorState, + lineNumber: number +): { node: SyntaxNode; range: FoldRange } | null { + if (lineNumber < 1 || lineNumber > state.doc.lines) return null + const line = state.doc.line(lineNumber) + let found: { node: SyntaxNode; range: FoldRange } | null = null + syntaxTree(state).iterate({ + from: line.from, + to: line.to, + enter: (ref) => { + if (found) return false + if (ref.name !== 'ListItem' || ref.from < line.from || ref.from > line.to) return undefined + const range = listItemFoldRange(state, ref.node) + if (range) { + found = { node: ref.node, range } + return false + } + return undefined + } + }) + return found +} + +/** The innermost foldable list item that contains `pos` below its first line. */ +export function enclosingListItem( + state: EditorState, + pos: number +): { node: SyntaxNode; range: FoldRange } | null { + for ( + let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, -1); + node; + node = node.parent + ) { + if (node.name !== 'ListItem') continue + const range = listItemFoldRange(state, node) + if (range && pos > range.from && pos <= range.to) return { node, range } + } + return null +} + +/** Every foldable list item in the document, nested ones included. Pass a + * fully parsed tree (ensureSyntaxTree) to reach past the viewport. */ +export function allListItemFoldRanges( + state: EditorState, + tree: Tree = syntaxTree(state) +): FoldRange[] { + const ranges: FoldRange[] = [] + tree.iterate({ + enter: (ref) => { + if (ref.name !== 'ListItem') return + const range = listItemFoldRange(state, ref.node) + if (range) ranges.push(range) + } + }) + return ranges +} + +export function foldedExactly(state: EditorState, range: FoldRange): FoldRange | null { + let existing: FoldRange | null = null + foldedRanges(state).between(range.from, range.to, (from, to) => { + if (from === range.from && to === range.to) { + existing = { from, to } + return false + } + return undefined + }) + return existing +} + +function toggleListItemFold(view: EditorView, markerPos: number): void { + const { state } = view + const item = listItemAtLine(state, state.doc.lineAt(markerPos).number) + if (!item) return + const existing = foldedExactly(state, item.range) + view.dispatch({ effects: existing ? unfoldEffect.of(existing) : foldEffect.of(item.range) }) +} + +class ListFoldArrow extends WidgetType { + constructor( + private readonly markerPos: number, + private readonly folded: boolean + ) { + super() + } + + eq(other: ListFoldArrow): boolean { + return other.markerPos === this.markerPos && other.folded === this.folded + } + + toDOM(view: EditorView): HTMLElement { + // A zero-width anchor at the marker, so the arrow sits just left of the + // bullet, number or checkbox at any depth without shifting the text. + const anchor = document.createElement('span') + anchor.className = 'cm-list-fold-anchor' + const arrow = document.createElement('span') + arrow.className = `cm-list-fold-arrow ${this.folded ? 'is-folded' : 'is-open'}` + arrow.setAttribute('role', 'button') + arrow.setAttribute('aria-label', this.folded ? 'Expand list item' : 'Collapse list item') + arrow.setAttribute('aria-expanded', String(!this.folded)) + arrow.textContent = this.folded ? '▸' : '▾' + // Same contract as the heading arrow: eat the press so CodeMirror does + // not place the caret, and toggle on click. + const swallow = (event: Event): void => { + event.preventDefault() + event.stopPropagation() + } + arrow.addEventListener('mousedown', swallow) + arrow.addEventListener('pointerdown', swallow) + arrow.addEventListener('click', (event) => { + event.preventDefault() + event.stopPropagation() + toggleListItemFold(view, this.markerPos) + }) + anchor.appendChild(arrow) + return anchor + } + + ignoreEvent(): boolean { + return true + } +} + +function buildListArrows(view: EditorView): DecorationSet { + const { state } = view + const widgets: { pos: number; deco: Decoration }[] = [] + const seenLines = new Set() + for (const { from, to } of view.visibleRanges) { + syntaxTree(state).iterate({ + from, + to, + enter: (ref) => { + if (ref.name !== 'ListItem' || ref.from < from || ref.from > to) return + const range = listItemFoldRange(state, ref.node) + if (!range) return + // One arrow per line: the outermost item wins a line two share. + const lineNumber = state.doc.lineAt(ref.from).number + if (seenLines.has(lineNumber)) return + seenLines.add(lineNumber) + widgets.push({ + pos: ref.from, + deco: Decoration.widget({ + side: -1, + widget: new ListFoldArrow(ref.from, foldedExactly(state, range) !== null) + }) + }) + } + }) + } + widgets.sort((a, b) => a.pos - b.pos) + return Decoration.set(widgets.map((w) => w.deco.range(w.pos))) +} + +/** The arrows. Fold state itself comes from `codeFolding()` in headingFolding. */ +export function listItemFoldArrows(): Extension { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet + + constructor(view: EditorView) { + this.decorations = buildListArrows(view) + } + + update(update: ViewUpdate): void { + if ( + update.docChanged || + update.viewportChanged || + syntaxTree(update.startState) !== syntaxTree(update.state) || + update.transactions.some((tr) => + tr.effects.some((e) => e.is(foldEffect) || e.is(unfoldEffect)) + ) + ) { + this.decorations = buildListArrows(update.view) + } + } + }, + { decorations: (plugin) => plugin.decorations } + ) +} diff --git a/packages/app-core/src/lib/cm-vim-default-keymap.test.ts b/packages/app-core/src/lib/cm-vim-default-keymap.test.ts index 73f57b02..0ec1b09a 100644 --- a/packages/app-core/src/lib/cm-vim-default-keymap.test.ts +++ b/packages/app-core/src/lib/cm-vim-default-keymap.test.ts @@ -4,6 +4,15 @@ import { EditorSelection, EditorState } from '@codemirror/state' import { EditorView, keymap, type KeyBinding } from '@codemirror/view' import { vim } from '@replit/codemirror-vim' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { + SearchQuery, + closeSearchPanel, + getSearchQuery, + openSearchPanel, + search, + searchPanelOpen, + setSearchQuery +} from '@codemirror/search' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from './cm-vim-default-keymap' // Regression guard for the macOS Vim `Ctrl-d` bug: defaultKeymap's emacs-style @@ -238,3 +247,58 @@ describe('vimAwareSearchKeymap', () => { }) }) +// #860: F3 and Mod-G ran findNext, which opens the find bar only while no +// query is set. After one search they jumped to the old query's next match +// with the bar closed, and the bar never came back to the keyboard. +describe('find next and previous reopen a closed find bar (#860)', () => { + const doc = 'alpha one\nbeta\nalpha two\nalpha three\n' + const binding = (key: string): KeyBinding => { + const found = vimAwareSearchKeymap(false, false).find((b) => b.key === key) + if (!found) throw new Error(`no ${key} binding`) + return found + } + let view: EditorView | null = null + afterEach(() => { + view?.destroy() + view = null + }) + // One search has run and the bar was closed again, the caret at the start. + const searchedThenClosed = (): EditorView => { + view = new EditorView({ state: EditorState.create({ doc, extensions: [search()] }), parent: document.body }) + openSearchPanel(view) + view.dispatch({ effects: setSearchQuery.of(new SearchQuery({ search: 'alpha' })) }) + closeSearchPanel(view) + view.dispatch({ selection: EditorSelection.cursor(0) }) + return view + } + const selected = (v: EditorView): [number, number] => [v.state.selection.main.from, v.state.selection.main.to] + + for (const key of ['F3', 'Mod-g']) { + it(`${key} opens the closed bar with the last query and moves nothing`, () => { + const v = searchedThenClosed() + expect(searchPanelOpen(v.state)).toBe(false) + expect(binding(key).run?.(v)).toBe(true) + expect(searchPanelOpen(v.state)).toBe(true) + expect(getSearchQuery(v.state).search).toBe('alpha') + expect(selected(v)).toEqual([0, 0]) + }) + + it(`${key} steps through the matches once the bar is open, and Shift steps back`, () => { + const v = searchedThenClosed() + binding(key).run?.(v) + binding(key).run?.(v) + expect(selected(v)).toEqual([0, 5]) + binding(key).run?.(v) + expect(selected(v)).toEqual([15, 20]) + binding(key).shift?.(v) + expect(selected(v)).toEqual([0, 5]) + }) + } + + it('Shift on a closed bar opens it too, rather than stepping back out of sight', () => { + const v = searchedThenClosed() + expect(binding('F3').shift?.(v)).toBe(true) + expect(searchPanelOpen(v.state)).toBe(true) + expect(selected(v)).toEqual([0, 0]) + }) +}) diff --git a/packages/app-core/src/lib/cm-vim-default-keymap.ts b/packages/app-core/src/lib/cm-vim-default-keymap.ts index 40bd1e9a..9745cd9f 100644 --- a/packages/app-core/src/lib/cm-vim-default-keymap.ts +++ b/packages/app-core/src/lib/cm-vim-default-keymap.ts @@ -1,8 +1,14 @@ import { defaultKeymap } from '@codemirror/commands' import { markdownKeymap } from '@codemirror/lang-markdown' import { Prec, type Extension } from '@codemirror/state' -import { keymap, type EditorView, type KeyBinding } from '@codemirror/view' -import { searchKeymap } from '@codemirror/search' +import { keymap, type Command, type EditorView, type KeyBinding } from '@codemirror/view' +import { + findNext, + findPrevious, + openSearchPanel, + searchKeymap, + searchPanelOpen +} from '@codemirror/search' import { getCM } from '@replit/codemirror-vim' import { insertNewlineContinueFencedCodeIndent } from './cm-code-fence-indent' import { insertNewlineContinueFrontmatterList } from './cm-frontmatter' @@ -164,6 +170,27 @@ export function vimAwareDefaultKeymap(vimMode: boolean): readonly KeyBinding[] { return vimMode ? vimModeKeymap : defaultKeymap } +/** + * F3 and Mod-G run CodeMirror's `findNext`/`findPrevious`, which open the find + * bar only while no query is set. Once one search had run and the bar was + * closed, they jumped to the next match of that old query out of sight (the + * selection toolbar popping up over it), and the bar never came back to the + * keyboard, in every note, until a restart. In the main window, where Mod+F + * opens note search with Vim off, F3 was the only key into the bar. (#860) + * With the bar closed they now open it, the last query prefilled and selected + * as Mod+F does; with it open they step through the matches as before. + */ +const reopenFirst = + (step: Command): Command => + (view) => + searchPanelOpen(view.state) ? step(view) : openSearchPanel(view) + +const reopeningSearchKeymap: readonly KeyBinding[] = searchKeymap.map((binding) => + binding.run === findNext + ? { ...binding, run: reopenFirst(findNext), shift: reopenFirst(findPrevious) } + : binding +) + /** * CodeMirror's `searchKeymap`, made Vim-aware where Mod is Ctrl. * @@ -173,11 +200,12 @@ export function vimAwareDefaultKeymap(vimMode: boolean): readonly KeyBinding[] { * Vim: the one motion chord the editor still took from Vim (#510). With Vim * mode on, Vim users search with `/`, so the binding is dropped there and * Ctrl+F pages like Ctrl+B. macOS keeps it: Cmd+F never collided with Vim. - * The rest of the keymap (find next, replace, select matches) stays as is. + * The rest of the keymap (replace, select matches) stays as is; find next and + * previous reopen a closed bar (above). */ export function vimAwareSearchKeymap(vimMode: boolean, mac: boolean = isMacPlatform()): readonly KeyBinding[] { - if (!vimMode || mac) return searchKeymap - return searchKeymap.filter((binding) => binding.key !== 'Mod-f') + if (!vimMode || mac) return reopeningSearchKeymap + return reopeningSearchKeymap.filter((binding) => binding.key !== 'Mod-f') } /** diff --git a/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts b/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts index a73f9e4b..3687974a 100644 --- a/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts +++ b/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts @@ -53,6 +53,13 @@ describe('wysiwygBlocksPlugin', () => { view.destroy() }) + it('hides a code block\'s closing fence inside a callout too', () => { + const view = mount('# T\n\n> [!warning] Log\n> ```\n> fetch: done\n> ```\n\nEnd.') + expect(view.dom.textContent).toContain('fetch: done') + expect(view.dom.textContent).not.toContain('```') + view.destroy() + }) + it('reveals the raw list marker on the active line', () => { const view = mount(DOC) const firstItem = DOC.indexOf('- first') diff --git a/packages/app-core/src/lib/cm-wysiwyg-blocks.ts b/packages/app-core/src/lib/cm-wysiwyg-blocks.ts index fdd2bb9c..000f4701 100644 --- a/packages/app-core/src/lib/cm-wysiwyg-blocks.ts +++ b/packages/app-core/src/lib/cm-wysiwyg-blocks.ts @@ -6,7 +6,7 @@ * * WYSIWYG-only: registered via `wysiwygExtensions()`; never loads in Split. */ -import { syntaxTree } from '@codemirror/language' +import { foldEffect, syntaxTree, unfoldEffect } from '@codemirror/language' import { RangeSetBuilder } from '@codemirror/state' import { Decoration, @@ -17,6 +17,8 @@ import { WidgetType } from '@codemirror/view' import { calloutGroupFor } from './callout-types' +import { CALLOUT_HEAD_RE, CalloutFoldChevron } from './cm-callout-fold' +import { foldedExactly } from './cm-list-fold' const quoteLine = Decoration.line({ class: 'cm-wq-quote' }) @@ -57,9 +59,6 @@ const hrRule = Decoration.replace({ widget: new HrWidget() }) * line + its card styling) when the cursor is outside the code block. */ const hideInline = Decoration.replace({}) -/** Header of an Obsidian-style callout: `> [!type] optional title`. */ -const CALLOUT_RE = /^(\s*>\s?)\[!(\w+)\]\s?(.*)$/ - /** Collapse callout type aliases onto one color group, mirroring the Preview * renderer (markdown.ts) via the shared `callout-types` table. */ const calloutGroup = calloutGroupFor @@ -120,20 +119,28 @@ function buildDecorations(view: EditorView): DecorationSet { enter: (node) => { if (node.name === 'Blockquote') { const first = state.doc.lineAt(node.from).number + // A fold splits the viewport into ranges and the tree walk meets a + // quote once per range: its line classes are guarded by + // `quotedLines`, and its title decorations must not come twice. + if (quotedLines.has(first)) return const last = state.doc.lineAt(Math.max(node.from, node.to - 1)).number const firstLine = state.doc.line(first) - const callout = firstLine.text.match(CALLOUT_RE) + const callout = firstLine.text.match(CALLOUT_HEAD_RE) if (callout) { // Obsidian-style callout: a colored card with a typed title. Tag - // each line for the box (head/foot round the top/bottom). + // each line for the box (head/foot round the top/bottom). A folded + // callout is its title line alone, so that line closes the box. const group = calloutGroup(callout[2]) + const marker = callout[3] ?? '' + const range = node.to > firstLine.to ? { from: firstLine.to, to: node.to } : null + const folded = range !== null && foldedExactly(state, range) !== null for (let n = first; n <= last; n++) { if (quotedLines.has(n)) continue quotedLines.add(n) const ln = state.doc.line(n) let cls = `cm-callout cm-callout-${group}` if (n === first) cls += ' cm-callout-head' - if (n === last) cls += ' cm-callout-foot' + if (n === last || (n === first && folded)) cls += ' cm-callout-foot' pending.push({ from: ln.from, to: ln.from, @@ -141,12 +148,22 @@ function buildDecorations(view: EditorView): DecorationSet { line: true }) } - // Header: render the title. Off the line, hide the `[!type]` token — - // the custom title stays (styled), or we show the type name. + // Header: render the title. Off the line, hide the `[!type]` token + // and its fold marker; the custom title stays (styled), or we show + // the type name. A foldable callout (#853) gets its chevron after + // the title. if (!active.has(first)) { const bStart = firstLine.from + callout[1].length - const bEnd = bStart + `[!${callout[2]}]`.length - if (callout[3].trim()) { + const bEnd = bStart + `[!${callout[2]}]${marker}`.length + if (marker && range) { + pending.push({ + from: firstLine.to, + to: firstLine.to, + deco: Decoration.widget({ widget: new CalloutFoldChevron(folded), side: -1 }), + line: false + }) + } + if (callout[4].trim()) { let to = bEnd if (state.doc.sliceString(to, to + 1) === ' ') to += 1 pending.push({ from: bStart, to, deco: hideInline, line: false }) @@ -196,8 +213,9 @@ function buildDecorations(view: EditorView): DecorationSet { pending.push({ from: firstLine.from, to: firstLine.to, deco: hideInline, line: false }) } // Only hide the last line if it's actually a closing fence — an - // unclosed block at EOF ends on a content line we must keep. - const closesWithFence = /^\s*(?:`{3,}|~{3,})\s*$/.test(lastLine.text) + // unclosed block at EOF ends on a content line we must keep. In a + // quote or callout the fence carries the `>` markers too. + const closesWithFence = /^\s*(?:>\s?)*(?:`{3,}|~{3,})\s*$/.test(lastLine.text) if ( closesWithFence && lastLine.number !== firstLine.number && @@ -248,7 +266,14 @@ export const wysiwygBlocksPlugin = ViewPlugin.fromClass( this.decorations = buildDecorations(view) } update(update: ViewUpdate): void { - if (update.docChanged || update.selectionSet || update.viewportChanged) { + if ( + update.docChanged || + update.selectionSet || + update.viewportChanged || + update.transactions.some((tr) => + tr.effects.some((e) => e.is(foldEffect) || e.is(unfoldEffect)) + ) + ) { this.decorations = buildDecorations(update.view) } } diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 947d18e2..2ff18ec9 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -51,13 +51,20 @@ const APP_REPOSITORY_URL = 'https://github.com/ZenNotes/zennotes' const APP_RELEASES_URL = 'https://github.com/ZenNotes/zennotes/releases/latest' const APP_ISSUES_URL = 'https://github.com/ZenNotes/zennotes/issues' -type FoldCommand = 'foldCode' | 'unfoldCode' | 'foldAll' | 'unfoldAll' +type FoldCommand = 'foldAtCursor' | 'unfoldAtCursor' | 'foldAllOutline' | 'unfoldAll' async function runFoldCommand(command: FoldCommand): Promise { const view = useStore.getState().editorViewRef if (!view) return - const foldModule = await import('@codemirror/language') - foldModule[command](view) + if (command === 'unfoldAll') { + const language = await import('@codemirror/language') + language.unfoldAll(view) + } else { + // Headings and list items alike (#848); loaded on demand like the + // editor itself. + const folding = await import('./cm-heading-fold') + folding[command](view) + } view.focus() } @@ -848,37 +855,37 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma }, { id: 'fold.heading', - title: 'Fold Heading at Cursor', + title: 'Fold Heading or List Item at Cursor', category: 'Editor', shortcut: shortcut('vim.foldCurrent'), - keywords: 'collapse fold heading section', + keywords: 'collapse fold heading section list item task bullet children callout', when: () => !!getState().editorViewRef && !!getState().activeNote, - run: () => runFoldCommand('foldCode') + run: () => runFoldCommand('foldAtCursor') }, { id: 'fold.unfold-heading', - title: 'Unfold Heading at Cursor', + title: 'Unfold Heading or List Item at Cursor', category: 'Editor', shortcut: shortcut('vim.unfoldCurrent'), - keywords: 'expand unfold heading section', + keywords: 'expand unfold heading section list item task bullet children callout', when: () => !!getState().editorViewRef && !!getState().activeNote, - run: () => runFoldCommand('unfoldCode') + run: () => runFoldCommand('unfoldAtCursor') }, { id: 'fold.all', - title: 'Fold All Headings', + title: 'Fold All Headings and Lists', category: 'Editor', shortcut: shortcut('vim.foldAll'), - keywords: 'collapse fold all every', + keywords: 'collapse fold all every heading list item task outline callout', when: () => !!getState().editorViewRef && !!getState().activeNote, - run: () => runFoldCommand('foldAll') + run: () => runFoldCommand('foldAllOutline') }, { id: 'fold.unfold-all', - title: 'Unfold All Headings', + title: 'Unfold All Headings and Lists', category: 'Editor', shortcut: shortcut('vim.unfoldAll'), - keywords: 'expand unfold all every reset', + keywords: 'expand unfold all every reset heading list item task outline callout', when: () => !!getState().editorViewRef && !!getState().activeNote, run: () => runFoldCommand('unfoldAll') }, diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 5d55e876..5ab4c081 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -360,9 +360,9 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ 'When you type `/` at the start of a line or after whitespace, ZenNotes opens an inline insert menu for common markdown structures such as headings, bulleted or numbered lists, to-do items, callouts, code blocks, dividers, tables, math blocks, links, images, and even creating a new note page.' }, { - title: 'Label and fold headings', + title: 'Label and fold headings and lists', body: - 'Settings → Editor → Writing can show H1 through H6 labels before Markdown headings. Every heading also has a disclosure arrow that folds its section. Click the arrow, use Ctrl+Alt+F and Ctrl+Alt+U (Cmd+Option+F and Cmd+Option+U on macOS), or use zc and zo in Vim mode.' + 'Settings → Editor → Writing can show H1 through H6 labels before Markdown headings. Every heading also has a disclosure arrow that folds its section, and so does every list item with indented lines under it (a bullet, a numbered item or a task): the arrow beside its marker folds those lines, in Edit and in Preview, and the item line stays in view. Click the arrow, use Ctrl+Alt+F and Ctrl+Alt+U (Cmd+Option+F and Cmd+Option+U on macOS), or use zc and zo in Vim mode; on one of the lines under a list item, they fold the item itself. Fold All (zM) folds every heading and every list item, nested, so opening a heading shows its tasks one line each. Folding only hides lines: the note, and the Tasks view, are unchanged.' }, { title: 'Create text replacements', @@ -377,7 +377,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Callouts highlight the important bits', body: - 'Turn a blockquote into a colored callout (an Obsidian-style admonition) by starting its first line with `> [!type]`, optionally followed by a title: `> [!warning] Heads up`. Typing `[!` inside a blockquote opens an insert menu of the callout types — filter by name (aliases match too, so `warn` finds Warning and `tldr` finds Abstract), move with the arrow keys or the Vim/Emacs completion chords (Ctrl+J / Ctrl+K, Ctrl+N / Ctrl+P), and press Enter, Tab, Ctrl+Y, or click to drop in the syntax. The type sets the color: `note`, `info`, `abstract` / `summary` / `tldr` render blue; `tip` / `hint` / `important` and `success` / `check` / `done` green; `question` / `help` / `faq` and `example` purple; `warning` / `caution` / `attention` yellow; `danger` / `error`, `bug`, and `failure` / `fail` red; and `quote` / `cite` a neutral gray. Types are case-insensitive, and an unrecognized one still renders as a neutral note, so callouts pasted in from Obsidian keep working.' + 'Turn a blockquote into a colored callout (an Obsidian-style admonition) by starting its first line with `> [!type]`, optionally followed by a title: `> [!warning] Heads up`. Typing `[!` inside a blockquote opens an insert menu of the callout types — filter by name (aliases match too, so `warn` finds Warning and `tldr` finds Abstract), move with the arrow keys or the Vim/Emacs completion chords (Ctrl+J / Ctrl+K, Ctrl+N / Ctrl+P), and press Enter, Tab, Ctrl+Y, or click to drop in the syntax. The type sets the color: `note`, `info`, `abstract` / `summary` / `tldr` render blue; `tip` / `hint` / `important` and `success` / `check` / `done` green; `question` / `help` / `faq` and `example` purple; `warning` / `caution` / `attention` yellow; `danger` / `error`, `bug`, and `failure` / `fail` red; and `quote` / `cite` a neutral gray. Types are case-insensitive, and an unrecognized one still renders as a neutral note, so callouts pasted in from Obsidian keep working. Put `-` or `+` right after the type to make a callout foldable, as in Obsidian: `> [!example]- Screenshots` starts collapsed and `> [!example]+ Screenshots` starts expanded. Click the title row in Preview, or the chevron after the title in Edit mode, to open or close it; the fold keys (zc and zo, or Ctrl+Alt+F and Ctrl+Alt+U, Cmd+Option+F and Cmd+Option+U on macOS) work on it from its title line or any line inside it, and Fold All (zM) includes it. A collapsed callout starts closed each time the note opens and prints open in a PDF export; folding never changes the note.' }, { title: '@ inserts dates and links notes', @@ -442,7 +442,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'The CLI is the bridge to launchers', body: - 'The `zn` command-line tool can list, read, search, capture, edit, archive, trash, inspect tasks, work with databases (`zn base list / rows / get / add / set / create` — add rows with record pages, set fields with the grid’s exact semantics, locally or against a self-hosted server; renaming a record page with `zn rename` keeps its row pointing at it), and start the MCP server without the app running. With no `--vault` or `--server` flag, `zn` works on the vault the app has open, a connected server included (give it the token as `ZENNOTES_REMOTE_TOKEN` or `--token`). Raycast uses it for search, then uses `zennotes://open` and `zennotes://open-window` links to bring the selected note back into ZenNotes. On macOS, Settings → CLI can install the bundled Raycast extension locally so users do not need to wait for the Raycast Store version.' + 'The `zn` command-line tool can list, read, search, capture, edit, archive, trash, inspect tasks, work with databases (`zn base list / rows / get / add / set / create` — add rows with record pages, set fields with the grid’s exact semantics, locally or against a self-hosted server; renaming a record page with `zn rename` keeps its row pointing at it), and start the MCP server without the app running. With no `--vault` or `--server` flag, `zn` works on the vault the app has open, a connected server included (it sends the token `zn connect` saved for that server, or `ZENNOTES_REMOTE_TOKEN` / `--token`). Raycast uses it for search, then uses `zennotes://open` and `zennotes://open-window` links to bring the selected note back into ZenNotes. On macOS, Settings → CLI can install the bundled Raycast extension locally so users do not need to wait for the Raycast Store version.' }, { title: 'Excalidraw drawings are first-class files', @@ -501,7 +501,8 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Ctrl+D (in Search notes)', action: 'Move the highlighted note to Trash', detail: 'Trash a note straight from the search results, with the usual confirmation; the palette stays open, so a clean-up pass is search, Ctrl+D, search, Ctrl+D.' }, { keys: 'Shift+Enter (in Search notes)', action: 'Create a note named after your search', detail: 'The last row of the results offers to create the note you typed: Shift+Enter (or Enter on that row) opens a New note form with three fields. Name starts as your search text, selected so you can retype it. Folder starts empty (your Inbox) or as the path you typed, like projects/roadmap; landing in it lists every folder, typing narrows the list, Enter picks the highlighted one and ArrowUp keeps a folder that does not exist yet. Tags starts with any #tag words from your search; type to pick an existing tag or add a new one, Space or comma commits, Backspace on an empty field removes the last one. The line under the fields tells you what will happen. A note with the same name in that folder blocks Create until you change the name, and Shift+↵ opens the existing note instead; the same name elsewhere only warns. Enter in Name, or Ctrl/Cmd+Enter anywhere, creates the note and opens it; Escape goes back to the results.' }, { keys: 'Mod+F', action: 'Search notes (non-Vim mode)', detail: 'Open the note search palette directly when Vim mode is off.' }, - { keys: 'Mod+F (in the editor)', action: 'Find and replace in the note', detail: 'With Vim mode on, Linux and Windows keep Ctrl+F as Vim\u2019s page-forward (search with / instead); on macOS and with Vim off the bar opens as usual. In Edit and Split, open the editor’s find-and-replace bar: Tab moves between the Find and Replace fields, with match-case, whole-word, and regex toggles. Esc closes it.' }, + { keys: 'Mod+F (in the editor)', action: 'Find and replace in the note', detail: 'In Edit and Split, open the editor’s find-and-replace bar: Tab moves between the Find and Replace fields, with match-case, whole-word, and regex toggles. Esc closes it. With Vim mode off, Mod+F in the main window opens note search instead (the row above); unbind or move Search notes in non-Vim mode under Settings \u2192 Keymap to give Mod+F to the bar, or use F3. With Vim mode on, Linux and Windows keep Ctrl+F as Vim\u2019s page-forward (search with / instead); macOS keeps Cmd+F for the bar. A floating window has no note search, so there Mod+F opens the bar with Vim mode off too.' }, + { keys: 'F3 / Mod+G (in the editor)', action: 'Find the next match in the note', detail: 'With the find bar closed, open it with your last search filled in and selected, ready to retype or to press F3 again; with it open, move to the next match. Shift+F3 or Shift+Mod+G moves to the previous one. The same with Vim mode on or off, in every window.' }, { keys: 'Shift+Mod+P', action: 'Open commands', detail: 'Open the command palette.' }, { keys: 'Mod+N', action: 'New note in current folder', detail: 'Create a note in the active note\u2019s folder (or the browsed folder when no note is open) and focus its title. On Linux and Windows this wins over Vim\u2019s Ctrl+N (cursor down). Rebindable under Settings \u2192 Keymaps.' }, { keys: 'Shift+Mod+N', action: 'New Quick Note', detail: 'Create a quick capture note in the main window and focus its title.' }, @@ -573,9 +574,9 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Space l d', action: 'Convert table to database', detail: 'Turn the Markdown table under the cursor (or the rendered table whose cell you are in) into a `.base` database and leave a `[[link]]` to it in the note. Also “Convert Table to Database…” in the command palette, `:table_to_database`, and the bottom of the table cell menu.' }, { keys: 'Space, then pause', action: 'Show leader hints', detail: 'If enabled in Settings, open a which-key style guide for the next available leader actions. Sticky mode keeps it open until `Space` or `Esc`.' }, { keys: 'Mod+3', action: 'Toggle outline panel', detail: 'Show or hide the persistent outline in the active pane. Once focused (Ctrl+W l or Alt+L from the editor), j / k — or the arrows — walk the headings, gg / G jump to the first and last, Enter jumps the editor to the heading under the cursor, and Esc hands focus back.' }, - { keys: 'zc / zo', action: 'Fold / unfold heading', detail: 'Collapse or expand the section below the heading at the cursor.' }, - { keys: 'Ctrl+Alt+F / U', action: 'Fold / unfold heading', detail: 'Collapse or expand the heading section at the cursor with Vim mode on or off. On macOS, use Cmd+Option+F / U.' }, - { keys: 'zM / zR', action: 'Fold / unfold all', detail: 'Collapse or expand every heading section in the note.' }, + { keys: 'zc / zo', action: 'Fold / unfold heading or list item', detail: 'Collapse or expand the section below the heading at the cursor, the lines under the list item at the cursor, or the callout whose title it is on. On a line under a list item or inside a foldable callout, zc folds that.' }, + { keys: 'Ctrl+Alt+F / U', action: 'Fold / unfold, Vim on or off', detail: 'The same as zc / zo, with Vim mode on or off. On macOS, use Cmd+Option+F / U.' }, + { keys: 'zM / zR', action: 'Fold / unfold all', detail: 'Collapse or expand every heading section, every list item and every foldable callout in the note. zM folds them nested, so opening a heading shows its list items still folded.' }, { keys: ']s / [s', action: 'Next / previous Harper suggestion', detail: 'With Grammar and spelling with Harper on, jump the cursor to the next or previous underlined problem, the way Vim walks misspellings.' }, { keys: 'z=', action: 'Harper suggestions', detail: 'Open the fixes for the problem under the cursor (or the first one on its line). A digit or Enter applies one, j/k move, Esc closes.' }, { keys: 'zg', action: 'Add word to Harper dictionary', detail: 'Teach this vault\'s dictionary the word under the cursor. Stored in vault.json, so it travels and syncs with the vault.' }, @@ -1068,13 +1069,13 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ }, { command: ':fold / :unfold', - summary: 'Toggle the heading at the cursor', - detail: 'Collapse or expand the section beneath the heading at the current line. This is the ex-line path to the editor fold and unfold motions.' + summary: 'Toggle the heading, list item or callout at the cursor', + detail: 'Collapse or expand the section beneath the heading at the current line, the lines under the list item there, or the callout whose title it is. This is the ex-line path to the editor fold and unfold motions.' }, { command: ':foldall / :unfoldall', - summary: 'Fold every heading', - detail: 'Collapse or expand every heading section at once. This is the ex-line path to the editor-wide fold motions.' + summary: 'Fold every heading, list item and foldable callout', + detail: 'Collapse or expand every heading section, every list item with lines under it and every foldable callout at once, nested. This is the ex-line path to the editor-wide fold motions.' } ] @@ -1273,6 +1274,6 @@ export const HELP_CLI: HelpCard[] = [ { title: 'MCP for AI agents', body: - '`zn mcp` starts the ZenNotes MCP server in stdio mode, the same one Claude Code, Claude Desktop, and Codex use under the hood. Once `zn` is installed, Settings → MCP installs configure the clients to launch `zn mcp` directly, so the install path is one stable absolute path that survives app moves. The server works on the vault the app has open: a folder on this machine, or a self-hosted ZenNotes server you connected from Settings → Vault. `vault_info` says which. A server that requires a token needs it in the MCP client\'s environment as `ZENNOTES_REMOTE_TOKEN` (the app keeps its own copy in the OS secret store, which `zn` cannot read). To point an agent somewhere else, give the command the same flags every other `zn` command takes: `zn mcp --vault work` or `zn mcp --server home --token ` in the client\'s config, or `ZENNOTES_SERVER` / `ZENNOTES_VAULT` in its environment. Beyond reading and writing notes, the server can hold a review with you through comments: ask the assistant to read a note\'s comments and answer them, and its replies land in the Comments panel under yours, signed with its name (`list_comments`, `add_comment`, `reply_to_comment`, `resolve_comment`).' + '`zn mcp` starts the ZenNotes MCP server in stdio mode, the same one Claude Code, Claude Desktop, and Codex use under the hood. Once `zn` is installed, Settings → MCP installs configure the clients to launch `zn mcp` directly, so the install path is one stable absolute path that survives app moves. The server works on the vault the app has open: a folder on this machine, or a self-hosted ZenNotes server you connected from Settings → Vault. `vault_info` says which. Switch vaults in the app and the server follows: the assistant\'s next tool call is refused with a note naming the old and the new vault, and once `vault_info` confirms the switch it carries on in the new one, so nothing planned in one vault lands in the other. A server that requires a token needs one the MCP server can read, because the app keeps its own copy in the OS secret store: with `zn` installed, run `zn connect --no-default` once in a terminal (it saves the token for `zn` and `zn mcp` without changing which vault they follow), or set `ZENNOTES_REMOTE_TOKEN` in the MCP client\'s environment. To point an agent somewhere else, give the command the same flags every other `zn` command takes: `zn mcp --vault work` or `zn mcp --server home --token ` in the client\'s config, or `ZENNOTES_SERVER` / `ZENNOTES_VAULT` in its environment. Beyond reading and writing notes, the server can hold a review with you through comments: ask the assistant to read a note\'s comments and answer them, and its replies land in the Comments panel under yours, signed with its name (`list_comments`, `add_comment`, `reply_to_comment`, `resolve_comment`).' } ] diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index 444504cc..5831fbcc 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -942,8 +942,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ kind: "sequence", scope: "vim-editor", group: "vim", - title: "Fold heading at cursor", - description: "Collapse the heading section at the cursor.", + title: "Fold heading or list item at cursor", + description: "Collapse the heading section, the list item or the callout at the cursor; on a line under a list item or inside a foldable callout, fold that.", defaultBinding: "z c", vimOnly: true, maxTokens: 2, @@ -953,8 +953,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ kind: "sequence", scope: "vim-editor", group: "vim", - title: "Unfold heading at cursor", - description: "Expand the heading section at the cursor.", + title: "Unfold heading or list item at cursor", + description: "Expand the heading section or the list item folded at the cursor.", defaultBinding: "z o", vimOnly: true, maxTokens: 2, @@ -964,8 +964,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ kind: "sequence", scope: "vim-editor", group: "vim", - title: "Fold all headings", - description: "Collapse every heading section in the note.", + title: "Fold all headings and lists", + description: "Collapse every heading section, every list item with lines under it and every foldable callout, nested.", defaultBinding: "z M", vimOnly: true, maxTokens: 2, @@ -975,8 +975,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ kind: "sequence", scope: "vim-editor", group: "vim", - title: "Unfold all headings", - description: "Expand every heading section in the note.", + title: "Unfold all headings and lists", + description: "Expand every fold in the note.", defaultBinding: "z R", vimOnly: true, maxTokens: 2, @@ -1280,8 +1280,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ kind: "shortcut", scope: "vim-editor", group: "view-actions", - title: "Fold heading", - description: "Fold the heading section containing the cursor.", + title: "Fold heading or list item", + description: "Fold the heading section, the list item or the callout at the cursor; on a line under a list item or inside a foldable callout, fold that.", defaultBinding: "Mod+Alt+F", }, { @@ -1289,8 +1289,8 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ kind: "shortcut", scope: "vim-editor", group: "view-actions", - title: "Unfold heading", - description: "Unfold the heading section containing the cursor.", + title: "Unfold heading or list item", + description: "Unfold the heading section or the list item folded at the cursor.", defaultBinding: "Mod+Alt+U", }, { diff --git a/packages/app-core/src/lib/markdown.test.ts b/packages/app-core/src/lib/markdown.test.ts index 629a1a69..1f576ab9 100644 --- a/packages/app-core/src/lib/markdown.test.ts +++ b/packages/app-core/src/lib/markdown.test.ts @@ -550,6 +550,39 @@ describe('callout titles keep their inline markup (#549)', () => { }) }) +describe('foldable callouts (#853)', () => { + it('renders a collapsed callout as a closed
with the title as its ', () => { + const html = renderMarkdown('> [!example]- Screenshots\n> first\n> second') + expect(html).toMatch(/
]*>/) + expect(html).not.toMatch(/]*\sopen/) + expect(html).toContain('Screenshots') + expect(html).toMatch(/]*>first\nsecond<\/p>/) + }) + + it('renders an expanded callout open', () => { + const html = renderMarkdown('> [!tip]+ Open by default\n> body') + expect(html).toMatch(/
Open by default
') + }) + + it('titles an untitled foldable callout with its type', () => { + expect(renderMarkdown('> [!note]-\n> body')).toContain('Note') + }) + + it('draws a plain box for a callout with nothing to fold, or no marker', () => { + const empty = renderMarkdown('> [!note]-') + expect(empty).not.toContain('Note
') + const plain = renderMarkdown('> [!note] Plain\n> body') + expect(plain).not.toContain(' { + expect(renderMarkdown('> [!note]-x not a marker')).not.toContain('callout') + }) +}) + // A display block inside an Obsidian-style callout used to lose the reading // view entirely: the fence normalizer re-scanned the closing fence of the block // BEFORE the callout as an opener, took the callout's `> $$` for content diff --git a/packages/app-core/src/lib/markdown.ts b/packages/app-core/src/lib/markdown.ts index dcdae4ef..8da9de40 100644 --- a/packages/app-core/src/lib/markdown.ts +++ b/packages/app-core/src/lib/markdown.ts @@ -47,6 +47,7 @@ const ALLOWED_RENDERED_URI_RE = const ALLOWED_RENDERED_DATA_ATTRS = [ 'data-bookmark-url', 'data-callout', + 'data-callout-fold', 'data-embed-src', 'data-embed-url', 'data-embed-height', @@ -592,6 +593,11 @@ function remarkHighlight() { * > body * * → `
` with a `.callout-title` header. + * + * A fold marker after the type (`> [!note]-` collapsed, `> [!note]+` + * expanded) makes it a `
` with the title as its ``, so the + * whole title row toggles it and the browser's find opens it on a match + * (#853). Folding it hides the body only; the note never changes. */ function remarkCallouts() { return (tree: MdRoot): void => { @@ -606,9 +612,10 @@ function remarkCallouts() { // nodes included — a [link](x) or $math$ in the title used to be // orphaned into an uncolored body paragraph, because only this leading // text fragment was consulted for the title. (#549) - const marker = firstText.value.match(/^\[!(\w+)\](?:[ \t]+|(?=\n)|$)/) + const marker = firstText.value.match(/^\[!(\w+)\]([-+])?(?:[ \t]+|(?=\n)|$)/) if (!marker) return const type = marker[1].toLowerCase() + const marker2 = marker[2] as '-' | '+' | undefined // Split the paragraph's inline children into the title line and the // body. Explicit Markdown breaks arrive as `break` nodes, while ordinary @@ -657,22 +664,27 @@ function remarkCallouts() { } else { node.children.shift() } + // A callout with nothing under its title has nothing to fold, and + // renders as a plain one, as the editor draws it with no chevron. + const fold = node.children.length > 0 ? marker2 : undefined - // Turn the blockquote into a styled div. + // Turn the blockquote into a styled div, or a
when it folds. node.data = { ...(node.data || {}), - hName: 'div', + hName: fold ? 'details' : 'div', hProperties: { className: ['callout'], - 'data-callout': type + 'data-callout': type, + ...(fold ? { 'data-callout-fold': fold === '-' ? 'collapsed' : 'expanded', open: fold === '+' } : {}) } } - // Prepend a title paragraph that renders as `
`. + // Prepend a title paragraph that renders as `
` + // (the of a foldable callout). node.children.unshift({ type: 'paragraph', data: { - hName: 'div', + hName: fold ? 'summary' : 'div', hProperties: { className: ['callout-title'] } }, children: hasTitle ? titleChildren : [{ type: 'text', value: fallbackTitle }] diff --git a/packages/app-core/src/lib/preview-list-fold.test.ts b/packages/app-core/src/lib/preview-list-fold.test.ts new file mode 100644 index 00000000..a95338f8 --- /dev/null +++ b/packages/app-core/src/lib/preview-list-fold.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { renderMarkdown } from './markdown' +import { enhancePreviewListFolds } from './preview-list-fold' +import { wrapTaskItemOwnText } from './preview-task-body' + +const md = [ + '- [ ] **Task title** #tag', + ' - detail line', + ' - another detail line', + '- [ ] **Next task** #tag', + '- plain bullet', + ' 1. numbered child', + '1. First step', + ' - step detail', + '2. Second step', + '', + '- loose item', + '', + ' second paragraph', + '', + '- after' +].join('\n') + +function mount(source = md): HTMLElement { + const root = document.createElement('div') + root.innerHTML = renderMarkdown(source) + document.body.append(root) + enhancePreviewListFolds(root) + return root +} + +/** An item's own first line: no arrow, nothing nested. */ +const labelOf = (li: HTMLElement): string => { + const copy = li.cloneNode(true) as HTMLElement + copy.querySelectorAll('.prose-list-fold-arrow, ul, ol').forEach((el) => el.remove()) + return (copy.textContent ?? '').trim().split('\n')[0].trim() +} + +const itemStartingWith = (root: HTMLElement, text: string): HTMLElement => + [...root.querySelectorAll('li')].find((li) => labelOf(li).startsWith(text))! + +const arrowOf = (li: HTMLElement): HTMLElement | null => li.querySelector(':scope > .prose-list-fold-arrow') + +describe('preview list folding (#848)', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + it('puts an arrow on every item with content under its first line, and on no other', () => { + const root = mount() + const withArrow = [...root.querySelectorAll('li')].filter((li) => arrowOf(li)).map(labelOf) + expect(withArrow).toEqual(['Task title #tag', 'plain bullet', 'First step', 'loose item']) + }) + + it('keeps the arrow out of a task body wrapped before it, as the preview renders', () => { + const root = document.createElement('div') + root.innerHTML = renderMarkdown(md) + document.body.append(root) + root.querySelectorAll('li.task-list-item').forEach((li) => + wrapTaskItemOwnText(li, li.querySelector(':scope > input')) + ) + enhancePreviewListFolds(root) + const task = itemStartingWith(root, 'Task title') + const arrow = arrowOf(task)! + expect(arrow).not.toBeNull() + expect(task.querySelector('.task-item-body')!.contains(arrow)).toBe(false) + arrow.click() + expect(task.querySelector(':scope > ul')!.style.display).toBe('none') + expect(task.querySelector('.task-item-body')!.style.display).toBe('') + }) + + it('leaves a loose task paragraph first, where its checkbox styling expects it', () => { + const root = mount(['- [ ] loose task', '', ' more about it', ''].join('\n')) + const task = root.querySelector('li.task-list-item')! + expect(arrowOf(task)).not.toBeNull() + const [first, second] = [...task.querySelectorAll(':scope > p')] + expect(task.querySelector(':scope > p:first-child')).toBe(first) + arrowOf(task)!.click() + expect(first.style.display).toBe('') + expect(second.style.display).toBe('none') + }) + + it('tells the CSS how wide a numbered item marker is', () => { + const items = Array.from({ length: 10 }, (_, i) => `${i + 1}. step ${i + 1}\n - detail`).join('\n') + const root = mount(items) + const chars = [...root.querySelectorAll('ol > li')].map((li) => + li.style.getPropertyValue('--z-list-marker-chars') + ) + expect(chars).toEqual(['3', '3', '3', '3', '3', '3', '3', '3', '3', '4']) + }) + + it('gives a bullet nested in a numbered item its own marker width', () => { + const root = mount(['1. step', ' - bullet', ' - detail', ''].join('\n')) + const bullet = itemStartingWith(root, 'bullet') + expect(bullet.style.getPropertyValue('--z-list-marker-chars')).toBe('2') + }) + + it('folds the list nested under a task from the arrow and opens it again', () => { + const root = mount() + const task = itemStartingWith(root, 'Task title') + const nested = task.querySelector(':scope > ul')! + const arrow = arrowOf(task)! + arrow.click() + expect(nested.style.display).toBe('none') + expect(task.getAttribute('data-list-folded')).toBe('true') + expect(arrow.classList.contains('is-folded')).toBe(true) + expect(arrow.getAttribute('aria-expanded')).toBe('false') + arrow.click() + expect(nested.style.display).toBe('') + expect(task.getAttribute('data-list-folded')).toBe('false') + }) + + it('keeps the first paragraph of a loose item and folds the rest', () => { + const root = mount() + const loose = itemStartingWith(root, 'loose item') + const [first, second] = [...loose.querySelectorAll(':scope > p')] + arrowOf(loose)!.click() + expect(first.style.display).toBe('') + expect(second.style.display).toBe('none') + }) + + it('stays out of the preview click handling and adds no second arrow', () => { + const root = mount() + const onClick = vi.fn() + root.addEventListener('click', onClick) + arrowOf(itemStartingWith(root, 'plain bullet'))!.click() + expect(onClick).not.toHaveBeenCalled() + enhancePreviewListFolds(root) + expect(root.querySelectorAll('.prose-list-fold-arrow')).toHaveLength(4) + }) +}) diff --git a/packages/app-core/src/lib/preview-list-fold.ts b/packages/app-core/src/lib/preview-list-fold.ts new file mode 100644 index 00000000..2d1ff8c0 --- /dev/null +++ b/packages/app-core/src/lib/preview-list-fold.ts @@ -0,0 +1,109 @@ +/** + * List item folding for the rendered preview (#848), the counterpart of the + * editor's list arrows: an item with block content under its first line (a + * nested list, another paragraph, a code block) gets a ▾ in the gutter that + * hides that content. + * + * Folded state lives in the DOM (`data-list-folded`), the contract the + * preview heading folds keep: a re-render (an edit, a mode switch) opens + * everything again. + * + * The arrow is the item's LAST child and absolutely placed: as the first it + * would break `li.task-list-item > p:first-child`, which seats a loose + * task's text beside its checkbox. + */ + +const ARROW_CLASS = 'prose-list-fold-arrow' +const FOLDABLE_CLASS = 'prose-list-foldable' +const FOLDED_ATTR = 'data-list-folded' + +const BLOCK_TAGS = new Set([ + 'UL', + 'OL', + 'P', + 'PRE', + 'BLOCKQUOTE', + 'TABLE', + 'DIV', + 'FIGURE', + 'DETAILS', + 'HR', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6' +]) + +function isBlock(node: Element): node is HTMLElement { + return node instanceof HTMLElement && BLOCK_TAGS.has(node.tagName) +} + +/** + * What an item folds: every block after the one holding its first line. A + * tight item keeps its text inline (after the checkbox, for a task), so all + * of its blocks fold; a loose item wraps its text in a first `

`, which + * stays. + */ +export function listItemFoldTargets(li: HTMLElement): HTMLElement[] { + const blocks = Array.from(li.children).filter(isBlock) + if (blocks.length === 0) return [] + let inlineFirst = false + for (const node of Array.from(li.childNodes)) { + if (node === blocks[0]) break + if (node instanceof HTMLElement && node.classList.contains(ARROW_CLASS)) continue + if (node.nodeType === Node.TEXT_NODE && !(node.textContent ?? '').trim()) continue + inlineFirst = true + break + } + return inlineFirst || blocks[0].tagName !== 'P' ? blocks : blocks.slice(1) +} + +function setListItemFolded(li: HTMLElement, folded: boolean): void { + for (const el of listItemFoldTargets(li)) el.style.display = folded ? 'none' : '' + li.setAttribute(FOLDED_ATTR, folded ? 'true' : 'false') + const arrow = li.querySelector(`:scope > .${ARROW_CLASS}`) + if (!arrow) return + arrow.classList.toggle('is-folded', folded) + arrow.textContent = folded ? '▸' : '▾' + arrow.setAttribute('aria-expanded', String(!folded)) + arrow.setAttribute('aria-label', folded ? 'Expand list item' : 'Collapse list item') +} + +/** `• ` for a bullet, `N. ` for a number. */ +function markerChars(li: HTMLElement): number { + const list = li.parentElement + if (!(list instanceof HTMLOListElement)) return 2 + const index = Array.from(list.children).filter((el) => el.tagName === 'LI').indexOf(li) + const number = (list.start || 1) + Math.max(index, 0) + return String(Math.abs(number)).length + 2 +} + +export function enhancePreviewListFolds(root: HTMLElement): void { + root.querySelectorAll('li').forEach((li) => { + if (li.querySelector(`:scope > .${ARROW_CLASS}`)) return // idempotent + if (listItemFoldTargets(li).length === 0) return + li.classList.add(FOLDABLE_CLASS) + li.setAttribute(FOLDED_ATTR, 'false') + // The marker renders outside the item, right-aligned, so `10. ` reaches + // further left than `9. `; the CSS seats the arrow by the marker's width + // in characters. Set on every item: a custom property inherits, and a + // bullet nested in a numbered item must not take its parent's width. + li.style.setProperty('--z-list-marker-chars', String(markerChars(li))) + + const arrow = document.createElement('span') + arrow.className = `${ARROW_CLASS} is-open` + arrow.setAttribute('role', 'button') + arrow.setAttribute('aria-label', 'Collapse list item') + arrow.setAttribute('aria-expanded', 'true') + arrow.setAttribute('contenteditable', 'false') + arrow.textContent = '▾' + arrow.addEventListener('click', (event) => { + event.preventDefault() + event.stopPropagation() + setListItemFolded(li, li.getAttribute(FOLDED_ATTR) !== 'true') + }) + li.append(arrow) + }) +} diff --git a/packages/app-core/src/lib/preview-outline-jump.test.ts b/packages/app-core/src/lib/preview-outline-jump.test.ts index 6d3a5724..9e7cac25 100644 --- a/packages/app-core/src/lib/preview-outline-jump.test.ts +++ b/packages/app-core/src/lib/preview-outline-jump.test.ts @@ -14,7 +14,6 @@ import { previewShowsSourceLine, previewVisibleSourceLines, scrollTopForElementRelativeTop, - scrollTopForScrollRatio, shouldSyncPreviewAfterMarkdownSettles, shouldSyncPreviewFromEditorViewport } from './preview-outline-jump' @@ -78,13 +77,6 @@ describe('preview outline jump helpers', () => { expect(previewScrollTopForHeading(preview, heading, 24)).toBe(700) }) - it('maps continuous scroll by ratio for smooth split-pane sync', () => { - expect(scrollTopForScrollRatio(250, 1000, 500, 2000, 1000)).toBe(500) - expect(scrollTopForScrollRatio(1200, 1000, 500, 2000, 1000)).toBe(1000) - expect(scrollTopForScrollRatio(250, 500, 500, 2000, 1000)).toBe(0) - expect(scrollTopForScrollRatio(250, 1000, 500, 1000, 1000)).toBe(0) - }) - it('extends the outline preview sync lock without shortening an active lock', () => { expect(nextOutlinePreviewSyncLockUntil(100, 450, 0)).toBe(550) expect(nextOutlinePreviewSyncLockUntil(125, 100, 550)).toBe(550) diff --git a/packages/app-core/src/lib/preview-outline-jump.ts b/packages/app-core/src/lib/preview-outline-jump.ts index e0077637..63235237 100644 --- a/packages/app-core/src/lib/preview-outline-jump.ts +++ b/packages/app-core/src/lib/preview-outline-jump.ts @@ -218,21 +218,6 @@ export function scrollTopForElementRelativeTop( return Math.max(0, Math.min(maxTop, nextTop)) } -export function scrollTopForScrollRatio( - sourceScrollTop: number, - sourceScrollHeight: number, - sourceClientHeight: number, - targetScrollHeight: number, - targetClientHeight: number -): number { - const sourceMax = sourceScrollHeight - sourceClientHeight - const targetMax = targetScrollHeight - targetClientHeight - if (targetMax <= 0) return 0 - if (sourceMax <= 0) return 0 - const ratio = Math.max(0, Math.min(1, sourceScrollTop / sourceMax)) - return ratio * targetMax -} - export function nextOutlinePreviewSyncLockUntil( nowMs: number, durationMs: number, diff --git a/packages/app-core/src/lib/rename-vault.ts b/packages/app-core/src/lib/rename-vault.ts index 0d7666c2..26cf85f9 100644 --- a/packages/app-core/src/lib/rename-vault.ts +++ b/packages/app-core/src/lib/rename-vault.ts @@ -6,14 +6,11 @@ * prompt; Settings has a field instead. */ import type { VaultInfo, WorkspaceMode } from '@shared/ipc' +import { vaultFolderName } from '@shared/vault-display-name' import { promptApp } from './prompt-requests' import { useStore } from '../store' -/** The folder a vault lives in, as it is shown beside a display name: the - * root's last segment, POSIX or Windows. */ -export function vaultFolderName(root: string): string { - return root.split(/[\\/]/).filter(Boolean).pop() ?? root -} +export { vaultFolderName } /** * Whether the open vault can take a display name: a local vault that is not @@ -34,7 +31,7 @@ export async function renameVaultWithPrompt(): Promise { const state = useStore.getState() const vault = state.vault if (!vault || !canRenameVault(state)) return false - const folder = vaultFolderName(vault.root) + const folder = vaultFolderName(vault) const next = await promptApp({ title: 'Rename vault', description: `Shown in the sidebar and the vault switcher. The folder stays ${folder} on disk; leave the name empty to go back to it.`, diff --git a/packages/app-core/src/lib/split-scroll-sync.test.ts b/packages/app-core/src/lib/split-scroll-sync.test.ts new file mode 100644 index 00000000..a850c861 --- /dev/null +++ b/packages/app-core/src/lib/split-scroll-sync.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { mapSplitScrollTop, splitScrollAnchors, splitScrollEchoes } from './split-scroll-sync' + +// Blocks as [editorTop, previewTop] pairs, in document order. +function geometry(blocks: Array<[number, number]>, editorMax: number, previewMax: number) { + return splitScrollAnchors( + blocks.map(([editor, preview]) => ({ editor, preview })), + editorMax, + previewMax + ) +} + +describe('mapSplitScrollTop (#859)', () => { + it('falls back to the scroll ratio when no block is anchored', () => { + const g = geometry([], 1000, 3000) + expect(mapSplitScrollTop(g, 'editor', 250)).toBe(750) + expect(mapSplitScrollTop(g, 'preview', 750)).toBe(250) + }) + + it('keeps the top of one pane at the top of the other, padding included', () => { + // The H1 starts 16px down in the preview: the old sync scrolled that + // padding away and pinned the heading against the pane's top edge. + const g = geometry([[0, 16], [120, 300]], 2000, 6000) + expect(mapSplitScrollTop(g, 'editor', 0)).toBe(0) + expect(mapSplitScrollTop(g, 'preview', 0)).toBe(0) + }) + + it('lands a block on its counterpart and interpolates inside it', () => { + // A callout 100px tall in the editor renders 1000px tall in the preview. + const g = geometry([[0, 0], [400, 900], [500, 1900], [800, 2200]], 3000, 9000) + expect(mapSplitScrollTop(g, 'editor', 400)).toBe(900) + expect(mapSplitScrollTop(g, 'editor', 450)).toBe(1400) + expect(mapSplitScrollTop(g, 'preview', 1400)).toBe(450) + expect(mapSplitScrollTop(g, 'preview', 2050)).toBe(650) + }) + + it('returns where it started after a round trip, anywhere in the note', () => { + const g = geometry([[0, 16], [90, 400], [300, 420], [310, 1700], [900, 2000], [1500, 5200]], 2400, 7000) + for (let top = 0; top <= 2400; top += 37) { + const there = mapSplitScrollTop(g, 'editor', top) + expect(mapSplitScrollTop(g, 'preview', there)).toBeCloseTo(top, 6) + } + }) + + it('skips blocks the editor folds into one widget', () => { + // Three blocks inside one rendered callout widget share its top in the + // editor; the segment runs to the first block past it in both panes. + const g = geometry([[100, 200], [100, 500], [100, 800], [300, 1200]], 1000, 4000) + expect(mapSplitScrollTop(g, 'editor', 200)).toBe(700) + expect(mapSplitScrollTop(g, 'preview', 700)).toBe(200) + }) + + it('maps the end of one pane to the end of the other', () => { + // The last blocks begin inside the final screenful, where neither pane's + // top can reach them. + const g = geometry([[0, 0], [700, 2000], [950, 2900], [990, 3100]], 900, 2800) + expect(mapSplitScrollTop(g, 'editor', 900)).toBe(2800) + expect(mapSplitScrollTop(g, 'preview', 2800)).toBe(900) + expect(mapSplitScrollTop(g, 'editor', 800)).toBe(2400) + }) + + it.each([ + ['editor', 900, 2000], + ['preview', 700, 2800], + ['both panes', 900, 2800] + ] as const)('keeps both ends reachable when a block starts exactly at the end of %s', (_, editor, preview) => { + const g = geometry([[0, 16], [100, 200], [editor, preview]], 900, 2800) + expect(mapSplitScrollTop(g, 'editor', 900)).toBe(2800) + expect(mapSplitScrollTop(g, 'preview', 2800)).toBe(900) + expect(mapSplitScrollTop(g, 'editor', 899)).toBeLessThan(2800) + expect(mapSplitScrollTop(g, 'preview', 2799)).toBeLessThan(900) + for (const top of [0, 250, 450, 700, 899, 900]) { + const there = mapSplitScrollTop(g, 'editor', top) + expect(mapSplitScrollTop(g, 'preview', there)).toBeCloseTo(top, 6) + } + }) + + it('clamps to the target pane and handles a pane that cannot scroll', () => { + const g = geometry([[0, 0], [100, 400]], 500, 2000) + expect(mapSplitScrollTop(g, 'editor', -50)).toBe(0) + expect(mapSplitScrollTop(g, 'editor', 9999)).toBe(2000) + expect(mapSplitScrollTop(geometry([[0, 0]], 0, 2000), 'editor', 0)).toBe(0) + expect(mapSplitScrollTop(geometry([[0, 0]], 500, 0), 'editor', 300)).toBe(0) + }) +}) + +describe('splitScrollEchoes (#859)', () => { + it('ignores a pane while the other one leads, and only then', () => { + const lead = { pane: 'preview' as const, until: 1000 } + expect(splitScrollEchoes(lead, 'editor', 999)).toBe(true) + expect(splitScrollEchoes(lead, 'preview', 999)).toBe(false) + expect(splitScrollEchoes(lead, 'editor', 1000)).toBe(false) + expect(splitScrollEchoes(null, 'editor', 0)).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/split-scroll-sync.ts b/packages/app-core/src/lib/split-scroll-sync.ts new file mode 100644 index 00000000..2a8d99c0 --- /dev/null +++ b/packages/app-core/src/lib/split-scroll-sync.ts @@ -0,0 +1,99 @@ +/** + * Split mode keeps the editor and the reading view on the same content. Every + * top-level block of the rendered note carries the source line it starts on + * (`data-source-line`), which gives one pair of scroll positions per block: + * where that content begins in the editor and where it begins in the preview. + * Between two such anchors a scroll position maps by the fraction of the way + * it is from one to the next, so a formula or callout that renders ten times + * taller than its source stretches its own stretch of the other pane instead + * of shifting everything after it. The document's start and both panes' ends + * are anchors too: the top of one pane is the top of the other, and the end is + * the end. + * + * Both directions walk the same anchors, so preview-to-editor is the exact + * inverse of editor-to-preview. The preview used to follow the editor by line + * while the editor followed the preview by raw scroll ratio; with KaTeX and + * callouts the two disagreed by whole sections, and every round trip dragged + * the reader somewhere else. (#859) + */ +export type SplitScrollPane = 'editor' | 'preview' + +export interface SplitScrollAnchor { + editor: number + preview: number +} + +/** + * The anchors both directions share: the document start, then each block + * that begins further down in BOTH panes than the last one kept (blocks the + * editor folds into one widget, or that render with no height, share a + * position; the first of them stands for all), then the two panes' ends. + * A block at either scroll limit belongs to the paired end anchor; keeping + * it would leave the other pane short of its end. Blocks beyond a limit + * are unreachable as scroll positions and end the list there too. + */ +export function splitScrollAnchors( + blocks: Iterable, + editorMax: number, + previewMax: number +): SplitScrollAnchor[] { + const anchors: SplitScrollAnchor[] = [{ editor: 0, preview: 0 }] + for (const block of blocks) { + if (block.editor >= editorMax || block.preview >= previewMax) break + const last = anchors[anchors.length - 1] + if (block.editor > last.editor && block.preview > last.preview) anchors.push(block) + } + const last = anchors[anchors.length - 1] + if (editorMax > last.editor && previewMax > last.preview) { + anchors.push({ editor: editorMax, preview: previewMax }) + } + return anchors +} + +/** The scroll position in the other pane that shows what `top` shows in `from`. */ +export function mapSplitScrollTop( + anchors: readonly SplitScrollAnchor[], + from: SplitScrollPane, + top: number +): number { + const to: SplitScrollPane = from === 'editor' ? 'preview' : 'editor' + const last = anchors.length - 1 + if (last < 1) return 0 + if (top <= anchors[0][from]) return anchors[0][to] + if (top >= anchors[last][from]) return anchors[last][to] + // The segment holding `top`: its start is the last anchor at or above it. + let lo = 0 + let hi = last + while (hi - lo > 1) { + const mid = (lo + hi) >> 1 + if (anchors[mid][from] <= top) lo = mid + else hi = mid + } + const start = anchors[lo] + const end = anchors[hi] + const fraction = (top - start[from]) / (end[from] - start[from]) + return start[to] + fraction * (end[to] - start[to]) +} + +/** How long the pane the user drives keeps the lead after its last scroll. */ +export const SPLIT_SCROLL_LEAD_MS = 250 + +export interface SplitScrollLead { + pane: SplitScrollPane + until: number +} + +/** + * True while the other pane leads: a scroll here is then the echo of our own + * sync (or the editor re-measuring the region the sync revealed), never input + * to send back. A one-shot "ignore the next event" flag missed the second echo + * CodeMirror fires while it measures live-preview widgets, and that echo + * snapped the preview back to where the editor had lagged. (#859) + */ +export function splitScrollEchoes( + lead: SplitScrollLead | null, + pane: SplitScrollPane, + now: number +): boolean { + return lead != null && lead.pane !== pane && now < lead.until +} diff --git a/packages/app-core/src/rename-vault.test.ts b/packages/app-core/src/rename-vault.test.ts index 1a6fa410..040d6043 100644 --- a/packages/app-core/src/rename-vault.test.ts +++ b/packages/app-core/src/rename-vault.test.ts @@ -133,6 +133,26 @@ describe('store.renameVault', () => { expect(useStore.getState().closedTabStack).toHaveLength(0) }) + it('a host whose root is a label keeps the folder name it gave through every settings save', async () => { + // The phone shells hand over a VaultInfo whose root reads + // "On this device › ZenNotes › docs" and whose folderName is "docs". + // Before folderName existed, the first settings save (a favorite toggled + // is enough) renamed the vault to the whole label, because the fallback + // took the root's last path segment and a label has none. + const useStore = await loadStore() + const phone = { root: 'On this device › ZenNotes › docs', name: 'docs', folderName: 'docs' } + useStore.setState({ vault: phone } as never) + + await useStore.getState().setVaultSettings({ ...DEFAULT_VAULT_SETTINGS, favorites: ['inbox/a.md'] }) + expect(useStore.getState().vault?.name).toBe('docs') + + expect(await useStore.getState().renameVault('Acme API docs')).toBe(true) + expect(useStore.getState().vault?.name).toBe('Acme API docs') + + expect(await useStore.getState().renameVault('')).toBe(true) + expect(useStore.getState().vault?.name).toBe('docs') + }) + it('an external vault.json change renames the open vault too', async () => { const useStore = await loadStore() ;(window.zen.getVaultSettings as ReturnType).mockResolvedValue({ diff --git a/packages/app-core/src/shell.test.ts b/packages/app-core/src/shell.test.ts index d756119b..1882af7e 100644 --- a/packages/app-core/src/shell.test.ts +++ b/packages/app-core/src/shell.test.ts @@ -158,6 +158,23 @@ describe('public shell snapshots', () => { expect(s.getShellSnapshot().vault).toBeNull() }) + it('carries the folder name a host gave, and notices when it changes', async () => { + // The phone shells describe a vault by a label root and name the folder + // separately (#692); a snapshot that dropped folderName made the + // switcher unable to tell which folder is open. + const s = await setup() + s.useStore.setState({ + vault: { root: 'On this device › ZenNotes › docs', name: 'Acme API docs', folderName: 'docs' } + }) + const first = s.getShellSnapshot() + expect(first.vault).toMatchObject({ name: 'Acme API docs', folderName: 'docs' }) + s.useStore.setState({ + vault: { root: 'On this device › ZenNotes › docs', name: 'Acme API docs', folderName: 'docs2' } + }) + expect(s.getShellSnapshot().vault?.folderName).toBe('docs2') + expect(s.getShellSnapshot().vault).not.toBe(first.vault) + }) + it('observes the current state after an earlier subscriber corrects a transition', async () => { const s = await setup() disposers.push( diff --git a/packages/app-core/src/shell.ts b/packages/app-core/src/shell.ts index 450a5571..9ed3c4d1 100644 --- a/packages/app-core/src/shell.ts +++ b/packages/app-core/src/shell.ts @@ -94,11 +94,16 @@ export function getShellSnapshot(): ShellSnapshot { else if ( vault?.root !== state.vault.root || vault.name !== state.vault.name || + vault.folderName !== state.vault.folderName || vault.temporary !== state.vault.temporary ) { + // Every field of VaultInfo, by name: the snapshot is typed as one, and a + // host that reads a field this list forgot gets undefined with no error + // (folderName, for the phone shells, was missing here in 2.56.1). vault = Object.freeze({ root: state.vault.root, name: state.vault.name, + folderName: state.vault.folderName, temporary: state.vault.temporary }) } diff --git a/packages/app-core/src/store-note-integrity.test.ts b/packages/app-core/src/store-note-integrity.test.ts index 7b2aa106..c79a0ec9 100644 --- a/packages/app-core/src/store-note-integrity.test.ts +++ b/packages/app-core/src/store-note-integrity.test.ts @@ -83,10 +83,17 @@ function installZen(): void { }) } -async function loadStore() { +// The store module a test loaded. Each test gets a fresh one, but a timer the +// old one armed keeps running with real timers and calls whatever window.zen +// is current when it fires, so afterEach settles the old store before the +// next test installs its own bridge. +let loaded: typeof import('./store') | null = null + +async function loadStore(): Promise { vi.resetModules() localStorage.clear() - return import('./store') + loaded = await import('./store') + return loaded } async function flush(): Promise { @@ -97,8 +104,27 @@ beforeEach(() => { vi.restoreAllMocks() installZen() }) -afterEach(() => { +afterEach(async () => { + // Real timers first: a fake-timer test's pending saves are dropped with the + // fake clock, and the race below needs a real setTimeout. vi.useRealTimers() + // Typing arms the store's 350 ms debounced save. A test that ends with a + // dirty buffer would let that timer fire into a later test and push a write + // nobody there made into writeCalls (the #852 dirty-buffer test did exactly + // that under turbo's parallel load). persistNote clears the timer before + // its first await, so calling it is enough; the write it starts goes to + // this test's bridge, which may be gated forever, hence the race. + const store = loaded + loaded = null + if (!store) return + const state = store.useStore.getState() + const dirty = Object.entries(state.noteDirty) + .filter(([, isDirty]) => isDirty) + .map(([path]) => path) + await Promise.race([ + Promise.allSettled(dirty.map((path) => state.persistNote(path))), + new Promise((resolve) => setTimeout(resolve, 50)) + ]) }) function seedRootVault(useStore: { setState: (s: Record) => void }): void { @@ -259,9 +285,7 @@ describe('#852: a note body taken from disk moves its disk revision', () => { expect(useStore.getState().noteContents[a]?.body).toBe('unsaved typing') expect(noteDiskRevision(a)).toBe(0) - // The typing above armed the debounced save; settle it here rather than - // let it fire into a later test. The user's own write reaching disk is - // not a disk change either. + // The user's own write reaching disk is not a disk change either. await useStore.getState().persistNote(a) expect(vault.get(a)).toBe('unsaved typing') expect(noteDiskRevision(a)).toBe(0) diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 803d61b5..e8a5c0c6 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -114,6 +114,26 @@ afterEach(() => { lastLoadedStore = null }) +type LoadedStore = Awaited>['useStore'] + +/** Run `work` on a fake clock and let `ms` of it pass: the unlink of an open + * note waits for its file to come back before the tab closes (#863). */ +async function onFakeClock(ms: number, work: () => Promise): Promise { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const done = work() + await vi.advanceTimersByTimeAsync(ms) + await done + } finally { + vi.useRealTimers() + } +} + +const applyUnlink = (useStore: LoadedStore, path: string): Promise => + onFakeClock(2000, () => useStore.getState().applyChange({ kind: 'unlink', path, folder: 'inbox' })) + +const missing = (path: string): Error => Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + async function flushAsyncWork(): Promise { await new Promise((resolve) => window.setTimeout(resolve, 0)) } @@ -2510,7 +2530,7 @@ describe('renaming the open note while the watcher reports the move (#713)', () // inotify: the move is an unlink of the old path and an add of the new // one, both delivered before the host has answered the rename. - await useStore.getState().applyChange({ kind: 'unlink', path: OLD, folder: 'inbox' }) + await applyUnlink(useStore, OLD) await useStore.getState().applyChange({ kind: 'add', path: NEW, folder: 'inbox' }) await useStore.getState().refreshNotes() expect(useStore.getState().selectedPath).toBe(OLD) @@ -2534,7 +2554,8 @@ describe('renaming the open note while the watcher reports the move (#713)', () useStore.setState({ notes: [oldNote] }) await useStore.getState().selectNote(OLD) - await useStore.getState().applyChange({ kind: 'unlink', path: OLD, folder: 'inbox' }) + vi.mocked(window.zen.readNote).mockRejectedValue(missing(OLD)) + await applyUnlink(useStore, OLD) expect(JSON.stringify(useStore.getState().paneLayout)).not.toContain(OLD) expect(useStore.getState().noteContents[OLD]).toBeUndefined() @@ -2552,11 +2573,115 @@ describe('renaming the open note while the watcher reports the move (#713)', () await useStore.getState().renameActive('Taken') expect(useStore.getState().selectedPath).toBe(OLD) - await useStore.getState().applyChange({ kind: 'unlink', path: OLD, folder: 'inbox' }) + vi.mocked(window.zen.readNote).mockRejectedValue(missing(OLD)) + await applyUnlink(useStore, OLD) expect(JSON.stringify(useStore.getState().paneLayout)).not.toContain(OLD) }) }) +describe('an open note replaced from outside the app (#863)', () => { + const PLAN = 'inbox/Plan.md' + const OTHER = 'inbox/Other.md' + + // The vault as files: another program deletes and rewrites them, and the + // app's own saves land here too. + function installDisk(files: Record): Map { + const disk = new Map(Object.entries(files)) + installZen({ + listNotes: vi.fn().mockImplementation(async () => + [...disk].map(([path, body]) => makeNote(body, path)) + ), + readNote: vi.fn().mockImplementation(async (path: string) => { + const body = disk.get(path) + if (body === undefined) throw missing(path) + return makeNote(body, path) + }), + writeNote: vi.fn().mockImplementation(async (path: string, body: string) => { + disk.set(path, body) + }) + }) + return disk + } + + async function openNotes(disk: Map, ...paths: string[]): Promise { + const { useStore } = await loadStore() + useStore.setState({ notes: [...disk].map(([path, body]) => makeNote(body, path)) }) + for (const path of paths) await useStore.getState().selectNote(path) + return useStore + } + + const tabs = (useStore: LoadedStore): string => JSON.stringify(useStore.getState().paneLayout) + + it('keeps a note open when its file is written back, and takes the new text', async () => { + const disk = installDisk({ [PLAN]: '# Plan\n\nfirst' }) + const useStore = await openNotes(disk, PLAN) + + // git checkout, some sync clients: delete, then write the file back later + // than chokidar's 100 ms, so the watcher reports an unlink and an add. + disk.delete(PLAN) + await onFakeClock(2000, async () => { + const unlinked = useStore.getState().applyChange({ kind: 'unlink', path: PLAN, folder: 'inbox' }) + await vi.advanceTimersByTimeAsync(400) + disk.set(PLAN, '# Plan\n\nrewritten') + await useStore.getState().applyChange({ kind: 'add', path: PLAN, folder: 'inbox' }) + await unlinked + }) + + expect(useStore.getState().selectedPath).toBe(PLAN) + expect(useStore.getState().noteContents[PLAN]?.body).toBe('# Plan\n\nrewritten') + }) + + it('never closes over unsaved edits: the pending save writes the note back', async () => { + const disk = installDisk({ [PLAN]: '# Plan\n\nfirst' }) + const useStore = await openNotes(disk, PLAN) + + await onFakeClock(2000, async () => { + useStore.getState().updateNoteBody(PLAN, '# Plan\n\nfirst, and typed') + disk.delete(PLAN) + await useStore.getState().applyChange({ kind: 'unlink', path: PLAN, folder: 'inbox' }) + }) + + expect(useStore.getState().selectedPath).toBe(PLAN) + expect(useStore.getState().noteContents[PLAN]?.body).toBe('# Plan\n\nfirst, and typed') + expect(disk.get(PLAN)).toBe('# Plan\n\nfirst, and typed') + }) + + it('closes a clean note whose file stays gone, once the wait is over', async () => { + const disk = installDisk({ [PLAN]: '# Plan\n\nfirst' }) + const useStore = await openNotes(disk, PLAN) + + disk.delete(PLAN) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const unlinked = useStore.getState().applyChange({ kind: 'unlink', path: PLAN, folder: 'inbox' }) + await vi.advanceTimersByTimeAsync(1000) + expect(tabs(useStore)).toContain(PLAN) + await vi.advanceTimersByTimeAsync(1000) + await unlinked + } finally { + vi.useRealTimers() + } + expect(tabs(useStore)).not.toContain(PLAN) + expect(useStore.getState().noteContents[PLAN]).toBeUndefined() + }) + + it('keeps a background tab with unsaved edits through a listing that missed its file', async () => { + const disk = installDisk({ [PLAN]: '# Plan\n\nfirst', [OTHER]: '# Other\n' }) + const useStore = await openNotes(disk, PLAN, OTHER) + expect(useStore.getState().selectedPath).toBe(OTHER) + + await onFakeClock(0, async () => { + useStore.getState().updateNoteBody(PLAN, '# Plan\n\nfirst, and typed') + disk.delete(PLAN) + await useStore.getState().refreshNotes() + }) + + expect(tabs(useStore)).toContain(PLAN) + expect(useStore.getState().noteContents[PLAN]?.body).toBe('# Plan\n\nfirst, and typed') + expect(useStore.getState().noteDirty[PLAN]).toBe(true) + }) +}) + describe('custom templates on the change feed (#723)', () => { it('re-lists templates on a templates-scope event without touching the note tree', async () => { const listTemplates = vi diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 9c47f145..1d453a68 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -11,7 +11,7 @@ import { type EditorCursorPosition } from './lib/editor-cursor-position' import { DEFAULT_VAULT_SETTINGS } from '@shared/ipc' -import { normalizeVaultDisplayName, resolveVaultName } from '@shared/vault-display-name' +import { normalizeVaultDisplayName, resolveVaultName, vaultFolderName } from '@shared/vault-display-name' import { DEFAULT_HARPER_DIALECT, isHarperDialect, @@ -3916,6 +3916,16 @@ export function noteDiskRevision(path: string): number { */ const renamesInFlight = new Set() +/** + * How long an open note's file may be gone before its tab closes. A writer + * that replaces a file by deleting it and writing it back (git, some sync + * clients, editors that save that way) reaches the watcher as an unlink and a + * later add, and chokidar merges the pair only inside 100 ms. Closing on the + * unlink dropped the reader on Home and threw away the text they had not + * saved yet along with the tab (#863). + */ +const UNLINK_SETTLE_MS = 1500 + // --- CSV database debounced persistence + echo suppression --- const DATABASE_SAVE_DEBOUNCE_MS = 400 const databaseSaveTimers = new Map>() @@ -4821,8 +4831,7 @@ function withoutNoteInWorkspace(s: Store, path: string): Partial { function applyVaultNameFromSettings(settings: VaultSettings): void { const vault = useStore.getState().vault if (!vault) return - const folder = vault.root.split(/[\\/]/).filter(Boolean).pop() ?? vault.root - const name = resolveVaultName(settings.displayName, folder) + const name = resolveVaultName(settings.displayName, vaultFolderName(vault)) if (name === vault.name) return useStore.setState({ vault: { ...vault, name } }) } @@ -7395,8 +7404,10 @@ export const useStore = create((set, get) => { existingPaths.has(path) || isWorkspaceVirtualTabPath(path) || renamesInFlight.has(path) || - (path === s.selectedPath && - (s.noteContents[path] !== undefined || s.noteDirty[path] === true)) + // Unsaved edits outrank a listing taken while their file was + // briefly gone, in a background tab as in the active one (#863). + s.noteDirty[path] === true || + (path === s.selectedPath && s.noteContents[path] !== undefined) const prunedLayout = rewritePathsInTree(s.paneLayout, (path) => keep(path) ? path : null ) @@ -7586,6 +7597,30 @@ export const useStore = create((set, get) => { } }) } + // An unlink from outside the app (this app's own deletes and moves close + // their tabs themselves) may be the first half of a replace, so the tab + // waits for the file to come back before closing. It never closes over + // unsaved edits: the note's pending save writes the file back instead, + // the same way a stale listing never outranks a dirty buffer (#863). + const settleUnlinkedNote = async (notePath: string): Promise => { + const root = get().vault?.root + await new Promise((resolve) => setTimeout(resolve, UNLINK_SETTLE_MS)) + const stillOpen = (): boolean => { + const s = get() + return ( + s.vault?.root === root && + findLeavesContaining(s.paneLayout, notePath).length > 0 && + !s.noteDirty[notePath] + ) + } + if (!stillOpen()) return + const back = await window.zen.readNote(notePath).then( + () => true, + () => false + ) + if (back || !stillOpen()) return + closeUnlinkedNote(notePath) + } if (ev.scope === 'resync') { // The change feed was interrupted and events were lost; re-pull every // surface the feed keeps fresh instead of trusting the resumed stream. @@ -7774,7 +7809,7 @@ export const useStore = create((set, get) => { if (!open) return if (ev.kind === 'unlink') { - closeUnlinkedNote(ev.path) + await settleUnlinkedNote(ev.path) return } diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index a789a8cc..3e19c754 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -3807,6 +3807,35 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite .prose-zen .callout > p { margin: 0.35em 0 0; } +/* Foldable callouts (#853): `> [!type]-` and `> [!type]+` render as a +

whose is the title, so the whole title row toggles it. + The chevron follows the title, as in the editor. */ +.prose-zen details.callout > summary.callout-title { + display: block; + list-style: none; + cursor: pointer; +} +.prose-zen details.callout > summary.callout-title::-webkit-details-marker { + display: none; +} +.prose-zen details.callout > summary.callout-title::after { + content: "▾"; + display: inline-block; + margin-left: 0.4em; + font-size: 0.8em; + opacity: 0.75; +} +.prose-zen details.callout:not([open]) > summary.callout-title { + margin-bottom: 0; +} +.prose-zen details.callout:not([open]) > summary.callout-title::after { + content: "▸"; +} +.prose-zen details.callout > summary.callout-title:focus-visible { + outline: 2px solid rgb(var(--z-accent) / 0.6); + outline-offset: 2px; + border-radius: 4px; +} /* Callout color groups — the `data-callout` lists mirror the alias→group table in `lib/callout-types.ts` (kept in sync by hand; CSS can't import it). */ .prose-zen .callout[data-callout="note"], @@ -4859,6 +4888,61 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite color: rgb(var(--z-accent)); } +/* List item fold arrow (#848): the widget is a zero-width anchor placed at + * the item's marker, so the arrow sits just left of the bullet, number or + * checkbox at any nesting depth and never shifts the text. The anchor is + * boxed and aligned like .cm-task-checkbox, so the arrow centres on the + * checkbox it sits beside. Revealed like the heading arrow: on line hover, + * on the caret line, and while folded. */ +.cm-list-fold-anchor { + position: relative; + display: inline-block; + width: 0; + height: 1.05em; + vertical-align: text-bottom; +} +/* text-indent: the list line's hanging indent is inherited and would push the + * glyph out of its box by the marker's width (six columns for a task). */ +.cm-list-fold-anchor, +.cm-list-fold-arrow { + text-indent: 0; +} +.cm-list-fold-arrow { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + font-size: 12px; + line-height: 1; + color: theme("colors.ink.400"); + opacity: 0; + cursor: pointer; + user-select: none; + border-radius: 4px; + transition: + opacity 120ms ease, + color 120ms ease, + background-color 120ms ease; +} +.cm-editor .cm-line:hover .cm-list-fold-arrow, +.cm-editor .cm-activeLine .cm-list-fold-arrow, +.cm-list-fold-arrow:hover, +.cm-list-fold-arrow.is-folded { + opacity: 1; +} +.cm-list-fold-arrow:hover { + color: theme("colors.ink.900"); + background: rgb(var(--z-bg-2)); +} +.cm-list-fold-arrow.is-folded { + color: rgb(var(--z-accent)); +} + /* The chip gutter must fit inside .cm-content's own box. The fold arrow * (≤28px + ≤10px gap) was sized to live inside the default 32px padding; * the chip needs another 30px beyond it, which only exists as centered- @@ -4940,6 +5024,67 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite color: rgb(var(--z-accent)); } +/* --- Preview list item fold arrow (#848) --------------------------- */ +/* Just left of the item's marker, as in the editor. A bullet or number + * renders outside the item, right-aligned to it, as `• ` or `N. `; the + * enhancer counts a number's characters, so `10.` steps its arrow further + * left than `9.`. A task's checkbox sits in the item's own padding instead. + * Shown while the item itself is hovered (not one nested under it) and while + * it is folded. */ +@property --z-list-item-em { + syntax: ""; + inherits: true; + initial-value: 16px; +} +@property --z-list-item-ch { + syntax: ""; + inherits: true; + initial-value: 9.6px; +} +.prose-zen li.prose-list-foldable { + position: relative; + /* Registered lengths resolve here, in the item's font: the arrow's own + * smaller font would shrink every offset below along with its glyph. */ + --z-list-item-em: 1em; + --z-list-item-ch: 1ch; +} +.prose-zen .prose-list-fold-arrow { + position: absolute; + left: calc(var(--z-list-marker-chars, 2) * var(--z-list-item-ch) * -1 - 20px); + top: calc(var(--z-list-item-em) * var(--z-editor-line-height, 1.7) / 2 - 8px); + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + font-size: 12px; + line-height: 1; + color: theme("colors.ink.400"); + opacity: 0; + cursor: pointer; + user-select: none; + border-radius: 4px; + transition: + opacity 120ms ease, + color 120ms ease, + background-color 120ms ease; +} +.prose-zen li.task-list-item > .prose-list-fold-arrow { + left: -20px; +} +.prose-zen li.prose-list-foldable:hover:not(:has(li:hover)) > .prose-list-fold-arrow, +.prose-zen .prose-list-fold-arrow:hover, +.prose-zen .prose-list-fold-arrow.is-folded { + opacity: 1; +} +.prose-zen .prose-list-fold-arrow:hover { + color: theme("colors.ink.900"); + background: rgb(var(--z-bg-2)); +} +.prose-zen .prose-list-fold-arrow.is-folded { + color: rgb(var(--z-accent)); +} + /* ===================================================================== WYSIWYG live-preview rendering — ported from PR #185 (author: songgnqing). Rendered tables, blockquote bars, list bullets, horizontal rules, @@ -5462,6 +5607,22 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite font-weight: 600; color: rgb(var(--callout-color)); } +/* The fold chevron after a foldable callout's title (#853). */ +.cm-wysiwyg .cm-editor .cm-callout-fold { + display: inline-block; + margin-left: 0.4em; + padding: 0 0.25em; + font-size: 0.8em; + color: rgb(var(--callout-color)); + opacity: 0.75; + cursor: pointer; + user-select: none; + border-radius: 4px; +} +.cm-wysiwyg .cm-editor .cm-callout-fold:hover { + opacity: 1; + background: rgb(var(--callout-color) / 0.14); +} /* A list line inside a callout/blockquote keeps its hanging indent, but it has to sit on TOP of the box's own left padding. Otherwise the list's negative text-indent (see .cm-markdown-list-line) drags the bullet out past the box's diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 1792b755..09c77017 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.56.0", + "version": "2.57.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index b11ee679..82e19f4e 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -761,6 +761,16 @@ export interface PastedImageInput { export interface VaultInfo { root: string name: string + /** + * The folder's own name on disk, for a host whose `root` is not a path + * (the phone shells describe a vault as "On this device › ZenNotes › docs"). + * The display name (#692) falls back to this when it is cleared, and the + * Vault name field shows it as the placeholder; without it the app takes + * the root's last path segment, which is right for every host whose root + * is a path (desktop, the web client, a remote workspace) and wrong for + * one whose root is a label. + */ + folderName?: string /** True when this is a temporary folder session (a folder dropped on the app * to read, not opened as a vault): edits save to the files in place, but no * ZenNotes state is written into the folder and it isn't remembered. The diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index aa59a248..65e83d70 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.56.0", + "version": "2.57.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-domain/src/demo-tour-data.ts b/packages/shared-domain/src/demo-tour-data.ts index 039aad83..d2490d69 100644 --- a/packages/shared-domain/src/demo-tour-data.ts +++ b/packages/shared-domain/src/demo-tour-data.ts @@ -34,7 +34,7 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ }, { path: "inbox/demo/06 — Callouts and Footnotes.md", - body: "# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n" + body: "# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\nPut `-` right after the type to start a callout collapsed, or `+` to start it expanded, then click its title to open or close it.\n\n> [!example]- Foldable callout\n> Handy for screenshots, logs, and config excerpts that should stay out of the way until you need them.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n" }, { path: "inbox/demo/07 — Wiki Links and Tags.md", @@ -46,7 +46,7 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ }, { path: "inbox/demo/09 — Vim Cheat Sheet.md", - body: "# Vim cheat sheet for ZenNotes\n\nZenNotes ships with Vim mode on by default. The editor uses CodeMirror Vim bindings, and the app adds its own keyboard-first flows around panes, panels, search, and built-in views.\n\n## Global shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `⌘P` | Search notes |\n| `⇧⌘P` | Open command palette |\n| `⇧⌘N` | New Quick Note |\n| `⌘,` | Open Settings |\n| `⌘1` | Toggle sidebar |\n| `⌘2` | Toggle connections |\n| `⌘3` | Toggle outline panel |\n| `⌘.` | Toggle Zen mode |\n| `⌘W` | Close active tab or built-in view |\n| `⌥Z` | Toggle word wrap |\n\nIf you explicitly turn Vim mode off, `⌘F` or `Ctrl+F` becomes an extra direct note-search shortcut.\n\n## Pane and panel motion\n\n| Keys | Action |\n| --- | --- |\n| `Ctrl-w h` / `j` / `k` / `l` | Move focus between sidebar, note list, editor panes, outline, and connections |\n| `Ctrl-w v` | Split right |\n| `Ctrl-w s` | Split down |\n| `Ctrl-o` | Jump back in note history |\n| `Ctrl-i` | Jump forward in note history |\n\n## Leader (`Space`) shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `Space o` | Open buffers |\n| `Space f` | Search notes |\n| `Space s t` | Search vault text |\n| `Space e` | Toggle sidebar |\n| `Space p` | Open note outline |\n| `Space l f` | Format the active note |\n| `Space`, then pause | Show leader hints when enabled |\n\nLeader hints can be **timed** or **sticky** in Settings. Sticky mode stays open until you press `Space` again or `Esc`.\n\n## Folding\n\n| Keys | Action |\n| --- | --- |\n| `zc` | Fold the heading at the cursor |\n| `zo` | Unfold the heading at the cursor |\n| `zM` | Fold all headings |\n| `zR` | Unfold all headings |\n\n## Links and hint mode\n\n| Keys | Action |\n| --- | --- |\n| `gd` | Follow wikilink, markdown link, or open/create note under cursor |\n| `f` | Hint mode for clickable targets when not in insert mode |\n\n## Sidebar, list, and built-in views\n\nWhen focus is in the sidebar, note list, Tasks, Tags, Archive, Trash, or Quick Notes tab:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Move selection |\n| `gg` / `G` | Jump to top / bottom |\n| `Enter` / `l` | Open selected item |\n| `h` | Collapse or move back |\n| `o` | Toggle selected folder |\n| `/` | Filter the current list or view |\n| `m` | Open the context menu for the selected row |\n| `Esc` | Return toward the editor |\n\nView-specific extras:\n\n| Keys | Action |\n| --- | --- |\n| `Space` / `x` | Toggle selected task in **Tasks** |\n| `r` | Restore selected note in **Trash** |\n| `x` / `d` | Permanently delete selected note in **Trash** |\n| `:` | Open the local ex prompt in **Tasks** or **Tags** |\n\n## Preview and connections\n\nWhen focus is in rendered preview or the connections panel:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Scroll line by line |\n| `Ctrl-d` / `Ctrl-u` | Half-page down / up |\n| `gg` / `G` | Jump to top / bottom |\n| `p` | Peek the selected backlink in Connections |\n| `h` / `Esc` | Back out toward the editor |\n\n## Ex commands\n\nType `:` in normal mode:\n\n| Command | Action |\n| --- | --- |\n| `:w` | Save the active note |\n| `:q` | Close the current tab or built-in view |\n| `:wq` | Save and close |\n| `:help` | Open the built-in manual |\n| `:tasks` | Open Tasks |\n| `:tag foo bar` | Open Tags filtered to `foo` and `bar` |\n| `:trash` | Open Trash |\n| `:e path` / `:edit path` | Open or create a note by vault-relative path |\n| `:new [path]` | Create a new note |\n| `:split` / `:vsplit` | Split the current tab down or right |\n| `:bn` / `:bp` | Next / previous tab |\n| `:buffers` / `:ls` | Open the buffer switcher |\n| `:bd` / `:bc` | Close the active tab |\n| `:view edit|split|preview` | Switch the current pane mode |\n| `:editmode` / `:splitmode` / `:previewmode` | Direct aliases for note mode changes |\n| `:zen` / `:zen on` / `:zen off` | Toggle or force Zen mode |\n| `:format` | Format the active note |\n| `:fold` / `:unfold` | Fold or unfold the current heading |\n| `:foldall` / `:unfoldall` | Fold or unfold every heading |\n| `:cmd query` / `:commands` | Run or browse command palette entries |\n| `Tab` on the ex line | Complete commands and supported arguments |\n\n## One more important note\n\nEvery shortcut above can now be remapped in [[12 — Settings and Keymaps]]. Vim mode is the default, but the app no longer hardcodes every sequence forever.\n\n#demo #vim #reference\n" + body: "# Vim cheat sheet for ZenNotes\n\nZenNotes ships with Vim mode on by default. The editor uses CodeMirror Vim bindings, and the app adds its own keyboard-first flows around panes, panels, search, and built-in views.\n\n## Global shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `⌘P` | Search notes |\n| `⇧⌘P` | Open command palette |\n| `⇧⌘N` | New Quick Note |\n| `⌘,` | Open Settings |\n| `⌘1` | Toggle sidebar |\n| `⌘2` | Toggle connections |\n| `⌘3` | Toggle outline panel |\n| `⌘.` | Toggle Zen mode |\n| `⌘W` | Close active tab or built-in view |\n| `⌥Z` | Toggle word wrap |\n\nIf you explicitly turn Vim mode off, `⌘F` or `Ctrl+F` becomes an extra direct note-search shortcut.\n\n## Pane and panel motion\n\n| Keys | Action |\n| --- | --- |\n| `Ctrl-w h` / `j` / `k` / `l` | Move focus between sidebar, note list, editor panes, outline, and connections |\n| `Ctrl-w v` | Split right |\n| `Ctrl-w s` | Split down |\n| `Ctrl-o` | Jump back in note history |\n| `Ctrl-i` | Jump forward in note history |\n\n## Leader (`Space`) shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `Space o` | Open buffers |\n| `Space f` | Search notes |\n| `Space s t` | Search vault text |\n| `Space e` | Toggle sidebar |\n| `Space p` | Open note outline |\n| `Space l f` | Format the active note |\n| `Space`, then pause | Show leader hints when enabled |\n\nLeader hints can be **timed** or **sticky** in Settings. Sticky mode stays open until you press `Space` again or `Esc`.\n\n## Folding\n\n| Keys | Action |\n| --- | --- |\n| `zc` | Fold the heading or list item at the cursor |\n| `zo` | Unfold the heading or list item at the cursor |\n| `zM` | Fold all headings and list items |\n| `zR` | Unfold all headings and list items |\n\n## Links and hint mode\n\n| Keys | Action |\n| --- | --- |\n| `gd` | Follow wikilink, markdown link, or open/create note under cursor |\n| `f` | Hint mode for clickable targets when not in insert mode |\n\n## Sidebar, list, and built-in views\n\nWhen focus is in the sidebar, note list, Tasks, Tags, Archive, Trash, or Quick Notes tab:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Move selection |\n| `gg` / `G` | Jump to top / bottom |\n| `Enter` / `l` | Open selected item |\n| `h` | Collapse or move back |\n| `o` | Toggle selected folder |\n| `/` | Filter the current list or view |\n| `m` | Open the context menu for the selected row |\n| `Esc` | Return toward the editor |\n\nView-specific extras:\n\n| Keys | Action |\n| --- | --- |\n| `Space` / `x` | Toggle selected task in **Tasks** |\n| `r` | Restore selected note in **Trash** |\n| `x` / `d` | Permanently delete selected note in **Trash** |\n| `:` | Open the local ex prompt in **Tasks** or **Tags** |\n\n## Preview and connections\n\nWhen focus is in rendered preview or the connections panel:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Scroll line by line |\n| `Ctrl-d` / `Ctrl-u` | Half-page down / up |\n| `gg` / `G` | Jump to top / bottom |\n| `p` | Peek the selected backlink in Connections |\n| `h` / `Esc` | Back out toward the editor |\n\n## Ex commands\n\nType `:` in normal mode:\n\n| Command | Action |\n| --- | --- |\n| `:w` | Save the active note |\n| `:q` | Close the current tab or built-in view |\n| `:wq` | Save and close |\n| `:help` | Open the built-in manual |\n| `:tasks` | Open Tasks |\n| `:tag foo bar` | Open Tags filtered to `foo` and `bar` |\n| `:trash` | Open Trash |\n| `:e path` / `:edit path` | Open or create a note by vault-relative path |\n| `:new [path]` | Create a new note |\n| `:split` / `:vsplit` | Split the current tab down or right |\n| `:bn` / `:bp` | Next / previous tab |\n| `:buffers` / `:ls` | Open the buffer switcher |\n| `:bd` / `:bc` | Close the active tab |\n| `:view edit|split|preview` | Switch the current pane mode |\n| `:editmode` / `:splitmode` / `:previewmode` | Direct aliases for note mode changes |\n| `:zen` / `:zen on` / `:zen off` | Toggle or force Zen mode |\n| `:format` | Format the active note |\n| `:fold` / `:unfold` | Fold or unfold the current heading or list item |\n| `:foldall` / `:unfoldall` | Fold or unfold every heading and list item |\n| `:cmd query` / `:commands` | Run or browse command palette entries |\n| `Tab` on the ex line | Complete commands and supported arguments |\n\n## One more important note\n\nEvery shortcut above can now be remapped in [[12 — Settings and Keymaps]]. Vim mode is the default, but the app no longer hardcodes every sequence forever.\n\n#demo #vim #reference\n" }, { path: "inbox/demo/10 — Ideas and Tasks.md", diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index 4dfe2bab..184da08d 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -121,10 +121,10 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "vim.hintMode", group: "vim", defaultBinding: "h", title: "Leader: hint mode" }, { id: "vim.goToDefinition", group: "vim", defaultBinding: "g d", title: "Follow link at cursor" }, { id: "vim.createNoteFromLink", group: "vim", defaultBinding: "g D", title: "Create note for link at cursor" }, - { id: "vim.foldCurrent", group: "vim", defaultBinding: "z c", title: "Fold heading at cursor" }, - { id: "vim.unfoldCurrent", group: "vim", defaultBinding: "z o", title: "Unfold heading at cursor" }, - { id: "vim.foldAll", group: "vim", defaultBinding: "z M", title: "Fold all headings" }, - { id: "vim.unfoldAll", group: "vim", defaultBinding: "z R", title: "Unfold all headings" }, + { id: "vim.foldCurrent", group: "vim", defaultBinding: "z c", title: "Fold heading or list item at cursor" }, + { id: "vim.unfoldCurrent", group: "vim", defaultBinding: "z o", title: "Unfold heading or list item at cursor" }, + { id: "vim.foldAll", group: "vim", defaultBinding: "z M", title: "Fold all headings and lists" }, + { id: "vim.unfoldAll", group: "vim", defaultBinding: "z R", title: "Unfold all headings and lists" }, { id: "vim.harperNext", group: "vim", defaultBinding: "] s", title: "Next Harper suggestion" }, { id: "vim.harperPrevious", group: "vim", defaultBinding: "[ s", title: "Previous Harper suggestion" }, { id: "vim.harperSuggest", group: "vim", defaultBinding: "z =", title: "Harper suggestions at cursor" }, @@ -153,8 +153,8 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "tasks.savedFilters", group: "view-actions", defaultBinding: "F", title: "Pick a saved Tasks filter" }, { id: "editor.hopMarkerForward", group: "view-actions", defaultBinding: "Alt+]", defaultBindingMac: "Ctrl+.", title: "Hop past next marker" }, { id: "editor.hopMarkerBackward", group: "view-actions", defaultBinding: "Alt+[", defaultBindingMac: "Ctrl+,", title: "Hop before previous marker" }, - { id: "editor.foldHeading", group: "view-actions", defaultBinding: "Alt+Mod+F", title: "Fold heading" }, - { id: "editor.unfoldHeading", group: "view-actions", defaultBinding: "Alt+Mod+U", title: "Unfold heading" }, + { id: "editor.foldHeading", group: "view-actions", defaultBinding: "Alt+Mod+F", title: "Fold heading or list item" }, + { id: "editor.unfoldHeading", group: "view-actions", defaultBinding: "Alt+Mod+U", title: "Unfold heading or list item" }, { id: "editor.moveLineUp", group: "view-actions", defaultBinding: "Alt+ArrowUp", title: "Move line up" }, { id: "editor.moveLineDown", group: "view-actions", defaultBinding: "Alt+ArrowDown", title: "Move line down" }, { id: "editor.toggleCheckbox", group: "view-actions", defaultBinding: "Mod+L", title: "Toggle checkbox" }, diff --git a/packages/shared-domain/src/vault-display-name.test.ts b/packages/shared-domain/src/vault-display-name.test.ts index 240a4169..c39d2e7d 100644 --- a/packages/shared-domain/src/vault-display-name.test.ts +++ b/packages/shared-domain/src/vault-display-name.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { VAULT_DISPLAY_NAME_MAX_LENGTH, normalizeVaultDisplayName, + vaultFolderName, resolveVaultName } from './vault-display-name' @@ -56,3 +57,21 @@ describe('resolveVaultName', () => { expect(resolveVaultName(undefined, 'docs')).toBe('docs') }) }) + +describe('vaultFolderName', () => { + it('takes the last segment of a path root, POSIX or Windows', () => { + expect(vaultFolderName({ root: '/Users/me/repos/acme/docs' })).toBe('docs') + expect(vaultFolderName({ root: 'C:\\Users\\me\\repos\\acme\\docs\\' })).toBe('docs') + expect(vaultFolderName({ root: '/docs' })).toBe('docs') + }) + + it('prefers the folder name a host gave, which a label root cannot provide', () => { + // The phone shells describe a vault by a label, not a path: without the + // host's folder name the whole label would stand in for it. + const label = 'On this device › ZenNotes › docs' + expect(vaultFolderName({ root: label, folderName: 'docs' })).toBe('docs') + expect(vaultFolderName({ root: label })).toBe(label) + // A host that gives it for a path root is simply believed. + expect(vaultFolderName({ root: '/Users/me/repos/acme/docs', folderName: 'docs' })).toBe('docs') + }) +}) diff --git a/packages/shared-domain/src/vault-display-name.ts b/packages/shared-domain/src/vault-display-name.ts index 5b7779f1..c61d6230 100644 --- a/packages/shared-domain/src/vault-display-name.ts +++ b/packages/shared-domain/src/vault-display-name.ts @@ -55,3 +55,15 @@ function cutAtUtf16Units(text: string, limit: number): string { export function resolveVaultName(displayName: unknown, folderName: string): string { return normalizeVaultDisplayName(displayName) ?? folderName } + +/** + * The folder a vault lives in, as it is shown beside a display name and as + * the name to fall back to: the host's `folderName` when it gave one, else + * the root's last segment, POSIX or Windows. A host whose root is a label + * rather than a path (the phone shells) must give the folder name, or the + * whole label would stand in for it. + */ +export function vaultFolderName(vault: { root: string; folderName?: string }): string { + if (vault.folderName) return vault.folderName + return vault.root.split(/[\\/]/).filter(Boolean).pop() ?? vault.root +} diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 43a22a46..df8b2242 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.56.0", + "version": "2.57.0", "type": "module", "exports": { ".": "./src/index.ts" diff --git a/tooling/scripts/vim-editor-smoke.mjs b/tooling/scripts/vim-editor-smoke.mjs index b79e5b72..c909b5c7 100644 --- a/tooling/scripts/vim-editor-smoke.mjs +++ b/tooling/scripts/vim-editor-smoke.mjs @@ -294,6 +294,7 @@ async function keys(client, sequence) { } async function gotoLine(client, line) { + await ensureEditorFocus(client) await keys(client, String(line).split('')) await press(client, 'G', { shift: true }) await sleep(100) @@ -391,6 +392,26 @@ async function focusEditor(client) { await sleep(100) } +// Something outside the checks (a tooltip, a toast, the window's own +// activation) can take DOM focus from the editor between two checks. With +// focus gone every later key goes nowhere, and each check then fails for the +// same reason, which is what the 2.56.0 gate run showed: four failures in a +// row, all reporting focused:false over an unmoved cursor. Every check block +// starts by putting focus back, so one stolen focus costs at most the block +// it happened in. The ex prompt owns focus on purpose; leave that alone. +async function ensureEditorFocus(client) { + const state = await evaluate( + client, + `(() => ({ + editor: document.activeElement === document.querySelector('.cm-content'), + prompt: document.activeElement instanceof HTMLInputElement + }))()` + ) + if (state?.editor || state?.prompt) return + console.log(' note editor lost focus between checks; refocusing') + await focusEditor(client) +} + async function main() { await prepareBuild() const tempRoot = await mkdtemp(join(tmpdir(), 'zennotes-vim-editor-')) @@ -485,6 +506,7 @@ async function main() { if (!noteReady || !editorReady) throw new Error('Editor did not become ready') await focusEditor(client) + await ensureEditorFocus(client) await keys(client, ['g', 'g', '0']) const lineStart = await editorSnapshot(client) await press(client, '$') @@ -495,6 +517,7 @@ async function main() { JSON.stringify({ lineStart, dollar }) ) + await ensureEditorFocus(client) await keys(client, ['g', 'g', '0']) await press(client, 'A', { shift: true }) await press(client, 'X', { shift: true }) @@ -507,6 +530,7 @@ async function main() { ) await press(client, 'u') + await ensureEditorFocus(client) await keys(client, ['g', 'g', '0', 'j']) await press(client, 'I', { shift: true }) await press(client, 'Y', { shift: true }) @@ -519,6 +543,7 @@ async function main() { ) await press(client, 'u') + await ensureEditorFocus(client) await keys(client, ['g', 'g', '0']) const wrappedCountStart = await editorSnapshot(client) await keys(client, ['8', 'j'])