diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 120038a1f..5b3820503 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.77.0", + "version": "2.78.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", @@ -71,7 +71,7 @@ "jsxgraph": "^1.12.2", "katex": "^0.16.15", "node-tikzjax": "^1.0.5", - "pele": "1.6.0", + "pele": "1.12.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index c20c78e42..c15494479 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -313,6 +313,11 @@ const SCALAR_FIELDS: Partial> = { comment: 'show disclosure arrows in the sidebar' }, contentAlign: { section: 'appearance', tomlKey: 'content_align', comment: 'center | left' }, + noteCoverPlacement: { + section: 'appearance', + tomlKey: 'note_cover_placement', + comment: 'scroll | pinned | hidden: where a note cover image sits' + }, unifiedSidebar: { section: 'appearance', tomlKey: 'unified_sidebar', diff --git a/apps/desktop/src/main/tasklists.test.ts b/apps/desktop/src/main/tasklists.test.ts index d25e1bd32..12f28a5f2 100644 --- a/apps/desktop/src/main/tasklists.test.ts +++ b/apps/desktop/src/main/tasklists.test.ts @@ -318,3 +318,24 @@ describe('setTaskFieldAtIndex', () => { expect(setTaskFieldAtIndex(md, 0, 'Bad Key', 'x')).toBe(md) }) }) + +describe('task index walks on Windows line endings', () => { + it('toggles a CRLF task and keeps its ending', () => { + const md = '# Saved on Windows\r\n\r\n- [ ] one\r\n- [ ] two\r\n' + expect(toggleTaskAtIndex(md, 1, true)).toBe('# Saved on Windows\r\n\r\n- [ ] one\r\n- [x] two\r\n') + }) + + it('counts tasks like the parser when a note mixes endings', () => { + const md = '- [ ] a\n- [ ] b\r\n- [ ] c\n' + expect(toggleTaskAtIndex(md, 1, true)).toBe('- [ ] a\n- [x] b\r\n- [ ] c\n') + }) + + it('takes the right line out of a CRLF note', () => { + const md = '- [ ] a\r\n- [ ] b\r\n' + expect(takeTaskLineAtIndex(md, 1)).toEqual({ line: '- [ ] b\r', body: '- [ ] a\r\n' }) + }) + + it('moves a CRLF task to the right place', () => { + expect(moveTaskLine('- [ ] a\r\n- [ ] b\r\n- [ ] c', 2, 0, 'before')).toBe('- [ ] c\n- [ ] a\r\n- [ ] b\r') + }) +}) diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index 2787ec893..05d2d1ca1 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.11.0", - "commit": "881057116fa131212bf55c2d26f5eae42ef8d72d", + "version": "0.12.2", + "commit": "3f1f638a555450891d378b4b67ab7d9eae2f5226", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.11.0/zn_0.11.0_darwin_arm64.tar.gz", - "sha256": "4b3548577ece83dc311bb67d72d4389a0a48a24c3bd56b9ba28aaa705303d33f" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.12.2/zn_0.12.2_darwin_arm64.tar.gz", + "sha256": "7e7b3be038dfa63dc4a79bc6068cb459462cf81e809e5b070d73bfbe3e7bd5ac" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.11.0/zn_0.11.0_darwin_amd64.tar.gz", - "sha256": "9dd6b9e19c3448ff947c7dd20eec511a6ffb4975616f0a78014c0e6b33047ddb" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.12.2/zn_0.12.2_darwin_amd64.tar.gz", + "sha256": "091f8b2a2f5ceb8f03acfd90f4337b085551b36058b7326b9ee64d4b1a330a12" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.11.0/zn_0.11.0_linux_arm64.tar.gz", - "sha256": "132eb7755693cea9911783c2097b6a18964e18658a95d99d93353bc1e56ee6e8" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.12.2/zn_0.12.2_linux_arm64.tar.gz", + "sha256": "882ec3fb8f988069c2208412e3ce72bfe8be8dfff461c0ec503f5734c7417786" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.11.0/zn_0.11.0_linux_amd64.tar.gz", - "sha256": "44fd68b60dd82946a210dc715bea4d8d28363870ef7fcc335522e0a3e586d2bd" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.12.2/zn_0.12.2_linux_amd64.tar.gz", + "sha256": "2617004481aee2e4f9677cc362d76ed9c0c414e6ec7858fa34db3d09156a33fe" } } } diff --git a/apps/quicklook/README.md b/apps/quicklook/README.md index fe54c6634..3b3eb35aa 100644 --- a/apps/quicklook/README.md +++ b/apps/quicklook/README.md @@ -7,10 +7,10 @@ Open in ZenNotes button. Three pieces ship inside `ZenNotes.app/Contents/PlugIns and fonts from `~/.config/zennotes/config.toml`, read-only. Typst math and the plot renderers stay out (KaTeX typesets math), as do the Excalidraw fonts. - `macos/Extension/`: `ZenNotesQuickLook.appex`, the sandboxed Quick Look - extension. It serves the page and the files the note embeds over its own - `zenql://` scheme, behind a Content-Security-Policy that allows nothing else, - so a preview never goes online. WebKit's helper processes still need the - network-client entitlement to start at all. + extension. It serves the page and the files the note embeds (its cover image + included) over its own `zenql://` scheme, behind a Content-Security-Policy + that allows nothing else, so a preview never goes online. WebKit's helper + processes still need the network-client entitlement to start at all. - `macos/Opener/`: `ZenNotesQuickLookOpener.xpc`, inside the extension. Quick Look's sandbox lets a preview launch nothing, so this unsandboxed service opens the note in the ZenNotes that contains it (the same route as a Finder diff --git a/apps/quicklook/macos/Extension/NotePreview.swift b/apps/quicklook/macos/Extension/NotePreview.swift index 8e7483fce..459d2c1cd 100644 --- a/apps/quicklook/macos/Extension/NotePreview.swift +++ b/apps/quicklook/macos/Extension/NotePreview.swift @@ -22,6 +22,8 @@ struct NotePreview { private let viewer: URL private let page: Data private let assets: [URL] + private let noteDirectory: URL + private let vaultRoot: URL? init(fileURL: URL, viewer: URL) throws { self.fileURL = fileURL @@ -37,6 +39,8 @@ struct NotePreview { let noteDirectory = fileURL.deletingLastPathComponent().standardizedFileURL let vaultRoot = Self.vaultRoot(above: noteDirectory) + self.noteDirectory = noteDirectory + self.vaultRoot = vaultRoot var resolver = AssetResolver(noteDirectory: noteDirectory, vaultRoot: vaultRoot) var files: [URL] = [] var urls: [String: String] = [:] @@ -89,6 +93,18 @@ struct NotePreview { } return try Self.serve(assets[index], for: url, range: range) } + if path.hasPrefix("/cover/") { + // A cover the embed scan did not find (a plain [[link]] or a bare + // path in the frontmatter): the page names it, having read the + // frontmatter with the app's own parser, and it resolves here like + // any embed, inside the vault and media files only. + let reference = String(path.dropFirst("/cover/".count)) + var resolver = AssetResolver(noteDirectory: noteDirectory, vaultRoot: vaultRoot) + guard let file = resolver.resolve(Reference(written: reference, decoded: reference, wikilink: true)) else { + throw URLError(.fileDoesNotExist) + } + return try Self.serve(file, for: url, range: nil) + } let file = viewer.appendingPathComponent(String(path.dropFirst())).standardizedFileURL guard file.path.hasPrefix(viewer.path + "/") else { throw URLError(.noPermissionsToReadFile) } return try Self.serve(file, for: url, range: nil) diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index 182f2dd7a..8cf9dd682 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.77.0", + "version": "2.78.0", "type": "module", "description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it", "homepage": "https://zennotes.org", @@ -33,7 +33,7 @@ "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", - "pele": "1.6.0", + "pele": "1.12.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/apps/quicklook/src/cover.test.ts b/apps/quicklook/src/cover.test.ts new file mode 100644 index 000000000..9050fd696 --- /dev/null +++ b/apps/quicklook/src/cover.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { quickLookCover } from './cover' + +const shown = { noteCoverPlacement: 'scroll' } as const + +describe('quickLookCover', () => { + it('draws a cover the extension already found as an embed, at its focal point', () => { + const payload = { + markdown: '---\nbanner: "![[assets/lisbon.jpg]]"\nbanner_y: 0.3\n---\n# Lisbon\n', + assets: { 'assets/lisbon.jpg': 'zenql://app/asset/0/lisbon.jpg' } + } + expect(quickLookCover(payload, shown)).toEqual({ url: 'zenql://app/asset/0/lisbon.jpg', y: 0.3 }) + }) + + it('asks the extension for any other form of cover by its decoded reference', () => { + expect(quickLookCover({ markdown: '---\nbanner: "[[pics/a b.png]]"\n---\n', assets: {} }, shown)).toEqual({ + url: 'zenql://app/cover/pics%2Fa%20b.png', + y: 0.5 + }) + expect(quickLookCover({ markdown: '---\ncover: "![x](pics/a%20b.png)"\n---\n', assets: {} }, shown)?.url).toBe( + 'zenql://app/cover/pics%2Fa%20b.png' + ) + }) + + it('leaves out a cover from the web, the hidden setting, and a note without one', () => { + expect(quickLookCover({ markdown: '---\nbanner: https://example.com/a.jpg\n---\n', assets: {} }, shown)).toBeNull() + const payload = { + markdown: '---\nbanner: "![[a.png]]"\n---\n', + assets: { 'a.png': 'zenql://app/asset/0/a.png' } + } + expect(quickLookCover(payload, { noteCoverPlacement: 'hidden' })).toBeNull() + expect(quickLookCover({ markdown: '# Plain note\n\n![[a.png]]\n', assets: payload.assets }, shown)).toBeNull() + }) +}) diff --git a/apps/quicklook/src/cover.ts b/apps/quicklook/src/cover.ts new file mode 100644 index 000000000..69cbf0836 --- /dev/null +++ b/apps/quicklook/src/cover.ts @@ -0,0 +1,39 @@ +import { readNoteCover } from '@shared/note-cover' +import { readOnlyNoteCoverUrl } from '@shared/note-cover-gradients' +import type { QuickLookPayload } from './payload' +import type { QuickLookPrefs } from './prefs' +import { decodeRef, lookupQuickLookAsset } from './shim' + +/** The extension serves a cover it was asked for by reference from here. */ +export const QUICKLOOK_COVER_PREFIX = 'zenql://app/cover/' + +export interface QuickLookCover { + url: string + y: number +} + +/** + * The note's cover as the preview draws it, or null for none. + * + * The extension finds a `![[...]]` cover while it scans the note for embeds, + * which is how ZenNotes and Obsidian's Banners plugin write one. Every other + * form `readNoteCover` accepts (a plain `[[link]]`, a bare path, `cover:`) is + * asked for by reference instead, so the frontmatter is read here, by the + * app's own parser, and the extension only resolves the file, beside the note + * or in the vault, with the limits it puts on any embed. An https cover is + * left out: the preview never goes online. Pinned or scrolling makes no + * difference in a preview, which is one page that scrolls. + */ +export function quickLookCover( + payload: Pick, + prefs: Pick +): QuickLookCover | null { + if (prefs.noteCoverPlacement === 'hidden') return null + const cover = readNoteCover(payload.markdown) + const url = readOnlyNoteCoverUrl(cover, { + resolveVault: (href) => + lookupQuickLookAsset(payload, href) ?? `${QUICKLOOK_COVER_PREFIX}${encodeURIComponent(decodeRef(href))}`, + remote: false + }) + return cover && url ? { url, y: cover.y } : null +} diff --git a/apps/quicklook/src/main.tsx b/apps/quicklook/src/main.tsx index 34f299040..9922b881b 100644 --- a/apps/quicklook/src/main.tsx +++ b/apps/quicklook/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client' import type { AssetMeta, ImportedAssetKind, NoteContent, VaultInfo } from '@shared/ipc' import { applyTypographyVariables } from '@renderer/lib/typography-variables' import { Button } from '@renderer/components/ui/Button' +import { ReadOnlyNoteCover } from '@renderer/components/ReadOnlyNoteCover' // The full app stylesheet (prose, themes, KaTeX, highlight, diagram chrome), // as the share viewer and the PDF export window ship it. import '@renderer/styles/index.css' @@ -11,6 +12,7 @@ import { installQuickLookBridge } from './shim' import { quickLookTheme, readQuickLookPrefs, type QuickLookPrefs } from './prefs' import { createQuickLookKeys, type QuickLookAction } from './keys' import { isOutsideLink, postToHost } from './host' +import { quickLookCover, type QuickLookCover } from './cover' const payload = readQuickLookPayload() if (payload) { @@ -78,7 +80,7 @@ async function boot(data: QuickLookPayload): Promise { ReactDOM.createRoot(root).render( - + @@ -88,10 +90,12 @@ async function boot(data: QuickLookPayload): Promise { function QuickLookPage({ title, vimMode, + cover, children }: { title: string vimMode: boolean + cover: QuickLookCover | null children: React.ReactNode }): React.ReactElement { return ( @@ -104,6 +108,7 @@ function QuickLookPage({ {vimMode ? : null} + {cover ? : null}
{children}
) diff --git a/apps/quicklook/src/prefs.test.ts b/apps/quicklook/src/prefs.test.ts index 4819d157b..2cbe58d4a 100644 --- a/apps/quicklook/src/prefs.test.ts +++ b/apps/quicklook/src/prefs.test.ts @@ -18,6 +18,7 @@ theme_family = "catppuccin" theme_mode = "light" theme_id = "catppuccin-latte" content_align = "left" +note_cover_placement = "hidden" [typography] text_font = "Iowan Old Style" @@ -33,6 +34,7 @@ mono_font = "" themeMode: 'light', themeId: 'catppuccin-latte', contentAlign: 'left', + noteCoverPlacement: 'hidden', textFont: 'Iowan Old Style', monoFont: null, interfaceFont: null @@ -46,6 +48,7 @@ mono_font = "" expect(prefs.editorFontSize).toBe(defaultQuickLookPrefs().editorFontSize) expect(prefs.themeMode).toBe(defaultQuickLookPrefs().themeMode) expect(prefs.vimMode).toBe(true) + expect(readQuickLookPrefs('[appearance]\nnote_cover_placement = "floating"').noteCoverPlacement).toBe('scroll') }) }) diff --git a/apps/quicklook/src/prefs.ts b/apps/quicklook/src/prefs.ts index ffe1b222c..bce3f97af 100644 --- a/apps/quicklook/src/prefs.ts +++ b/apps/quicklook/src/prefs.ts @@ -16,6 +16,8 @@ export interface QuickLookPrefs { previewMaxWidth: number contentAlign: 'center' | 'left' completedTaskStyle: string + /** Hidden keeps a note's cover out of the preview, as in the app. */ + noteCoverPlacement: 'scroll' | 'pinned' | 'hidden' vimMode: boolean } @@ -26,6 +28,7 @@ const TOML_KEYS: Record = themeMode: ['appearance', 'theme_mode'], themeId: ['appearance', 'theme_id'], contentAlign: ['appearance', 'content_align'], + noteCoverPlacement: ['appearance', 'note_cover_placement'], interfaceFont: ['typography', 'interface_font'], textFont: ['typography', 'text_font'], monoFont: ['typography', 'mono_font'], @@ -75,6 +78,9 @@ export function readQuickLookPrefs(configText: string): QuickLookPrefs { prefs.themeMode = defaultQuickLookPrefs().themeMode } if (prefs.contentAlign !== 'left') prefs.contentAlign = 'center' + if (!['scroll', 'pinned', 'hidden'].includes(prefs.noteCoverPlacement)) { + prefs.noteCoverPlacement = defaultQuickLookPrefs().noteCoverPlacement + } for (const font of ['interfaceFont', 'textFont', 'monoFont'] as const) { if (prefs[font] === '') prefs[font] = null } diff --git a/apps/quicklook/src/shim.ts b/apps/quicklook/src/shim.ts index e3a8d924b..e75ab2740 100644 --- a/apps/quicklook/src/shim.ts +++ b/apps/quicklook/src/shim.ts @@ -24,7 +24,8 @@ const QUICKLOOK_APP_INFO: ZenAppInfo = { runtime: 'web' } -function decodeRef(href: string): string { +/** A reference's file path: no query or fragment, percent-decoded. */ +export function decodeRef(href: string): string { const cleaned = href.split('#')[0]?.split('?')[0] ?? href try { return decodeURIComponent(cleaned) diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index ab493ac0c..615499ed9 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.77.0", + "version": "2.78.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", @@ -34,7 +34,7 @@ "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", - "pele": "1.6.0", + "pele": "1.12.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/apps/share-viewer/src/main.tsx b/apps/share-viewer/src/main.tsx index 6206c3c0c..a5645e756 100644 --- a/apps/share-viewer/src/main.tsx +++ b/apps/share-viewer/src/main.tsx @@ -1,9 +1,12 @@ import React from 'react' import ReactDOM from 'react-dom/client' import type { AssetMeta, ImportedAssetKind, NoteContent, VaultInfo } from '@shared/ipc' +import { readNoteCover } from '@shared/note-cover' +import { readOnlyNoteCoverUrl } from '@shared/note-cover-gradients' import { readSharePagePayload, type SharePagePayload } from './payload' import { THEMES } from '@renderer/lib/themes' -import { installShareViewerBridge } from './shim' +import { ReadOnlyNoteCover } from '@renderer/components/ReadOnlyNoteCover' +import { installShareViewerBridge, lookupShareAsset } from './shim' // The full app stylesheet (prose, themes, KaTeX, highlight, diagram // chrome); the same file the PDF export window ships wholesale. import '@renderer/styles/index.css' @@ -93,8 +96,17 @@ async function boot(data: SharePagePayload): Promise { root.dataset.viewerReady = 'true' } + // The cover image uploaded with the note (its `![[...]]` counts as an + // embed), an https cover as is, and a built-in gradient redrawn here. + const cover = readNoteCover(note.body) + const coverUrl = readOnlyNoteCoverUrl(cover, { + resolveVault: (href) => lookupShareAsset(data, href), + remote: true + }) + ReactDOM.createRoot(rendered).render( + {cover && coverUrl ? : null}
diff --git a/apps/share-viewer/src/shim.ts b/apps/share-viewer/src/shim.ts index 855539a8b..dc85a0713 100644 --- a/apps/share-viewer/src/shim.ts +++ b/apps/share-viewer/src/shim.ts @@ -109,7 +109,8 @@ function decodeRef(href: string): string { } } -function lookupAsset(payload: SharePagePayload, href: string): string | null { +/** The public URL of a file the publisher uploaded, or null. */ +export function lookupShareAsset(payload: Pick, href: string): string | null { const direct = Object.hasOwn(payload.assets, href) ? payload.assets[href] : null if (direct && /^https?:\/\//i.test(direct)) return direct const ref = decodeRef(href) @@ -150,9 +151,9 @@ export function installShareViewerBridge(payload: SharePagePayload): void { }, resolveVaultAssetUrl: (_vaultRoot: string, assetPath: string): string | null => - lookupAsset(payload, assetPath), + lookupShareAsset(payload, assetPath), resolveLocalAssetUrl: (_vaultRoot: string, _notePath: string, href: string): string | null => - lookupAsset(payload, href), + lookupShareAsset(payload, href), getPathForFile: () => null, clipboardWriteText: (text: string): void => { diff --git a/apps/web/package.json b/apps/web/package.json index c07894892..8b40853b8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.77.0", + "version": "2.78.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", @@ -40,7 +40,7 @@ "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", - "pele": "1.6.0", + "pele": "1.12.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/package-lock.json b/package-lock.json index 1dc724e53..666c906f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.77.0", + "version": "2.78.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.77.0", + "version": "2.78.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.77.0", + "version": "2.78.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -59,7 +59,7 @@ "jsxgraph": "^1.12.2", "katex": "^0.16.15", "node-tikzjax": "^1.0.5", - "pele": "1.6.0", + "pele": "1.12.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -915,7 +915,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.77.0", + "version": "2.78.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -938,7 +938,7 @@ "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", - "pele": "1.6.0", + "pele": "1.12.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -989,7 +989,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.77.0", + "version": "2.78.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1012,7 +1012,7 @@ "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", - "pele": "1.6.0", + "pele": "1.12.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -1061,7 +1061,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.77.0", + "version": "2.78.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1090,7 +1090,7 @@ "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", - "pele": "1.6.0", + "pele": "1.12.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -12897,9 +12897,9 @@ } }, "node_modules/pele": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/pele/-/pele-1.6.0.tgz", - "integrity": "sha512-VfAiVItsOGlGhvnBfphdT28VCG7mXMZxQNOwAGsCtxI2ztbUsT39MraEOYjsapLxVETkdUZscm0DdotYDQMLdw==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/pele/-/pele-1.12.0.tgz", + "integrity": "sha512-3OdOK6rvkaTB569R4/I8U0H82MqPRxj7vfkbowa2rRhgSBPFhvWlfFy5eae0jP+2A6hE8abE17nsdyxJz2VBiQ==", "license": "MIT", "engines": { "node": ">=20" @@ -16409,7 +16409,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.77.0", + "version": "2.78.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16454,7 +16454,7 @@ "jsxgraph": "^1.12.2", "katex": "^0.16.15", "mdast": "^3.0.0", - "pele": "1.6.0", + "pele": "1.12.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -16498,14 +16498,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.77.0", + "version": "2.78.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.77.0", + "version": "2.78.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16519,7 +16519,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.77.0" + "version": "2.78.0" } } } diff --git a/package.json b/package.json index 532043046..3a7cb3042 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.77.0", + "version": "2.78.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 c55eb663e..ad3d82868 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.77.0", + "version": "2.78.0", "type": "module", "exports": { "./main": "./src/main.tsx", @@ -67,7 +67,7 @@ "jsxgraph": "^1.12.2", "katex": "^0.16.15", "mdast": "^3.0.0", - "pele": "1.6.0", + "pele": "1.12.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index eb309a443..9f9ad5fa4 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -9,6 +9,7 @@ import { } from './store' import { resolveAuto, findTheme } from './lib/themes' import { applyTypographyVariables } from './lib/typography-variables' +import { activeReferencePath } from './lib/pinned-reference' import { injectActiveTheme, injectOverrides, @@ -222,6 +223,10 @@ const EmbedDrawingPalette = lazy(async () => { const module = await import('./components/EmbedDrawingPalette') return { default: module.EmbedDrawingPalette } }) +const NoteCoverPicker = lazy(async () => { + const module = await import('./components/NoteCoverPicker') + return { default: module.NoteCoverPicker } +}) const SettingsModal = lazy(async () => { const module = await import('./components/SettingsModal') @@ -387,6 +392,7 @@ function App(): JSX.Element { const templatePaletteOpen = useStore((s) => s.templatePaletteOpen) const setTemplatePaletteOpen = useStore((s) => s.setTemplatePaletteOpen) const embedDrawingPaletteOpen = useStore((s) => s.embedDrawingPaletteOpen) + const noteCoverPickerPath = useStore((s) => s.noteCoverPickerPath) const setEmbedDrawingPaletteOpen = useStore((s) => s.setEmbedDrawingPaletteOpen) const sidebarOpen = useStore((s) => s.sidebarOpen) const noteListOpen = useStore((s) => s.noteListOpen) @@ -429,7 +435,7 @@ function App(): JSX.Element { const persistWorkspace = useStore((s) => s.persistWorkspace) const flushDirtyNotes = useStore((s) => s.flushDirtyNotes) const activePinnedRefPath = useMemo( - () => (selectedPath ? noteRefs[selectedPath]?.path ?? pinnedRefPath : pinnedRefPath), + () => activeReferencePath({ selectedPath, noteRefs, pinnedRefPath }), [noteRefs, pinnedRefPath, selectedPath] ) const showPinnedReferencePane = !zenMode && pinnedRefVisible && !!activePinnedRefPath @@ -1335,6 +1341,11 @@ function App(): JSX.Element { )} + {noteCoverPickerPath && ( + + + + )} {settingsOpen && ( diff --git a/packages/app-core/src/assets/covers/blue-marble.webp b/packages/app-core/src/assets/covers/blue-marble.webp new file mode 100644 index 000000000..c6a7a3fca Binary files /dev/null and b/packages/app-core/src/assets/covers/blue-marble.webp differ diff --git a/packages/app-core/src/assets/covers/earthrise.webp b/packages/app-core/src/assets/covers/earthrise.webp new file mode 100644 index 000000000..2a57ee33e Binary files /dev/null and b/packages/app-core/src/assets/covers/earthrise.webp differ diff --git a/packages/app-core/src/assets/covers/great-wave.webp b/packages/app-core/src/assets/covers/great-wave.webp new file mode 100644 index 000000000..e67b1a4db Binary files /dev/null and b/packages/app-core/src/assets/covers/great-wave.webp differ diff --git a/packages/app-core/src/assets/covers/oxbow.webp b/packages/app-core/src/assets/covers/oxbow.webp new file mode 100644 index 000000000..2804e39ca Binary files /dev/null and b/packages/app-core/src/assets/covers/oxbow.webp differ diff --git a/packages/app-core/src/assets/covers/rocky-mountains.webp b/packages/app-core/src/assets/covers/rocky-mountains.webp new file mode 100644 index 000000000..0c36a6da6 Binary files /dev/null and b/packages/app-core/src/assets/covers/rocky-mountains.webp differ diff --git a/packages/app-core/src/assets/covers/thumbs/blue-marble.webp b/packages/app-core/src/assets/covers/thumbs/blue-marble.webp new file mode 100644 index 000000000..345c14cdb Binary files /dev/null and b/packages/app-core/src/assets/covers/thumbs/blue-marble.webp differ diff --git a/packages/app-core/src/assets/covers/thumbs/earthrise.webp b/packages/app-core/src/assets/covers/thumbs/earthrise.webp new file mode 100644 index 000000000..55d1badba Binary files /dev/null and b/packages/app-core/src/assets/covers/thumbs/earthrise.webp differ diff --git a/packages/app-core/src/assets/covers/thumbs/great-wave.webp b/packages/app-core/src/assets/covers/thumbs/great-wave.webp new file mode 100644 index 000000000..05a02d39d Binary files /dev/null and b/packages/app-core/src/assets/covers/thumbs/great-wave.webp differ diff --git a/packages/app-core/src/assets/covers/thumbs/oxbow.webp b/packages/app-core/src/assets/covers/thumbs/oxbow.webp new file mode 100644 index 000000000..0b460d8b9 Binary files /dev/null and b/packages/app-core/src/assets/covers/thumbs/oxbow.webp differ diff --git a/packages/app-core/src/assets/covers/thumbs/rocky-mountains.webp b/packages/app-core/src/assets/covers/thumbs/rocky-mountains.webp new file mode 100644 index 000000000..b1f770348 Binary files /dev/null and b/packages/app-core/src/assets/covers/thumbs/rocky-mountains.webp differ diff --git a/packages/app-core/src/assets/covers/thumbs/wheat-field.webp b/packages/app-core/src/assets/covers/thumbs/wheat-field.webp new file mode 100644 index 000000000..b880b9311 Binary files /dev/null and b/packages/app-core/src/assets/covers/thumbs/wheat-field.webp differ diff --git a/packages/app-core/src/assets/covers/wheat-field.webp b/packages/app-core/src/assets/covers/wheat-field.webp new file mode 100644 index 000000000..e97cfdf28 Binary files /dev/null and b/packages/app-core/src/assets/covers/wheat-field.webp differ diff --git a/packages/app-core/src/calendar.tsx b/packages/app-core/src/calendar.tsx index 8b1d90a28..0a156e715 100644 --- a/packages/app-core/src/calendar.tsx +++ b/packages/app-core/src/calendar.tsx @@ -1,5 +1,16 @@ import { CalendarPanel } from './components/CalendarPanel' import { useStore } from './store' +import { isoDateStr, isoWeekStr, parseIsoDate } from './lib/calendar-data' +import { buildDateNoteIndexes, normalizeVaultSettings } from './lib/vault-layout' + +export { + CALENDAR_WIDGET_MAX_TASKS_PER_DAY, + getCalendarWidgetData, + type CalendarWidgetData, + type CalendarWidgetDay, + type CalendarWidgetMark, + type CalendarWidgetTask, +} from './lib/calendar-widget-data' /** * The date calendar as a surface of its own, for hosts that open it from a @@ -7,9 +18,43 @@ import { useStore } from './store' * so it works with no note open. It orients on the open note when that is a * daily or weekly note, else on today. `onOpen` runs once it has opened a * day, a week or a task's line; a host that shows it as a sheet closes it - * there. + * there. `date` (`YYYY-MM-DD`) opens it on that day instead, and `focusAdd` + * puts the cursor in "Add a task for …": the home-screen widget's day and + * add buttons. Both are read on mount. */ -export function DateCalendar({ onOpen }: { onOpen?: () => void }): JSX.Element { +export function DateCalendar({ + onOpen, + date, + focusAdd +}: { + onOpen?: () => void + date?: string + focusAdd?: boolean +}): JSX.Element { const note = useStore((s) => s.activeNote) - return + return +} + +/** + * Open the daily (or weekly) note for a `YYYY-MM-DD` day when it exists. + * Resolves false, opening nothing, when it does not or that kind of note is + * off: a host then shows the calendar on that day, where "Day +" asks before + * creating the note. + */ +export async function openDateNote(kind: 'daily' | 'weekly', iso: string): Promise { + if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false + const date = parseIsoDate(iso) + if (Number.isNaN(date.getTime()) || isoDateStr(date) !== iso) return false + const state = useStore.getState() + if (!state.vault) return false + const settings = normalizeVaultSettings(state.vaultSettings) + const { dailyByDate, weeklyByWeek } = buildDateNoteIndexes(state.notes, settings) + if (kind === 'daily') { + if (!settings.dailyNotes.enabled || !dailyByDate.has(iso)) return false + await state.openDailyNoteForDate(date) + } else { + if (!settings.weeklyNotes.enabled || !weeklyByWeek.has(isoWeekStr(date))) return false + await state.openWeeklyNoteForDate(date) + } + return true } diff --git a/packages/app-core/src/components/CalendarPanel.test.ts b/packages/app-core/src/components/CalendarPanel.test.ts index 3e590fed6..dfabf735d 100644 --- a/packages/app-core/src/components/CalendarPanel.test.ts +++ b/packages/app-core/src/components/CalendarPanel.test.ts @@ -137,4 +137,26 @@ describe('CalendarPanel: a day on a touch screen', () => { expect(confirmApp).not.toHaveBeenCalled() expect(openDailyNoteForDate).not.toHaveBeenCalled() }) + + it("opens on the host's day, in that day's month (the widget's day link)", () => { + const later = new Date(today.getFullYear(), today.getMonth() + 2, 15) + act(() => root.render(createElement(CalendarPanel, { note: null, initialDate: isoDate(later) }))) + expect(day(later).className).toContain('ring-accent/60') + expect(host.querySelector(`[data-cal-day="${isoDate(today)}"]`)).toBeNull() + }) + + it('ignores a malformed host day and orients on today', () => { + act(() => root.render(createElement(CalendarPanel, { note: null, initialDate: '2026-13-45' }))) + expect(day(today).className).toContain('ring-accent/60') + }) + + it('puts the cursor in "Add a task" when asked (the widget\'s add button)', async () => { + act(() => root.render(createElement(CalendarPanel, { note: null, focusAdd: true }))) + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))) + }) + const input = host.querySelector('input[placeholder^="Add a task for"]') + expect(input).not.toBeNull() + expect(document.activeElement).toBe(input) + }) }) diff --git a/packages/app-core/src/components/CalendarPanel.tsx b/packages/app-core/src/components/CalendarPanel.tsx index bdc1a7f8d..3e90bceb8 100644 --- a/packages/app-core/src/components/CalendarPanel.tsx +++ b/packages/app-core/src/components/CalendarPanel.tsx @@ -18,8 +18,6 @@ import type { NoteContent, NoteMeta } from '@shared/ipc' import { filterTasksForDisplay, inferDailyTaskDueDates, - isTaskOpen, - parseTasksFromBody, tasksDueOn, type VaultTask, } from '@shared/tasks' @@ -31,8 +29,17 @@ import { normalizeVaultSettings, weeklyNoteTitle, } from '../lib/vault-layout' -import { getISOWeek, getISOWeekYear } from '../lib/template-render' -import { countWords } from '../lib/word-count' +import { getISOWeek } from '../lib/template-render' +import { + addMonths, + buildGrid, + dotsFor, + isoDateStr, + isoWeekStr, + measureNote, + parseIsoDate, + type NoteStats, +} from '../lib/calendar-data' import { InlineMarkdown } from '../lib/inline-markdown' import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' import { resolveWeekStartDay } from '../lib/week-start' @@ -45,45 +52,8 @@ import { LazyNoteHoverPreview as NoteHoverPreview } from './LazyNoteHoverPreview import { buildTaskMenuItems } from '../lib/task-context-menu' const FULL_DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] -const WORDS_PER_DOT = 80 -const MAX_DOTS = 3 const HOVER_DELAY_MS = 280 -interface NoteStats { - /** `${updatedAt}:${size}` — re-read when this changes. */ - sig: string - words: number - openTasks: number -} - -function addMonths(d: Date, n: number): Date { - return new Date(d.getFullYear(), d.getMonth() + n, 1) -} - -/** 6-row (42-cell) grid for the month containing `anchor`, starting on `firstDay`. */ -function buildGrid(anchor: Date, firstDay: number): Date[] { - const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1) - const offset = (first.getDay() - firstDay + 7) % 7 - const start = new Date(first.getFullYear(), first.getMonth(), 1 - offset) - return Array.from({ length: 42 }, (_, i) => { - const d = new Date(start) - d.setDate(start.getDate() + i) - return d - }) -} - -function isoDateStr(d: Date): string { - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( - d.getDate() - ).padStart(2, '0')}` -} - -/** Parse a `YYYY-MM-DD` string back to a local-midnight Date. */ -function parseIsoDate(iso: string): Date { - const [y, m, d] = iso.split('-').map((s) => Number.parseInt(s, 10)) - return new Date(y, m - 1, d) -} - // Every task state, so the "lossless" promise below actually holds for a `[>]` // or `[/]` task too: an unmatched prefix falls back to the token-stripped // `content`, which an inline edit would then write back over the real line. @@ -97,25 +67,16 @@ function taskTail(task: VaultTask): string { return m ? task.rawText.slice(m[0].length) : task.content } -function isoWeekStr(d: Date): string { - return `${getISOWeekYear(d)}-W${String(getISOWeek(d)).padStart(2, '0')}` -} - function monthLabel(d: Date): string { return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) } -/** Number of word-count dots for a note (0 = none/unknown), plus a faint flag. */ -function dotsFor(stats: NoteStats | undefined): { count: number; faint: boolean } { - if (!stats) return { count: 1, faint: true } // exists but not measured yet - if (stats.words === 0) return { count: 1, faint: true } - return { count: Math.min(MAX_DOTS, Math.ceil(stats.words / WORDS_PER_DOT)), faint: false } -} - export function CalendarPanel({ note, fitWidth, - onOpen + onOpen, + initialDate, + focusAdd }: { /** The note beside the calendar, or null where the calendar stands on its * own with no note open (the phones' Calendar entry); it then orients on @@ -128,6 +89,13 @@ export function CalendarPanel({ * a note that was already open, so a host that shows it as a sheet can put * it away. Beside a note it stays open, so the pane passes none. */ onOpen?: () => void + /** A `YYYY-MM-DD` day to open on instead of the active note's date or + * today: the phones' home-screen widget opens the calendar on the day it + * shows. Read when the panel mounts; a host that needs another day + * remounts it. */ + initialDate?: string + /** Focus "Add a task for …" on mount, for the widget's add button. */ + focusAdd?: boolean }): JSX.Element { const notes = useStore((s) => s.notes) const vaultSettings = useStore((s) => s.vaultSettings) @@ -191,6 +159,17 @@ export function CalendarPanel({ const refDate = active?.date ?? new Date() const activeDayIso = active?.kind === 'daily' ? isoDateStr(active.date) : null const activeWeekIso = active?.kind === 'weekly' ? isoWeekStr(active.date) : null + // The host's day wins only beside the note the panel mounted with; once the + // active note changes the calendar follows it again. Keyed on the path, not + // cleared in an effect, so a re-run effect still sees it. + const mountedWith = useRef({ + path: note?.path, + iso: initialDate && /^\d{4}-\d{2}-\d{2}$/.test(initialDate) && isoDateStr(parseIsoDate(initialDate)) === initialDate + ? initialDate + : undefined + }) + const hostIso = mountedWith.current.path === note?.path ? mountedWith.current.iso : undefined + const orientDate = hostIso ? parseIsoDate(hostIso) : refDate const today = useMemo(() => { const now = new Date() @@ -199,12 +178,12 @@ export function CalendarPanel({ const todayIso = isoDateStr(today) const [anchor, setAnchor] = useState( - () => new Date(refDate.getFullYear(), refDate.getMonth(), 1) + () => new Date(orientDate.getFullYear(), orientDate.getMonth(), 1) ) // Re-center on the active note whenever it changes. useEffect(() => { - setAnchor(new Date(refDate.getFullYear(), refDate.getMonth(), 1)) + setAnchor(new Date(orientDate.getFullYear(), orientDate.getMonth(), 1)) // eslint-disable-next-line react-hooks/exhaustive-deps }, [note?.path]) @@ -217,9 +196,9 @@ export function CalendarPanel({ // The day the user has clicked to inspect (tasks + actions shown below the // grid). Defaults to the active daily note's date, else today; follows the // active note as it changes. - const [selectedIso, setSelectedIso] = useState(() => activeDayIso ?? todayIso) + const [selectedIso, setSelectedIso] = useState(() => hostIso ?? activeDayIso ?? todayIso) useEffect(() => { - setSelectedIso(activeDayIso ?? todayIso) + setSelectedIso(hostIso ?? activeDayIso ?? todayIso) // eslint-disable-next-line react-hooks/exhaustive-deps }, [note?.path]) const selectedDate = useMemo(() => parseIsoDate(selectedIso), [selectedIso]) @@ -284,6 +263,12 @@ export function CalendarPanel({ // keys from the editor). Mirrors the big calendar. const panelRef = useRef(null) const addInputRef = useRef(null) + useEffect(() => { + if (!focusAdd) return + const frame = requestAnimationFrame(() => addInputRef.current?.focus()) + return () => cancelAnimationFrame(frame) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const focusedTaskRef = useRef(null) const [activeTaskIndex, setActiveTaskIndex] = useState(0) const [grabbedTask, setGrabbedTask] = useState(null) @@ -351,12 +336,7 @@ export function CalendarPanel({ } } if (cancelled) return - const openTasks = parseTasksFromBody(body, { - path: n.path, - title: n.title, - folder: n.folder, - }).filter(isTaskOpen).length - updates.push([n.path, { sig, words: countWords(body), openTasks }]) + updates.push([n.path, measureNote(n, body)]) } if (!cancelled && updates.length) { setStats((prev) => { diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index 8745d6aa2..a01ecde16 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -18,7 +18,7 @@ import { canReturnToCommandList, shouldRefocusEditorAfterCommand } from '../lib/command-palette-mode' -import { THEMES, type ThemeFamily, type ThemeMode, type ThemeOption } from '../lib/themes' +import { rankThemes, type ThemeFamily, type ThemeMode, type ThemeOption } from '../lib/themes' import { buildVaultSwitcherEntries, newWindowVaultRows, @@ -104,15 +104,7 @@ export function CommandPalette(): JSX.Element { return [...recentCommands, ...ranked.filter((c) => !recentSet.has(c.id))] }, [allCommands, query, recentCommands]) - const themeResults = useMemo( - () => - rankItems(THEMES, query, [ - { get: (t) => t.label, weight: 1 }, - { get: (t) => t.family, weight: 0.9 }, - { get: (t) => t.variant, weight: 0.6 } - ]), - [query] - ) + const themeResults = useMemo(() => rankThemes(query), [query]) const vaultOptions = useMemo(() => { const entries = buildVaultSwitcherEntries({ @@ -208,7 +200,19 @@ export function CommandPalette(): JSX.Element { }, [active]) /* -------- Theme preview on active change -------- */ + // A new filter or mode re-syncs `active` in the effect above, but that lands + // on the next render: in this one `active` still indexes the previous list + // (or the command list), and previewing it applied a theme nobody had + // highlighted (typing `l` from Gruvbox Hard dark switched to Nord Light), + // which the next re-sync then locked onto. So a preview waits until `active` + // was set against the list it indexes, including on the first render. + const previewBasisRef = useRef<{ mode: Mode; results: ThemeOption[] } | null>(null) useEffect(() => { + const basis = previewBasisRef.current + if (!basis || basis.mode !== mode || basis.results !== themeResults) { + previewBasisRef.current = { mode, results: themeResults } + return + } if (mode !== 'theme') return if (active < 0) return const theme = themeResults[active] diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index a2d1d35a4..6474e31e6 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -64,6 +64,18 @@ import { offerCreateNoteFromLink } from "../lib/create-note-from-link"; import { openWikilinkAttachment } from "../lib/open-wikilink-attachment"; import { buildVersionReport } from "../lib/version-report"; import { writeClipboardText } from "../lib/clipboard-text"; +import { + coverNotePath, + openCoverPicker, + removeCover, + resolveCoverArgument, + setCoverFromBuiltin, + setCoverFromClipboard, + setCoverPosition, + setCoverTarget, + startCoverReposition, +} from "../lib/note-cover-actions"; +import { GRADIENT_COVERS, PHOTO_COVERS } from "../lib/note-cover-gallery"; import { externalFileLink, openExternalFileLink, @@ -540,6 +552,63 @@ function registerVimCommands(): void { Vim.defineEx("format", "format", () => { void useStore.getState().formatActiveNote(); }); + // `:cover` (`:cov`) is the ex twin of Space l c. Bare, it opens the picker; + // `:cover!` or `:cover remove` takes the cover off; `paste` uses the + // clipboard image; `reposition` enters reposition mode and `40%` sets the + // focal point outright. Anything else names the image: an https URL, a + // vault image by path or unique file name, or a built-in cover by name. + Vim.defineEx( + "cover", + "cov", + (_cm: unknown, params: { argString?: string } | undefined) => { + const arg = (params?.argString ?? "").trim(); + const path = coverNotePath(); + if (!arg) { + openCoverPicker(); + return; + } + if (!path) { + useToastStore.getState().addToast("Open a note to give it a cover", "info"); + return; + } + const lower = arg.toLowerCase(); + if (arg === "!" || lower === "remove") { + void removeCover(path); + return; + } + if (lower === "paste") { + void setCoverFromClipboard(path); + return; + } + if (lower === "reposition") { + startCoverReposition(); + return; + } + const percent = arg.match(/^(\d{1,3}(?:\.\d+)?)%$/); + if (percent) { + void setCoverPosition(path, Number(percent[1]) / 100); + return; + } + const target = resolveCoverArgument(arg); + if (target) { + void setCoverTarget(path, target); + return; + } + const builtin = [...GRADIENT_COVERS, ...PHOTO_COVERS].find( + (cover) => + cover.title.toLowerCase() === lower || + cover.id.toLowerCase() === lower || + cover.id.toLowerCase() === `gradient-${lower}`, + ); + if (builtin) { + void setCoverFromBuiltin(path, builtin); + return; + } + useToastStore + .getState() + .addToast(`No image called "${arg}" in this vault`, "info"); + }, + ); // `:imgwidth 480` (`:imgw`) writes the `|480` size hint into the image on // the cursor line, the same edit as dragging the widget's handle; `auto` // (or 0) strips it, and no argument opens the Resize Image prompt (#684). diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 92de089a8..216f1ac8e 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -156,6 +156,10 @@ import { shouldShowSelectionToolbar } from '../lib/cm-selection-toolbar' import { editorCursorPosition } from '../lib/editor-cursor-position' import { EditorSelectionToolbar } from './EditorSelectionToolbar' import { CloudConflictNoteBanner } from './CloudConflictNoteBanner' +import { createPortal } from 'react-dom' +import { readNoteCover } from '@shared/note-cover' +import { editorShowsNoteCover, noteCoverExtension } from '../lib/cm-note-cover' +import { NoteCover } from './NoteCover' import { AgentEditNoteBanner, AgentReviewBar, @@ -348,6 +352,8 @@ import { isDiagramTabPath } from '../lib/diagram-tabs' import { classifyLocalAssetHref } from '../lib/local-assets' +import { canShowPdfInline } from '../lib/pdf-inline' +import { PdfOpenElsewhere } from './PdfOpenElsewhere' import { formatKeyToken, getKeymapBinding, @@ -923,6 +929,17 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const activeTab = pane.activeTab const content = useStore((s) => (activeTab ? s.noteContents[activeTab] ?? null : null)) + const noteCoverPlacement = useStore((s) => s.noteCoverPlacement) + const noteCover = useMemo( + () => (content && /\.md$/i.test(content.path) ? readNoteCover(content.body) : null), + [content] + ) + // CodeMirror builds the slot element and may rebuild it; the cover follows + // whichever one is current. + const [noteCoverSlot, setNoteCoverSlot] = useState(null) + const noteCoverSlotSink = useCallback((slot: HTMLElement, alive: boolean) => { + setNoteCoverSlot((current) => (alive ? slot : current === slot ? null : current)) + }, []) const isDirty = useStore((s) => (activeTab ? s.noteDirty[activeTab] ?? false : false)) const comments = useStore((s) => activeTab ? s.noteComments[activeTab] ?? EMPTY_COMMENTS : EMPTY_COMMENTS @@ -1172,6 +1189,8 @@ 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) + // The editor's cover slot: present only while covers scroll with the text. + const noteCoverCompartmentRef = useRef(null) 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 @@ -1358,6 +1377,14 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { if (!visible) return null const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number if (previewShowsSourceLine(visible, caretLine)) return null + // A reader at the very top of a note with a cover was looking at the + // cover. The first block sits below it (and below the frontmatter, where + // the caret usually is), so landing that block at the top would scroll the + // cover away; a margin as deep as the block keeps both panes at the top. + if (previewEl && previewEl.scrollTop <= 1 && editorShowsNoteCover(view.state)) { + const block = view.lineBlockAt(view.state.doc.line(visible.top).from) + return { line: visible.top, topMargin: block.bottom + OUTLINE_JUMP_TOP_MARGIN } + } return { line: visible.top, topMargin: OUTLINE_JUMP_TOP_MARGIN } }, []) @@ -2040,7 +2067,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const tabSizeCompartment = new Compartment() const historyCompartment = new Compartment() const agentReviewCompartment = new Compartment() + const noteCoverCompartment = new Compartment() agentReviewCompartmentRef.current = agentReviewCompartment + noteCoverCompartmentRef.current = noteCoverCompartment vimCompartmentRef.current = vimCompartment harperCompartmentRef.current = harperCompartment editorKeymapCompartmentRef.current = editorKeymapCompartment @@ -2102,6 +2131,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }), commentDecorationField, agentReviewCompartment.of([]), + noteCoverCompartment.of( + s0.noteCoverPlacement === 'scroll' ? noteCoverExtension(noteCoverSlotSink) : [] + ), wordWrapCompartment.of(s0.wordWrap ? EditorView.lineWrapping : []), scrolloffCompartment.of(scrollOff(s0.editorScrollOff)), markdownCompartment.of( @@ -2752,6 +2784,16 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { if (!view || !comp) return view.dispatch({ effects: comp.reconfigure(lineNumberExtension(lineNumberMode)) }) }, [lineNumberMode]) + useEffect(() => { + const view = viewRef.current + const comp = noteCoverCompartmentRef.current + if (!view || !comp) return + view.dispatch({ + effects: comp.reconfigure( + noteCoverPlacement === 'scroll' ? noteCoverExtension(noteCoverSlotSink) : [] + ) + }) + }, [noteCoverPlacement, noteCoverSlotSink]) useEffect(() => { const view = viewRef.current const comp = wordWrapCompartmentRef.current @@ -4530,6 +4572,26 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { onDone={agentReview.stopReview} /> )} + {noteCover && noteCoverPlacement === 'pinned' && ( + + )} + {noteCover && + noteCoverPlacement === 'scroll' && + noteCoverSlot && + createPortal( + , + noteCoverSlot + )}
+ {noteCover && noteCoverPlacement === 'scroll' && ( + + )}
+ ) : assetKind === 'pdf' && !canShowPdfInline() ? ( + ) : (