diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4533f0c3..f21338c1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.48.0", + "version": "2.49.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/desktop/src/main/cloud-sync-client.test.ts b/apps/desktop/src/main/cloud-sync-client.test.ts index 7adc6534..e3dd10a6 100644 --- a/apps/desktop/src/main/cloud-sync-client.test.ts +++ b/apps/desktop/src/main/cloud-sync-client.test.ts @@ -249,11 +249,8 @@ describe('createCloudSyncClient', () => { upload: { method: 'PUT', url: 'https://objects.example.test/upload-1?signature=signed', - headers: { - 'Content-Length': String(bytes.byteLength), - 'Content-Type': 'application/octet-stream', - 'X-Upload-Header': 'signed-value' - } + // The Cloud service signs only the host: no length, no type. + headers: { Host: 'objects.example.test', 'X-Upload-Header': 'signed-value' } } } }, @@ -327,14 +324,13 @@ describe('createCloudSyncClient', () => { expect(directUpload?.[0]).toBe('https://objects.example.test/upload-1?signature=signed') expect(directUpload?.[1]?.method).toBe('PUT') expect(directUpload?.[1]?.redirect).toBe('error') - expect(new Headers(directUpload?.[1]?.headers)).toEqual( - new Headers({ - 'Content-Length': String(bytes.byteLength), - 'Content-Type': 'application/octet-stream', - 'X-Upload-Header': 'signed-value' - }) - ) - expect(new Headers(directUpload?.[1]?.headers).has('Authorization')).toBe(false) + // The stream has no length of its own; without Content-Length the PUT + // goes out chunked and object storage answers 411 (Discord, 2026-09-13). + const putHeaders = new Headers(directUpload?.[1]?.headers) + expect(putHeaders.get('Content-Length')).toBe(String(bytes.byteLength)) + expect(putHeaders.get('Content-Type')).toBe(mutation.content.media_type) + expect(putHeaders.get('X-Upload-Header')).toBe('signed-value') + expect(putHeaders.has('Authorization')).toBe(false) expect(uploadedBodies[0]?.byteLength).toBe(bytes.byteLength) expect(uploadedBodies[0]?.[0]).toBe(7) expect(uploadedBodies[0]?.at(-1)).toBe(7) @@ -346,6 +342,65 @@ describe('createCloudSyncClient', () => { expect(new Headers(completion?.[1]?.headers).get('Authorization')).toBe('Bearer secret-token') }) + it('keeps a Content-Length and Content-Type the service already set', async () => { + const bytes = Buffer.alloc(INLINE_UPLOAD_LIMIT_BYTES + 1, 7) + const directory = await mkdtemp(path.join(os.tmpdir(), 'zennotes-direct-upload-')) + temporaryDirectories.push(directory) + const sourcePath = path.join(directory, 'archive.zip') + await writeFile(sourcePath, bytes) + const mutation = upsertMutation(bytes.byteLength, '') + rememberCloudSyncUploadSource(mutation.content, sourcePath) + const fetchImplementation = vi.fn(async (input, options) => { + const url = String(input) + if (url.endsWith('/uploads')) { + return jsonResponse( + { + data: { + id: 'upload-1', + operation_id: mutation.operation_id, + status: 'uploading', + expected_bytes: bytes.byteLength, + expires_at: '2026-08-19T18:30:00.000Z', + upload: { + method: 'PUT', + url: 'https://objects.example.test/upload-1?signature=signed', + headers: { + 'Content-Length': String(bytes.byteLength), + 'Content-Type': 'application/zip' + } + } + } + }, + 201 + ) + } + if (url.startsWith('https://objects.example.test/')) { + for await (const _chunk of options?.body as AsyncIterable) { + /* drain */ + } + return new Response(null, { status: 200 }) + } + if (url.endsWith('/complete')) { + return jsonResponse({ + data: { + id: 'upload-1', + operation_id: mutation.operation_id, + status: 'completed', + result: { acknowledged: [], conflicts: [], cursor: 9 } + } + }) + } + throw new Error(`Unexpected request: ${url}`) + }) + const client = createCloudSyncClient('https://zennotes.org/', 'secret-token', fetchImplementation) + + await client.mutate('vault-1', { mutations: [mutation] }) + + const putHeaders = new Headers(fetchImplementation.mock.calls[1]?.[1]?.headers) + expect(putHeaders.get('Content-Length')).toBe(String(bytes.byteLength)) + expect(putHeaders.get('Content-Type')).toBe('application/zip') + }) + it('aborts the upload reservation when object storage rejects the PUT', async () => { const bytes = Buffer.alloc(INLINE_UPLOAD_LIMIT_BYTES + 1, 11) const mutation = upsertMutation(bytes.byteLength, bytes.toString('base64')) diff --git a/apps/desktop/src/main/cloud-sync-client.ts b/apps/desktop/src/main/cloud-sync-client.ts index 3d1e9a3a..3510bf99 100644 --- a/apps/desktop/src/main/cloud-sync-client.ts +++ b/apps/desktop/src/main/cloud-sync-client.ts @@ -120,13 +120,27 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient { throw error } const upload = instruction.upload + // The Cloud service signs only the host of the presigned PUT and hands back + // no length, and a streamed file has no length of its own, so fetch would + // send it chunked. Object storage refuses a chunked PUT without a + // Content-Length (411 Length Required), which is what every file above the + // inline limit ran into. The mutation knows the byte count, so it travels + // as Content-Length unless the service already set one; a file that + // changes size mid-upload then fails the request instead of storing a + // truncated or padded object. Content-Type likewise names the media type + // the service reserved. + const headers = new Headers(upload.headers) + if (!headers.has('content-length')) { + headers.set('content-length', String(mutation.content.byte_length)) + } + if (!headers.has('content-type')) headers.set('content-type', mutation.content.media_type) let response: Response try { try { response = await this.fetchImplementation(uploadUrl, { method: upload.method, - headers: upload.headers, + headers, body: uploadBody.createBody(), signal: AbortSignal.timeout(DIRECT_UPLOAD_TIMEOUT_MS), redirect: 'error', diff --git a/apps/desktop/src/main/cloud-sync-upload-network.test.ts b/apps/desktop/src/main/cloud-sync-upload-network.test.ts index dd3f8edd..c3032e38 100644 --- a/apps/desktop/src/main/cloud-sync-upload-network.test.ts +++ b/apps/desktop/src/main/cloud-sync-upload-network.test.ts @@ -55,6 +55,10 @@ describe('disk-backed Cloud uploads over HTTP', () => { const fixture = await setup(size) const result = await fixture.client().mutate('vault', { mutations: [fixture.mutation] }) expect(result.acknowledged).toHaveLength(1) + // Framed with a length, never chunked (the 411 class from Discord). + expect(fixture.putHeaders()?.['content-length']).toBe(String(size)) + expect(fixture.putHeaders()?.['transfer-encoding']).toBeUndefined() + expect(fixture.putHeaders()?.['content-type']).toBe('image/jpeg') const downloaded = Buffer.from(await (await fetch(`${fixture.url}/object`)).arrayBuffer()) expect(downloaded.length).toBe(size) expect(sha256(downloaded)).toBe(fixture.mutation.content.sha256) @@ -112,6 +116,7 @@ async function setup( } let failure = initialFailure let stored: Buffer | null = null + let putHeaders: import('node:http').IncomingHttpHeaders | null = null let aborts = 0 let completions = 0 let uploadMutation = mutation @@ -134,13 +139,23 @@ async function setup( upload: { method: 'PUT', url: `${url}/object`, - headers: { 'Content-Length': String(bytes.length) } + // The Cloud service signs only the host of the presigned PUT and + // hands back nothing else; the client must supply the length. + headers: { Host: new URL(url).host } } } }) }) } else if (request.url === '/object' && request.method === 'PUT') { - if (failure === 'reject') { + putHeaders = request.headers + // Object storage (S3, R2) refuses a chunked PUT that carries no + // Content-Length before reading a byte: 411 Length Required. The old + // fixture accepted anything, which is how the real rejection stayed + // hidden behind green tests. + if (request.headers['transfer-encoding'] || !request.headers['content-length']) { + response.writeHead(411, { Connection: 'close' }) + response.end() + } else if (failure === 'reject') { response.writeHead(403, { Connection: 'close' }) response.end() } else if (failure === 'disconnect') { @@ -217,7 +232,8 @@ async function setup( failure = null }, aborts: () => aborts, - completions: () => completions + completions: () => completions, + putHeaders: () => putHeaders } } diff --git a/apps/desktop/src/renderer/export-window.tsx b/apps/desktop/src/renderer/export-window.tsx index 1b3b8f7c..5aa474c1 100644 --- a/apps/desktop/src/renderer/export-window.tsx +++ b/apps/desktop/src/renderer/export-window.tsx @@ -14,6 +14,7 @@ import { resolveCustomThemeMode } from '@renderer/lib/custom-themes' import { withExportTitle } from '@shared/export-title' +import { settleExportImages } from '@renderer/lib/export-images' import '@renderer/styles/index.css' const PREFS_KEY = 'zen:prefs:v2' @@ -448,7 +449,12 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element { setExportState('ready')} + onRendered={() => { + // 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). + void settleExportImages(document).then(() => setExportState('ready')) + }} /> diff --git a/apps/server/package.json b/apps/server/package.json index 0ce846a9..ed000aa7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.48.0", + "version": "2.49.0", "scripts": { "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", diff --git a/apps/web/package.json b/apps/web/package.json index 5ecd1cbc..01558b43 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.48.0", + "version": "2.49.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/apps/web/src/export-window.tsx b/apps/web/src/export-window.tsx index 4fbf4815..ba78407d 100644 --- a/apps/web/src/export-window.tsx +++ b/apps/web/src/export-window.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client' import type { AssetMeta, NoteContent, NoteMeta, VaultInfo } from '@shared/ipc' import { LazyPreview as Preview } from '@renderer/components/LazyPreview' import { useStore } from '@renderer/store' +import { settleExportImages } from '@renderer/lib/export-images' import { withExportTitle } from '@shared/export-title' import '@renderer/styles/index.css' @@ -158,6 +159,9 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element { const triggerPrint = async (): Promise => { if (didTriggerPrint.current) return didTriggerPrint.current = true + // The preview defers images below the viewport; print only once every + // image has loaded or failed (#769). + await settleExportImages(document) try { if ('fonts' in document && document.fonts?.ready) { await document.fonts.ready diff --git a/docs/releases/v2.49.0/RELEASE_NOTES.md b/docs/releases/v2.49.0/RELEASE_NOTES.md new file mode 100644 index 00000000..f162af71 --- /dev/null +++ b/docs/releases/v2.49.0/RELEASE_NOTES.md @@ -0,0 +1,40 @@ +ZenNotes 2.49.0: Cloud sync uploads files over 5 MB again, every image in a note makes it into the PDF, Space at the front of existing bold no longer doubles the marker, and a missing note can be created straight from its wikilink + +> Four community reports from the days after 2.48.0. Cloud sync no longer fails with "object upload failed (411)" on vaults with attachments above 5 MB (Sasori and Unyanda on Discord). A PDF export now waits for every image in the note, including the ones far below the first screen, instead of printing empty frames ([#769](https://github.com/ZenNotes/zennotes/issues/769)). Pressing Space with the cursor just inside an existing `**bold**` span inserts a space instead of a second marker pair, and Backspace no longer takes the original marker with it ([#770](https://github.com/ZenNotes/zennotes/issues/770)). And wikilinks at notes that do not exist yet are drawn apart from live ones, in the editor as well as the reading view, while a Cmd/Ctrl-click, or `gD` in normal mode, creates the note at once without the confirmation ([#768](https://github.com/ZenNotes/zennotes/issues/768)). + +## ✨ New + +- **Create a missing note straight from its wikilink** ([#768](https://github.com/ZenNotes/zennotes/issues/768), requested by SomeoneInTheRoom). Following a wikilink at a note that does not exist yet asked, every time, whether to create it and where. The question is right when the note belongs somewhere specific, and pure friction when it belongs exactly where the link says. Hold Cmd (macOS) or Ctrl (Windows/Linux) while clicking an unresolved wikilink, in the live preview, in edit mode, or in the reading view, and the note is created immediately at the path the prompt would have suggested: Inbox, named after the link text, with a path-style link keeping its subfolder and an explicit top folder honoured. A plain click still asks. The keyboard twin is `gD` in normal mode: `gd` without the question. A note that already exists at the suggested path is opened, never overwritten. The reading view used to do nothing at all on a dead link; it now offers to create the note like the editor does. "Inbox" here means the vault's primary notes location: a vault whose notes live at the root creates the note at the root, where New note puts it. + + How to test locally: in a note, type `[[Brand new idea]]` and move the cursor off the link. Cmd/Ctrl-click it. Before: the "Create note for" prompt opens. After: the note opens at once and appears under Inbox. Type another dead link, put the cursor on it in normal mode and press `gD`: same result. A plain click on a dead link still opens the prompt. + +- **Unresolved wikilinks look different in the editor** ([#768](https://github.com/ZenNotes/zennotes/issues/768)). The reading view already drew a link at a missing note muted with a dashed underline; the live preview drew every wikilink in the accent colour, so there was no telling which links were live. The editor now uses the same look, with the same definition of "resolved" on both surfaces: a note, a heading or block in this note, a `.base` database, or a file in the vault. The editor recomputes when the vault moves under it, so a link flips to the live look the moment its note is created, and a wikilink at an image or PDF no longer reads as a note waiting to be made. + + How to test locally: open a note with `[[Existing note]]` and `[[Nothing here]]` on one line, in Edit mode with live preview on. Before: both links look the same. After: the second is muted with a dashed underline; create it (Cmd/Ctrl-click) and come back, and it is drawn like the first. + +## 🐛 Fixes + +- **Cloud sync uploads files over 5 MB again** (Discord, reported by Sasori and Unyanda on Linux; not Linux-specific). A file above the 5 MB inline limit is streamed straight to object storage through a presigned PUT. The Cloud service signs only the host of that PUT and hands back no length, and a streamed file has no length of its own, so the request went out chunked, and S3-style storage refuses a chunked PUT without a Content-Length: **411 Length Required**, surfaced as "ZenNotes Cloud object upload failed (411)". Before 2.48.0 the same rejection showed as "Controller is already closed", because the storage answered before reading the body and the old stream adapter crashed on that; 2.48.0 fixed the adapter, which is why the real status appeared "on the latest". The desktop now sends the file's byte count as Content-Length, and its media type as Content-Type, unless the service already set them. A file that changes size mid-upload fails the request instead of storing a truncated object. The upload tests used to hand the client a Content-Length the real service never sends; their fixtures now mirror production and refuse a chunked PUT with 411, so the framing cannot regress silently. + + How to test locally: link a vault to ZenNotes Cloud and add an attachment larger than 5 MB, then press **Sync now** in Settings > Cloud. Before: "ZenNotes Cloud object upload failed (411)". After: the sync completes and the file appears on the other device. + +- **Every image in the note makes it into the PDF** ([#769](https://github.com/ZenNotes/zennotes/issues/769), reported by Unyanda). The reading preview lazy-loads local images, which is right on screen: a long note only fetches what scrolls into view. The PDF export renders through that same preview inside a hidden window that never scrolls, so any image below the window's first viewport never started loading, and the PDF showed an empty frame with the caption underneath. Images near the top survived only because they sat inside that viewport; on Linux, where a hidden window may never observe intersections at all, even those could go missing. Both export windows (desktop and web) now flip every deferred image to eager, which starts its load at once, and wait for each load to end, success or failure alike, before printing. The wait is capped at 8 s so one dead remote URL cannot hang the export. On-screen lazy loading is unchanged. + + How to test locally: open a note with an embedded image after a couple of screens of text and press Ctrl+Shift+E (Cmd+Shift+E on macOS). Before: the PDF shows an empty frame with the file name under it. After: the image is in the PDF, at the same size as the ones near the top. + +- **Space at the front of existing bold no longer doubles the marker** ([#770](https://github.com/ZenNotes/zennotes/issues/770), reported by Unyanda). The Space snippet turns a freshly typed `**` into an empty `**|**` pair. It decided whether the `**` before the cursor was a fresh opener by looking only at the text before it, so with the cursor just inside an existing span, `**|word**`, the opener looked unmatched and Space expanded it into `**|**word**`. The Backspace that followed then found an empty pair around the cursor and removed it as one, four characters for the two that had been inserted, taking the original opener with it. The rule now also reads the rest of the line: markers pair off left to right, so an odd number of closers ahead means one already belongs to this opener, and the snippet stays out of the way. The same applies to `__`, `~~`, `==`, `%%`, backticks and `[[ ]]`. An opener whose only closers ahead belong to a later, complete pair still expands. + + How to test locally: type `**word**`, put the cursor right after the opening `**` (in Vim: `0ll` then `i`), and press Space. Before: `****word**`, and Backspace leaves `word**`. After: `** word**`, and Backspace gives `**word**` back. + +## 🧰 For contributors + +- **Sources:** `62eaf82b` (PDF export images), `3a58b386` (bold marker snippet), `094c9401` (wikilink creation and unresolved styling), and the Cloud upload Content-Length fix in `cloud-sync-client.ts`. New tests: `export-images.test.ts`, the #770 block in `cm-markdown-snippets.test.ts`, `create-note-from-link.test.ts`, the #768 blocks in `follow-link.test.ts` and `cm-wikilink-render.test.ts`, the "keeps a Content-Length the service already set" case in `cloud-sync-client.test.ts`, and the 411-refusing fixture plus framing assertions in `cloud-sync-upload-network.test.ts`. +- **Verified for the upload fix:** a local wire probe under Electron 41's bundled Node 24 (undici 7.28) showed the stream body going out chunked and the same body with a Content-Length header going out framed; the real-HTTP network test drives the production coordinator and client through the 411-refusing fixture and fails without the client change. The reporters are asked to confirm on their own vaults, since the round trip against the production bucket needs a linked account with a file over 5 MB. +- **Keymaps:** `vim.createNoteFromLink` (default `g D`) joins the registry, the shared catalog and the manual; rebind it under Settings > Keymaps or in `config.toml`. +- **Verified:** all three were driven in the built desktop app on macOS over CDP against an isolated profile. The export check stubs the native save dialog through the main-process inspector (`--inspect`) and probes every `` right before `printToPDF`; the recipe lives in the session scratchpad and is worth lifting into `tooling/scripts` if it is needed again. +- Build with `npm run build --workspace @zennotes/desktop`. Create a scratch root with `mktemp -d /tmp/zennotes-249.XXXXXX`, then launch `ZEN_PERF=1 ZENNOTES_USER_DATA_PATH=/userdata ZENNOTES_CONFIG_DIR=/config apps/desktop/node_modules/.bin/electron apps/desktop/out/main/index.js --remote-debugging-port=9326`. Use a scratch vault only. +- Gates at the cut: `apps/desktop` `build:prod` through `npm run pack` (typecheck, tests, build, electron-builder --dir), Go vet and `go test -count=1`, turbo typecheck 7/7, shared-domain 1,593 tests, app-core 2,024, desktop 750, the packaged app launched in an isolated profile and reaching a CDP page target, and the website suite with the release page entry in place. + +--- + +Local-first and keyboard-first, as always. diff --git a/docs/releases/v2.49.0/media/768-unresolved-wikilinks-editor.png b/docs/releases/v2.49.0/media/768-unresolved-wikilinks-editor.png new file mode 100644 index 00000000..9799c3bd Binary files /dev/null and b/docs/releases/v2.49.0/media/768-unresolved-wikilinks-editor.png differ diff --git a/docs/releases/v2.49.0/media/769-pdf-export-bottom-images-after.png b/docs/releases/v2.49.0/media/769-pdf-export-bottom-images-after.png new file mode 100644 index 00000000..01bab245 Binary files /dev/null and b/docs/releases/v2.49.0/media/769-pdf-export-bottom-images-after.png differ diff --git a/docs/releases/v2.49.0/media/zennotes-2.49.0-demo.mp4 b/docs/releases/v2.49.0/media/zennotes-2.49.0-demo.mp4 new file mode 100644 index 00000000..cd2a62c8 Binary files /dev/null and b/docs/releases/v2.49.0/media/zennotes-2.49.0-demo.mp4 differ diff --git a/docs/releases/v2.49.0/media/zennotes-2.49.0-issue-768.mp4 b/docs/releases/v2.49.0/media/zennotes-2.49.0-issue-768.mp4 new file mode 100644 index 00000000..0b7aecb3 Binary files /dev/null and b/docs/releases/v2.49.0/media/zennotes-2.49.0-issue-768.mp4 differ diff --git a/docs/releases/v2.49.0/media/zennotes-2.49.0-issue-769.mp4 b/docs/releases/v2.49.0/media/zennotes-2.49.0-issue-769.mp4 new file mode 100644 index 00000000..02dfb234 Binary files /dev/null and b/docs/releases/v2.49.0/media/zennotes-2.49.0-issue-769.mp4 differ diff --git a/docs/releases/v2.49.0/media/zennotes-2.49.0-issue-770.mp4 b/docs/releases/v2.49.0/media/zennotes-2.49.0-issue-770.mp4 new file mode 100644 index 00000000..b4cea2da Binary files /dev/null and b/docs/releases/v2.49.0/media/zennotes-2.49.0-issue-770.mp4 differ diff --git a/docs/releases/v2.49.0/twitter-post.md b/docs/releases/v2.49.0/twitter-post.md new file mode 100644 index 00000000..183d5eb0 --- /dev/null +++ b/docs/releases/v2.49.0/twitter-post.md @@ -0,0 +1,53 @@ +# Twitter/X post for ZenNotes 2.49.0 + +Post once the GitHub release has all its assets and the channels are verified. + +## Single post (bullet list, preferred) + +ZenNotes 2.49.0 is out: + +• Cloud sync uploads attachments over 5 MB again, no more "object upload failed (411)" +• Every image in a note makes it into the PDF export, not just the ones near the top (#769) +• Space just inside an existing **bold** span inserts a space, not a second marker pair (#770) +• Cmd/Ctrl-click a wikilink at a missing note to create it at once, no prompt; gD does the same from the keyboard (#768) +• Unresolved wikilinks are drawn muted and dashed in the editor, like the reading view (#768) + +Download: zennotes.org + +## Thread + +### Tweet 1 + +ZenNotes 2.49.0 is out. First, Cloud sync: a file over 5 MB streams straight to object storage, and that request went out without a Content-Length, which the storage refuses with 411. The desktop now sends the length; large attachments sync again. Thanks Sasori and @uNyanda for the Discord reports. + +### Tweet 2 + +PDF export now waits for every image in the note before printing. The preview lazy-loads images, which is right on screen, but the export renders in a hidden window that never scrolls, so anything below the first screen printed as an empty frame with a caption (#769). + +### Tweet 3 + +Pressing Space with the cursor just inside an existing **bold** span used to insert a second marker pair, and Backspace then took the original marker with it. The snippet now reads the rest of the line and stays out of the way when the pair is already closed (#770). + +### Tweet 4 + +Wikilinks at notes that do not exist yet: hold Cmd (macOS) or Ctrl (Linux/Windows) while clicking one and the note is created immediately at the suggested path, no prompt. Keyboard twin: gD in normal mode. A plain click still asks, for when you want a custom path (#768). + +### Tweet 5 + +And you can now tell which wikilinks are live: a link at a missing note is drawn muted with a dashed underline in the editor, the way the reading view already did it, and it flips to the live look the moment the note exists. Thanks @uNyanda and SomeoneInTheRoom for the reports. + +Free, open source, local-first Markdown notes. +https://github.com/ZenNotes/zennotes/releases/tag/v2.49.0 + +Arch: yay -S zennotes-bin + +## Short alt + +ZenNotes 2.49.0: Cloud sync uploads large attachments again, every image lands in the PDF export, Space inside existing bold behaves, and a missing note is one Cmd/Ctrl-click (or gD) away from its wikilink, with unresolved links drawn apart from live ones. zennotes.org + +## Notes + +- Issues closed: #769, #770, #768. Discord: the 411 upload report (Sasori, unyanda), no GitHub issue; reply on Discord once released. +- Release PR: not opened yet (open the v2.49.0 to main PR before fast-forwarding main). +- Contributor PR #715 (PDF wikilink images) overlaps #769 with a different diagnosis; decide before the release whether to close it with a note or take its tests. +- Media in `media/`: `zennotes-2.49.0-demo.mp4` (all three fixes, 56 s, 1080p, captioned) plus one clip per issue, `zennotes-2.49.0-issue-769.mp4` (16 s), `-issue-770.mp4` (13 s), `-issue-768.mp4` (22 s); stills `769-pdf-export-bottom-images-after.png` and `768-unresolved-wikilinks-editor.png`. Recorded from the built app over CDP (`scratchpad/demo/demo.mjs`: frame loop + in-page captions + the rendered PDF pages spliced in, assembled with ffmpeg concat). diff --git a/package-lock.json b/package-lock.json index 3de5d604..c06a6afe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.48.0", + "version": "2.49.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.48.0", + "version": "2.49.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.48.0", + "version": "2.49.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -874,11 +874,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.48.0" + "version": "2.49.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.48.0", + "version": "2.49.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16286,7 +16286,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.48.0", + "version": "2.49.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16363,11 +16363,11 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.48.0" + "version": "2.49.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.48.0", + "version": "2.49.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16378,7 +16378,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.48.0" + "version": "2.49.0" } } } diff --git a/package.json b/package.json index 37f47f66..dd002b4f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.48.0", + "version": "2.49.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 3388c92e..ef2138cc 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.48.0", + "version": "2.49.0", "type": "module", "exports": { "./main": "./src/main.tsx" diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 34a4bc11..58ff10cb 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -84,12 +84,14 @@ import { toVimSequence } from "../lib/vim-key-sequence"; import { registerNoteMoveExCommands } from "../lib/vim-ex-commands"; import { promptImageWidth, setImageWidthFromInput } from "../lib/image-resize"; import { copyLinkAtCursor } from "../lib/link-copy"; +import { followLinkTarget } from "../lib/follow-link"; let vimCommandsRegistered = false; let syncedVimBindings: Partial> = {}; const DEFAULT_VIM_MAPPINGS_TO_CLEAR = [ "gd", + "gD", "h", "j", "k", @@ -221,6 +223,13 @@ function syncVimKeymaps(overrides: KeymapOverrides): void { toVimSequence(getKeymapBinding(overrides, "vim.goToDefinition")), ].filter((binding): binding is string => !!binding), }, + { + id: "vim.createNoteFromLink", + action: "zenCreateNoteFromLink", + bindings: [ + toVimSequence(getKeymapBinding(overrides, "vim.createNoteFromLink")), + ].filter((binding): binding is string => !!binding), + }, { id: "vim.paneFocusLeft", action: "focusPaneLeft", @@ -779,6 +788,20 @@ function registerVimCommands(): void { Vim.defineEx("pane_focus_up", "pane_focus_up", () => focusDir("k")); Vim.defineEx("pane_focus_right", "pane_focus_right", () => focusDir("l")); + // `gd` without the question: follows the link under the cursor and, when it + // reaches nothing, creates the note at the suggested path right away (#768). + // The keyboard twin of a Cmd/Ctrl-click on a rendered wikilink. + Vim.defineAction("zenCreateNoteFromLink", (cm: ReturnType) => { + const view = (cm as unknown as { cm6?: EditorView }).cm6; + if (!view) return; + const target = extractLinkAtCursor( + view.state.doc.toString(), + view.state.selection.main.head, + ); + if (!target) return; + followLinkTarget(target, { createWithoutAsking: true }); + }); + Vim.defineAction("goToDefinition", (cm: ReturnType) => { const view = (cm as unknown as { cm6?: EditorView }).cm6; if (!view) return; diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 74ba1992..38fe99e0 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -1819,10 +1819,12 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const doc = view.state.doc.toString() if (event.metaKey || event.ctrlKey) { const link = linkRangeAtCursor(doc, pos) + // The modifier is the user's answer to "create it?": a + // link at a missing note creates it at once (#768). if ( link && pointerOverRange(view, link.from, link.to, event.clientX, event.clientY) && - followLinkTarget(link.target) + followLinkTarget(link.target, { createWithoutAsking: true }) ) { event.preventDefault() return true diff --git a/packages/app-core/src/components/HelpView.tsx b/packages/app-core/src/components/HelpView.tsx index 0ff3c58c..a6acdc17 100644 --- a/packages/app-core/src/components/HelpView.tsx +++ b/packages/app-core/src/components/HelpView.tsx @@ -154,6 +154,7 @@ function resolveShortcutKeys( function resolveVimCommandLabel(command: string, overrides: KeymapOverrides): string { if (command === 'gd') return shortcut(overrides, 'vim.goToDefinition') + if (command === 'gD') return shortcut(overrides, 'vim.createNoteFromLink') if (command === ' l f') { return chord( leaderShortcut(overrides, 'vim.leaderNoteActions'), diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index 47cb7f1d..889fb208 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -20,6 +20,8 @@ import { import { openWikilinkTarget, } from "../lib/wikilink-navigation"; +import { followLinkTarget } from "../lib/follow-link"; +import { resolveAssetPathAmong } from "../lib/asset-path-resolution"; import { listDatabaseLinkTargets, resolveDatabaseWikilink } from "../lib/database-links"; import { externalLinkUrl, resolveInternalNoteHref } from "../lib/internal-links"; import { copyableLink, linkMenuItems, type CopyableLink } from "../lib/link-copy"; @@ -473,6 +475,13 @@ export const Preview = memo(function Preview({ void openWikilinkTarget(path, anchor.dataset.wikilink ?? ""); } else if (anchor.dataset.databaseCsv) { void useStore.getState().openDatabase(anchor.dataset.databaseCsv); + } else if (anchor.dataset.wikilink) { + // A link that reaches nothing used to be inert here while the editor + // offered to create the note. Same offer now, and with Cmd/Ctrl held + // the note is created at once at the suggested path (#768). + followLinkTarget(anchor.dataset.wikilink, { + createWithoutAsking: e.metaKey || e.ctrlKey, + }); } return; } @@ -719,6 +728,11 @@ export const Preview = memo(function Preview({ if (db) { a.classList.remove("broken"); a.dataset.databaseCsv = db.csvPath; + } else if (resolveAssetPathAmong(assetFiles, notePath ?? "", target)) { + // A wikilink at a file in the vault opens that file (#757); it is + // not a note waiting to be created, so it keeps the live-link look. + a.classList.remove("broken"); + delete a.dataset.databaseCsv; } else { a.classList.add("broken"); delete a.dataset.databaseCsv; diff --git a/packages/app-core/src/lib/cm-markdown-snippets.test.ts b/packages/app-core/src/lib/cm-markdown-snippets.test.ts index 7c68b41e..8a5a1ebb 100644 --- a/packages/app-core/src/lib/cm-markdown-snippets.test.ts +++ b/packages/app-core/src/lib/cm-markdown-snippets.test.ts @@ -151,6 +151,50 @@ describe('markdownSnippetTransaction', () => { }) }) +// #770: Space with the cursor just inside the opening marker of existing markup +// (`**|word**`) expanded the opener into a second empty pair, `**|**word**`, +// and the Backspace that followed then removed the empty pair, four characters +// for the two that were typed. An opener whose partner is already ahead on the +// line is not a snippet trigger: Space inserts a space. +describe('markdownSnippetTransaction at the front of existing markup (#770)', () => { + it('leaves the opener of an existing bold span alone', () => { + expect(applySnippet('**word**', 'Space', 2)).toBeNull() + expect(applySnippet('__word__', 'Space', 2)).toBeNull() + }) + + it('leaves the other symmetric pairs alone too', () => { + expect(applySnippet('`code`', 'Space', 1)).toBeNull() + expect(applySnippet('~~done~~', 'Space', 2)).toBeNull() + expect(applySnippet('==mark==', 'Space', 2)).toBeNull() + expect(applySnippet('%%note%%', 'Space', 2)).toBeNull() + }) + + it('leaves the opener of an existing wikilink alone', () => { + expect(applySnippet('[[Note]]', 'Space', 2)).toBeNull() + expect(applySnippet('see [[Note]] and [[Other]]', 'Space', 6)).toBeNull() + }) + + it('still expands an opener whose only closers ahead belong to a later pair', () => { + const state = applySnippet('** and **bold**', 'Space', 2) + + expect(state?.doc.toString()).toBe('**** and **bold**') + expect(state?.selection.main.head).toBe(2) + }) + + it('still expands a wikilink opener ahead of a complete link', () => { + const state = applySnippet('[[ and [[Other]]', 'Space', 2) + + expect(state?.doc.toString()).toBe('[[]] and [[Other]]') + expect(state?.selection.main.head).toBe(2) + }) + + it('ignores an escaped closer when deciding', () => { + const state = applySnippet('**word\\**', 'Space', 2) + + expect(state?.doc.toString()).toBe('****word\\**') + }) +}) + // #405: a fenced/math block opened inside a bullet list must auto-close with the // content and closing fence indented to the fence column, not escape to col 0. describe('block snippets inside list items (#405)', () => { diff --git a/packages/app-core/src/lib/cm-markdown-snippets.ts b/packages/app-core/src/lib/cm-markdown-snippets.ts index 65dbf2ba..b2f935a2 100644 --- a/packages/app-core/src/lib/cm-markdown-snippets.ts +++ b/packages/app-core/src/lib/cm-markdown-snippets.ts @@ -231,6 +231,36 @@ function isOpeningDelimiter(state: EditorState, rule: MarkdownSnippetRule, from: return true } +function indexOfUnescaped(text: string, token: string): number { + for (let index = 0; index <= text.length - token.length; index++) { + if (text.slice(index, index + token.length) !== token) continue + if (!hasOddBackslashRun(text, index)) return index + index += token.length - 1 + } + return -1 +} + +/** + * True when the delimiter ending at `pos` already has its partner later on the + * line, so the cursor is sitting at the front of existing markup rather than + * typing a fresh opener. Space at `**|word**` used to read the `**` as + * unmatched and expand it, leaving `**|**word**` (#770). Delimiters pair off + * left to right: for a symmetric pair an odd number of closers ahead means one + * of them belongs to this opener; for `[[`/`]]` the first closer ahead must come + * before the next opener. + */ +function isClosedAhead(state: EditorState, rule: MarkdownSnippetRule, pos: number): boolean { + const line = state.doc.lineAt(pos) + const after = state.doc.sliceString(pos, line.to) + if (rule.open === rule.close) { + return countUnescapedOccurrences(after, rule.close) % 2 === 1 + } + const closeAt = indexOfUnescaped(after, rule.close) + if (closeAt === -1) return false + const openAt = indexOfUnescaped(after, rule.open) + return openAt === -1 || closeAt < openAt +} + function inlineSnippetTransaction( state: EditorState, rule: MarkdownSnippetRule, @@ -251,6 +281,7 @@ function inlineSnippetTransaction( if (state.doc.sliceString(pos, Math.min(state.doc.length, pos + rule.close.length)) === rule.close) { return null } + if (isClosedAhead(state, rule, pos)) return null return { changes: { from, to: pos, insert: rule.open + rule.close }, diff --git a/packages/app-core/src/lib/cm-wikilink-render.test.ts b/packages/app-core/src/lib/cm-wikilink-render.test.ts index 53131d4f..d81620aa 100644 --- a/packages/app-core/src/lib/cm-wikilink-render.test.ts +++ b/packages/app-core/src/lib/cm-wikilink-render.test.ts @@ -4,9 +4,15 @@ import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { forceParsing } from '@codemirror/language' import { EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AssetMeta, NoteMeta } from '@shared/ipc' +import { useStore } from '../store' import { wikilinkRenderExtension } from './cm-wikilink-render' +const createNoteFromLinkNow = vi.hoisted(() => vi.fn()) +const offerCreateNoteFromLink = vi.hoisted(() => vi.fn()) +vi.mock('./create-note-from-link', () => ({ createNoteFromLinkNow, offerCreateNoteFromLink })) + function mount(doc: string, anchor: number): EditorView { const parent = document.createElement('div') document.body.append(parent) @@ -80,3 +86,67 @@ describe('wikilinkRenderExtension', () => { view.destroy() }) }) + +// #768: a wikilink at a note that does not exist yet is drawn apart from a live +// one, and a modifier click on it creates the note without the prompt. +describe('wikilinkRenderExtension: unresolved links (#768)', () => { + const note = (path: string, title: string): NoteMeta => + ({ path, title, folder: 'inbox' }) as unknown as NoteMeta + + afterEach(() => { + useStore.setState({ notes: [], assetFiles: [], selectedPath: null }) + createNoteFromLinkNow.mockClear() + offerCreateNoteFromLink.mockClear() + }) + + it('marks a link whose note is missing, and leaves a resolved one alone', () => { + useStore.setState({ notes: [note('inbox/Foo.md', 'Foo')] }) + const doc = 'see [[Foo]] and [[Nope]] and [[#Heading]] end' + const view = mount(doc, doc.length) + const links = Array.from(view.dom.querySelectorAll('.cm-wikilink')) + expect(links.map((e) => [e.textContent, e.classList.contains('cm-wikilink-broken')])).toEqual([ + ['Foo', false], + ['Nope', true], + ['#Heading', false] + ]) + view.destroy() + }) + + it('treats a wikilink at a vault file as live, not as a note to create', () => { + useStore.setState({ + assetFiles: [{ path: 'assets/diagram.png' }] as unknown as AssetMeta[], + selectedPath: 'inbox/Current.md' + }) + const doc = 'see [[assets/diagram.png]] end' + const view = mount(doc, doc.length) + const link = view.dom.querySelector('.cm-wikilink') + expect(link?.classList.contains('cm-wikilink-broken')).toBe(false) + view.destroy() + }) + + it('re-decorates when the note arrives, without an edit', () => { + const doc = 'see [[Later]] end' + const view = mount(doc, doc.length) + expect(view.dom.querySelector('.cm-wikilink-broken')).not.toBeNull() + + useStore.setState({ notes: [note('inbox/Later.md', 'Later')] }) + + expect(view.dom.querySelector('.cm-wikilink')).not.toBeNull() + expect(view.dom.querySelector('.cm-wikilink-broken')).toBeNull() + view.destroy() + }) + + it('creates the note at once on a Cmd/Ctrl click, and asks on a plain click', () => { + const doc = 'see [[Nope]] end' + const view = mount(doc, doc.length) + const link = view.dom.querySelector('.cm-wikilink')! + + link.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0, metaKey: true })) + expect(createNoteFromLinkNow).toHaveBeenCalledWith('Nope') + expect(offerCreateNoteFromLink).not.toHaveBeenCalled() + + link.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + expect(offerCreateNoteFromLink).toHaveBeenCalledWith('Nope') + view.destroy() + }) +}) diff --git a/packages/app-core/src/lib/cm-wikilink-render.ts b/packages/app-core/src/lib/cm-wikilink-render.ts index f7f1a0f9..b60c5835 100644 --- a/packages/app-core/src/lib/cm-wikilink-render.ts +++ b/packages/app-core/src/lib/cm-wikilink-render.ts @@ -11,7 +11,7 @@ * WYSIWYG-only: registered via `wysiwygExtensions()`. */ import { syntaxTree } from '@codemirror/language' -import { RangeSetBuilder } from '@codemirror/state' +import { RangeSetBuilder, StateEffect } from '@codemirror/state' import { Decoration, type DecorationSet, @@ -22,8 +22,10 @@ import { import { useStore } from '../store' import { isSameFileBlockLink, isSameFileHeadingLink, resolveWikilinkTarget } from './wikilinks' import { openDatabaseFromWikilink, openWikilinkTarget } from './wikilink-navigation' -import { offerCreateNoteFromLink } from './create-note-from-link' +import { createNoteFromLinkNow, offerCreateNoteFromLink } from './create-note-from-link' import { openWikilinkAttachment } from './open-wikilink-attachment' +import { resolveAssetPathAmong } from './asset-path-resolution' +import { listDatabaseLinkTargets, resolveDatabaseWikilink } from './database-links' // Same shape as the Preview pipeline (remarkWikilinks). const WIKILINK_RE = /(!?)\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]/g @@ -32,6 +34,82 @@ const hide = Decoration.replace({}) // wikilink (overrides the orange link highlight so brackets read as markers). const bracketMark = Decoration.mark({ class: 'cm-wikilink-bracket' }) +/** Dispatched when vault state a link's fate depends on changes (a note was + * created, the asset list arrived), so the decorations recompute without + * waiting for the next edit or scroll. */ +const refreshWikilinksEffect = StateEffect.define() + +/** + * Whether a wikilink target reaches something: a note, a spot in this note, a + * `.base` database, or a file in the vault. Anything else would create a note + * when followed, and is drawn as unresolved (#768). Mirrors the reading view's + * `broken` decision in Preview.tsx. + * + * Decorations rebuild on every selection change, and note resolution scans + * the whole notes list, so answers are memoized until any input changes. + */ +interface ResolverCache { + notes: unknown + folders: unknown + vaultSettings: unknown + assetFiles: unknown + selectedPath: string | null + databases: ReturnType + memo: Map +} +let resolverCache: ResolverCache | null = null + +function wikilinkResolves(target: string): boolean { + const s = useStore.getState() + // A surface that mounts the editor with a partial store (tests, the + // standalone windows) has no vault lists to check against. A link there is + // drawn live rather than crashing the plugin, which would disable every + // wikilink decoration at once. + const notes = Array.isArray(s.notes) ? s.notes : [] + const folders = Array.isArray(s.folders) ? s.folders : [] + const assetFiles = Array.isArray(s.assetFiles) ? s.assetFiles : [] + const selectedPath = s.selectedPath ?? null + if ( + !resolverCache || + resolverCache.notes !== notes || + resolverCache.folders !== folders || + resolverCache.vaultSettings !== s.vaultSettings || + resolverCache.assetFiles !== assetFiles || + resolverCache.selectedPath !== selectedPath + ) { + let databases: ReturnType = [] + try { + databases = listDatabaseLinkTargets(folders, s.vaultSettings) + } catch { + databases = [] + } + resolverCache = { + notes, + folders, + vaultSettings: s.vaultSettings, + assetFiles, + selectedPath, + databases, + memo: new Map() + } + } + const cached = resolverCache.memo.get(target) + if (cached != null) return cached + let resolves = true + try { + resolves = + resolveWikilinkTarget(notes, target) != null || + isSameFileHeadingLink(target) || + isSameFileBlockLink(target) || + resolveDatabaseWikilink(resolverCache.databases, target) != null || + resolveAssetPathAmong(assetFiles, selectedPath ?? '', target) != null + } catch { + resolves = true + } + resolverCache.memo.set(target, resolves) + return resolves +} + /** * True when `pos` sits inside a code span or code block — there `[[...]]` is * literal text, not a link, so it should render as code (matching the Preview @@ -104,7 +182,7 @@ function buildDecorations(view: EditorView): DecorationSet { from: labelStart, to: labelEnd, deco: Decoration.mark({ - class: 'cm-wikilink', + class: wikilinkResolves(target) ? 'cm-wikilink' : 'cm-wikilink cm-wikilink-broken', attributes: { 'data-target': target } }) }) @@ -122,23 +200,47 @@ function buildDecorations(view: EditorView): DecorationSet { const wikilinkRenderPlugin = ViewPlugin.fromClass( class { decorations: DecorationSet + unsubscribe: (() => void) | null = null constructor(view: EditorView) { this.decorations = buildDecorations(view) + // A link's resolved/unresolved look depends on vault state that moves + // under the editor: the notes list arriving after mount, a note created + // from the link itself, the asset list. Recompute when any of it changes. + this.unsubscribe = useStore.subscribe((state, prev) => { + if ( + state.notes !== prev.notes || + state.assetFiles !== prev.assetFiles || + state.folders !== prev.folders || + state.vaultSettings !== prev.vaultSettings || + state.selectedPath !== prev.selectedPath + ) { + view.dispatch({ effects: refreshWikilinksEffect.of(null) }) + } + }) } update(update: ViewUpdate): void { - if (update.docChanged || update.selectionSet || update.viewportChanged) { + const refreshed = update.transactions.some((tr) => + tr.effects.some((effect) => effect.is(refreshWikilinksEffect)) + ) + if (refreshed || update.docChanged || update.selectionSet || update.viewportChanged) { this.decorations = buildDecorations(update.view) } } + destroy(): void { + this.unsubscribe?.() + this.unsubscribe = null + } }, { decorations: (p) => p.decorations } ) /** * Open the note a wikilink points to, scrolling to its `#heading` when the - * target carries one (`[[Doc#Heading]]`). (#196) + * target carries one (`[[Doc#Heading]]`). (#196) A dead link asks before + * creating the note, unless `createWithoutAsking` (a modifier click) says the + * suggested path is fine as it is (#768). */ -function openWikilink(target: string): void { +function openWikilink(target: string, options: { createWithoutAsking?: boolean } = {}): void { const state = useStore.getState() const focusEditorSoon = (): void => { useStore.getState().setFocusedPanel('editor') @@ -158,7 +260,8 @@ function openWikilink(target: string): void { if (openDatabaseFromWikilink(target)) return // A file in the vault (an embedded image, a PDF) opens in its own tab. (#757) if (openWikilinkAttachment(target)) return - void offerCreateNoteFromLink(target) + if (options.createWithoutAsking) void createNoteFromLinkNow(target) + else void offerCreateNoteFromLink(target) return } @@ -166,14 +269,18 @@ function openWikilink(target: string): void { } // Click a rendered wikilink to jump. Intercept on mousedown so CodeMirror -// doesn't first drop the caret into the (hidden) source. +// doesn't first drop the caret into the (hidden) source. With Cmd (macOS) or +// Ctrl held, a link at a note that does not exist yet creates it at once at +// the suggested path instead of asking (#768). const wikilinkClick = EditorView.domEventHandlers({ mousedown: (event) => { const el = (event.target as HTMLElement | null)?.closest('.cm-wikilink') const target = el?.dataset.target if (!target) return false event.preventDefault() - openWikilink(target) + openWikilink(target, { + createWithoutAsking: event.button === 0 && (event.metaKey || event.ctrlKey) + }) return true } }) diff --git a/packages/app-core/src/lib/create-note-from-link.test.ts b/packages/app-core/src/lib/create-note-from-link.test.ts new file mode 100644 index 00000000..c5865d7f --- /dev/null +++ b/packages/app-core/src/lib/create-note-from-link.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// #768: a modifier click or `gD` on an unresolved wikilink creates the note +// where the prompt would have suggested, without showing the prompt. + +const state = vi.hoisted(() => ({ + notes: [{ path: 'inbox/Existing.md', title: 'Existing', folder: 'inbox' as const }], + selectNote: vi.fn(() => Promise.resolve()), + createAndOpen: vi.fn(() => Promise.resolve()), + setFocusedPanel: vi.fn(), + editorViewRef: null +})) +const promptApp = vi.hoisted(() => vi.fn(() => Promise.resolve(null))) + +vi.mock('../store', () => ({ useStore: { getState: () => state } })) +vi.mock('./prompt-requests', () => ({ promptApp })) + +const { createNoteFromLinkNow, offerCreateNoteFromLink } = await import('./create-note-from-link') + +beforeEach(() => { + promptApp.mockClear() + state.selectNote.mockClear() + state.createAndOpen.mockClear() +}) + +describe('createNoteFromLinkNow (#768)', () => { + it('creates the note in Inbox, named after the link, without prompting', async () => { + await createNoteFromLinkNow('Brand new idea') + + expect(promptApp).not.toHaveBeenCalled() + expect(state.createAndOpen).toHaveBeenCalledWith('inbox', '', { title: 'Brand new idea' }) + }) + + it('keeps a path-style link under Inbox and drops a heading anchor', async () => { + await createNoteFromLinkNow('projects/Plan#Goals') + + expect(state.createAndOpen).toHaveBeenCalledWith('inbox', 'projects', { title: 'Plan' }) + }) + + it('honors an explicit top folder', async () => { + await createNoteFromLinkNow('archive/Old idea') + + expect(state.createAndOpen).toHaveBeenCalledWith('archive', '', { title: 'Old idea' }) + }) + + it('opens a note that already exists at the suggested path instead of creating', async () => { + await createNoteFromLinkNow('Existing') + + expect(state.createAndOpen).not.toHaveBeenCalled() + expect(state.selectNote).toHaveBeenCalledWith('inbox/Existing.md') + }) + + it('the confirming path still prompts first', async () => { + await offerCreateNoteFromLink('Brand new idea') + + expect(promptApp).toHaveBeenCalledTimes(1) + expect(state.createAndOpen).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/lib/create-note-from-link.ts b/packages/app-core/src/lib/create-note-from-link.ts index 3c9123f8..c548fe3b 100644 --- a/packages/app-core/src/lib/create-note-from-link.ts +++ b/packages/app-core/src/lib/create-note-from-link.ts @@ -27,7 +27,22 @@ export async function offerCreateNoteFromLink(target: string): Promise { } }) if (!value) return + await createNoteAtPath(value) +} + +/** + * Create the note a dead link `target` points to without asking, at the path + * the prompt would have suggested: the link text as the file name, in Inbox + * unless the link names a top folder. The fast path behind a Cmd/Ctrl-click on + * an unresolved wikilink and the `gD` motion (#768): when the note belongs + * where the link already says, the confirmation only costs a keystroke. A + * note that already exists at that path is opened instead. + */ +export async function createNoteFromLinkNow(target: string): Promise { + await createNoteAtPath(suggestCreateNotePath(target)) +} +async function createNoteAtPath(value: string): Promise { const focusEditorSoon = (): void => { useStore.getState().setFocusedPanel('editor') requestAnimationFrame(() => useStore.getState().editorViewRef?.focus()) diff --git a/packages/app-core/src/lib/export-images.test.ts b/packages/app-core/src/lib/export-images.test.ts new file mode 100644 index 00000000..8514a2f9 --- /dev/null +++ b/packages/app-core/src/lib/export-images.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { settleExportImages } from './export-images' + +// #769: the preview lazy-loads local images, so in the hidden export window an +// image below the viewport never loaded and printed as an empty frame. The +// export now flips deferred images to eager and waits for every load to end. + +function image(src: string | null, loading?: string): HTMLImageElement { + const img = document.createElement('img') + if (src !== null) img.setAttribute('src', src) + if (loading) img.setAttribute('loading', loading) + document.body.append(img) + return img +} + +afterEach(() => { + document.body.replaceChildren() + vi.useRealTimers() +}) + +describe('settleExportImages', () => { + it('turns lazy images eager and resolves once every load has ended', async () => { + const lazy = image('zen-asset://v/bottom.png', 'lazy') + const plain = image('https://example.test/remote.png') + let settled = false + const done = settleExportImages(document).then(() => { + settled = true + }) + + expect(lazy.getAttribute('loading')).toBe('eager') + await Promise.resolve() + expect(settled).toBe(false) + + lazy.dispatchEvent(new Event('load')) + await Promise.resolve() + expect(settled).toBe(false) + + // A failed load ends the wait too: the export prints the broken image + // rather than hanging on it. + plain.dispatchEvent(new Event('error')) + await done + expect(settled).toBe(true) + }) + + it('resolves at once when nothing is pending', async () => { + image(null) + image('') + await expect(settleExportImages(document)).resolves.toBeUndefined() + }) + + it('gives up after the timeout so a dead source cannot hang the export', async () => { + vi.useFakeTimers() + image('https://example.test/never.png', 'lazy') + let settled = false + const done = settleExportImages(document, 500).then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(499) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await done + expect(settled).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/export-images.ts b/packages/app-core/src/lib/export-images.ts new file mode 100644 index 00000000..f4cd06c7 --- /dev/null +++ b/packages/app-core/src/lib/export-images.ts @@ -0,0 +1,47 @@ +/** + * Settle every image in a rendered note before a PDF export prints. + * + * The reading preview marks local images `loading="lazy"`: on screen a long + * note only fetches what scrolls into view. The export window is hidden and + * never scrolls, so an image below its viewport never starts loading, and the + * print captures an empty frame with the caption underneath (#769). The + * export therefore flips every deferred image to eager (which starts its load + * at once) and waits for the loads to finish, success or failure alike, so the + * page prints whatever the images turn out to be. + * + * Capped, so one dead remote URL can never hang the export. The desktop main + * process gives the export window 15 s in total; this stays well inside it. + */ +export const EXPORT_IMAGE_SETTLE_TIMEOUT_MS = 8000 + +export function settleExportImages( + root: ParentNode, + timeoutMs = EXPORT_IMAGE_SETTLE_TIMEOUT_MS +): Promise { + const pending: Promise[] = [] + for (const img of Array.from(root.querySelectorAll('img'))) { + if (img.getAttribute('loading') === 'lazy') img.setAttribute('loading', 'eager') + // `complete` is true once the load ended either way, and for an image with + // no source, which has nothing to wait for. + if (img.complete) continue + pending.push( + new Promise((resolve) => { + const done = (): void => { + img.removeEventListener('load', done) + img.removeEventListener('error', done) + resolve() + } + img.addEventListener('load', done) + img.addEventListener('error', done) + }) + ) + } + if (pending.length === 0) return Promise.resolve() + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs) + void Promise.all(pending).then(() => { + clearTimeout(timer) + resolve() + }) + }) +} diff --git a/packages/app-core/src/lib/follow-link.test.ts b/packages/app-core/src/lib/follow-link.test.ts index 2320024d..bfcb1bc0 100644 --- a/packages/app-core/src/lib/follow-link.test.ts +++ b/packages/app-core/src/lib/follow-link.test.ts @@ -13,13 +13,14 @@ const state = vi.hoisted(() => ({ ], setFocusedPanel: vi.fn(), editorViewRef: null, - selectNote: vi.fn(), + selectNote: vi.fn(() => Promise.resolve()), assetFiles: [{ path: 'assets/diagram.png' }], openNoteInTab: vi.fn(() => Promise.resolve()) })) const openWikilinkTarget = vi.hoisted(() => vi.fn(() => new Promise(() => undefined))) const offerCreateNoteFromLink = vi.hoisted(() => vi.fn()) +const createNoteFromLinkNow = vi.hoisted(() => vi.fn()) vi.mock('../store', () => ({ useStore: { getState: () => state } @@ -31,7 +32,7 @@ vi.mock('./wikilink-navigation', () => ({ openWikilinkTarget })) -vi.mock('./create-note-from-link', () => ({ offerCreateNoteFromLink })) +vi.mock('./create-note-from-link', () => ({ offerCreateNoteFromLink, createNoteFromLinkNow })) const { followLinkTarget } = await import('./follow-link') @@ -65,3 +66,37 @@ describe('followLinkTarget: wikilinks at vault files (#757)', () => { expect(offerCreateNoteFromLink).toHaveBeenCalledWith('Nowhere') }) }) + +// #768: with the modifier held (or `gD`), a dead link creates its note at the +// suggested path at once; the confirmation is what the modifier answers. +describe('followLinkTarget: creating without asking (#768)', () => { + it('creates the note straight away when asked not to confirm', () => { + offerCreateNoteFromLink.mockClear() + createNoteFromLinkNow.mockClear() + + expect(followLinkTarget('Brand new idea', { createWithoutAsking: true })).toBe(true) + + expect(createNoteFromLinkNow).toHaveBeenCalledWith('Brand new idea') + expect(offerCreateNoteFromLink).not.toHaveBeenCalled() + }) + + it('still asks by default', () => { + offerCreateNoteFromLink.mockClear() + createNoteFromLinkNow.mockClear() + + expect(followLinkTarget('Brand new idea')).toBe(true) + + expect(offerCreateNoteFromLink).toHaveBeenCalledWith('Brand new idea') + expect(createNoteFromLinkNow).not.toHaveBeenCalled() + }) + + it('never creates over an existing note, modifier or not', () => { + createNoteFromLinkNow.mockClear() + state.selectNote.mockClear() + + expect(followLinkTarget('Current', { createWithoutAsking: true })).toBe(true) + + expect(state.selectNote).toHaveBeenCalledWith('inbox/Current.md') + expect(createNoteFromLinkNow).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/lib/follow-link.ts b/packages/app-core/src/lib/follow-link.ts index acdd22bb..9478949c 100644 --- a/packages/app-core/src/lib/follow-link.ts +++ b/packages/app-core/src/lib/follow-link.ts @@ -1,5 +1,5 @@ import { useStore } from '../store' -import { offerCreateNoteFromLink } from './create-note-from-link' +import { createNoteFromLinkNow, offerCreateNoteFromLink } from './create-note-from-link' import { externalFileLink, openExternalFileLink } from './external-file-link' import { externalLinkUrl, resolveInternalNoteHref } from './internal-links' import { openWikilinkAttachment } from './open-wikilink-attachment' @@ -19,8 +19,16 @@ import { * Shared so links follow the same way wherever they're rendered — the main * editor's click / Cmd-click handlers and the WYSIWYG table cell both call this * (#445). Returns true when it handled the target. + * + * With `createWithoutAsking`, a dead link creates its note at the suggested + * path right away instead of asking first: the modifier-click and `gD` fast + * path (#768). */ -export function followLinkTarget(target: string): boolean { +export interface FollowLinkOptions { + createWithoutAsking?: boolean +} + +export function followLinkTarget(target: string, options: FollowLinkOptions = {}): boolean { const external = externalLinkUrl(target) if (external) { window.open(external, '_blank') @@ -58,6 +66,7 @@ export function followLinkTarget(target: string): boolean { } // Dead link — don't leave it a silent dead end. Offer to create the note (with // a confirmation), matching the `gd` follow-link path. (Discord: dead links) - void offerCreateNoteFromLink(target) + if (options.createWithoutAsking) void createNoteFromLinkNow(target) + else void offerCreateNoteFromLink(target) return true } diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 1b317528..836dfa63 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -397,7 +397,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Links are actionable', body: - 'Use [[wikilinks]] or markdown links. Following a link — click it, Cmd/Ctrl-click it, or use the follow-link motion (`gd`) in normal mode — opens the note under the cursor and pins PDFs into the reference pane. If the note does not exist yet, following the link offers to create it (after you confirm) rather than leaving a dead link. Prefix a wikilink with `!` to embed rather than link: `![[Note]]` inlines the target note content in the reading view and PDF export — recursively, with cycle protection — so a master note can pull in sub-notes and export to PDF as one document. `![[image.png]]` embeds an image and `![[drawing.excalidraw]]` embeds an Excalidraw drawing as a PNG preview; both take optional `|width` or `|WxH` size hints (`![[image.png|300]]`, `![[image.png|600x400]]`), and the markdown form carries the same hint after the alt text (`![caption|300](image.png)`). In the editor, drag the handle on a picture\'s right edge to resize it; the width is written back as that hint, so the reading view, exports and Obsidian all show the same size. Resize Image… in the command palette (or `:imgwidth 480` in Vim mode, `:imgw auto` to reset) sets it by number for the image under the cursor. Right-click a web link or an email address, in the editor or the reading view, to open it or copy it (the address itself, without `mailto:`); `gy` in normal mode, or Copy Link Under Cursor in the palette, copies the one under the caret.' + 'Use [[wikilinks]] or markdown links. Following a link — click it, Cmd/Ctrl-click it, or use the follow-link motion (`gd`) in normal mode — opens the note under the cursor and pins PDFs into the reference pane. If the note does not exist yet, following the link offers to create it (after you confirm) rather than leaving a dead link; hold Cmd (macOS) or Ctrl (Windows/Linux) while clicking the link, or use `gD` in normal mode, to create it at once at the suggested path: Inbox, named after the link text. A link at a note that does not exist yet is drawn muted with a dashed underline, in the editor and in the reading view, so you can tell live links from ones that would create a note. Prefix a wikilink with `!` to embed rather than link: `![[Note]]` inlines the target note content in the reading view and PDF export — recursively, with cycle protection — so a master note can pull in sub-notes and export to PDF as one document. `![[image.png]]` embeds an image and `![[drawing.excalidraw]]` embeds an Excalidraw drawing as a PNG preview; both take optional `|width` or `|WxH` size hints (`![[image.png|300]]`, `![[image.png|600x400]]`), and the markdown form carries the same hint after the alt text (`![caption|300](image.png)`). In the editor, drag the handle on a picture\'s right edge to resize it; the width is written back as that hint, so the reading view, exports and Obsidian all show the same size. Resize Image… in the command palette (or `:imgwidth 480` in Vim mode, `:imgw auto` to reset) sets it by number for the image under the cursor. Right-click a web link or an email address, in the editor or the reading view, to open it or copy it (the address itself, without `mailto:`); `gy` in normal mode, or Copy Link Under Cursor in the palette, copies the one under the caret.' }, { title: 'Point at one block, not a whole note', @@ -896,6 +896,11 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Follow the link under the cursor', detail: 'Open wikilinks, open external links, create missing notes, or pin PDFs into the reference pane.' }, + { + command: 'gD', + summary: 'Create the note for the link under the cursor', + detail: 'Like `gd`, but a link at a note that does not exist yet creates it at once at the suggested path (Inbox, named after the link) instead of asking first. The keyboard twin of Cmd/Ctrl-clicking an unresolved wikilink.' + }, { command: 'gy', summary: 'Copy the link under the cursor', diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index d1e9ce4d..d8701276 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -85,6 +85,7 @@ export type KeymapId = | "vim.tabNext" | "vim.hintMode" | "vim.goToDefinition" + | "vim.createNoteFromLink" | "vim.foldCurrent" | "vim.unfoldCurrent" | "vim.foldAll" @@ -883,6 +884,18 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ vimOnly: true, maxTokens: 2, }, + { + id: "vim.createNoteFromLink", + kind: "sequence", + scope: "vim-editor", + group: "vim", + title: "Create note for link at cursor", + description: + "Follow the link under the cursor; a note that does not exist yet is created at once, without the confirmation.", + defaultBinding: "g D", + vimOnly: true, + maxTokens: 2, + }, { id: "vim.foldCurrent", kind: "sequence", diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 972a8485..229fd1ab 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -5538,6 +5538,23 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite .cm-wysiwyg .cm-editor .cm-wikilink:hover { text-decoration-color: rgb(var(--z-accent)); } +/* Unresolved target: muted with a dashed underline, the reading view's + `a.wikilink.broken` look, so a link that would create a note reads apart + from one that opens a note (#768). */ +.cm-wysiwyg .cm-editor .cm-wikilink.cm-wikilink-broken { + text-decoration-style: dashed; + text-decoration-color: theme("colors.ink.400"); +} +/* The label's own highlight spans (the link token inside `[[ ]]`) carry the + accent color themselves, so the muted color has to reach them too; the + parent's value alone never wins over a child's own declaration. */ +.cm-wysiwyg .cm-editor .cm-wikilink.cm-wikilink-broken, +.cm-wysiwyg .cm-editor .cm-wikilink.cm-wikilink-broken * { + color: theme("colors.ink.500"); +} +.cm-wysiwyg .cm-editor .cm-wikilink.cm-wikilink-broken:hover { + text-decoration-color: theme("colors.ink.500"); +} /* Revealed `[[ ]]` / `|` markers when editing a wikilink — quiet grey, upright, no underline (the inner brackets otherwise inherit the italic/underline link highlight, which left one bracket slanted). */ diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 1e70ee64..25e0b592 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.48.0", + "version": "2.49.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index a54b1977..3a7f5c9f 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.48.0", + "version": "2.49.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index 95a93eff..f6787b5a 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -117,6 +117,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "vim.tabNext", group: "vim", defaultBinding: "g t", title: "Next tab" }, { 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" }, diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 5bdc39ee..cda698d7 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.48.0", + "version": "2.49.0", "type": "module", "exports": { ".": "./src/index.ts"