diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c2bc7b40..4f846b6a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.26.0", + "version": "2.27.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/server/package.json b/apps/server/package.json index bb02b063..d93887ae 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.26.0", + "version": "2.27.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 5e0f1a1b..b319a084 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.26.0", + "version": "2.27.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index 0293b392..ec41017d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.26.0", + "version": "2.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.26.0", + "version": "2.27.0", "workspaces": [ "apps/*", "packages/*" @@ -20,7 +20,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.26.0", + "version": "2.27.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -99,11 +99,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.26.0" + "version": "2.27.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.26.0", + "version": "2.27.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -17123,7 +17123,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.26.0", + "version": "2.27.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -17187,18 +17187,18 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.26.0" + "version": "2.27.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.26.0", + "version": "2.27.0", "dependencies": { "lz-string": "^1.5.0" } }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.26.0" + "version": "2.27.0" } } } diff --git a/package.json b/package.json index 29861b14..086e838a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.26.0", + "version": "2.27.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 f4fd0780..1e23c3f7 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.26.0", + "version": "2.27.0", "type": "module", "exports": { "./main": "./src/main.tsx" diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index a0375b5a..890bf06f 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -24,7 +24,14 @@ import { ServerDirectoryPickerHost } from './components/ServerDirectoryPickerHos import { ToastHost } from './components/ui' import { ExcalidrawEmbedMenuHost } from './components/ExcalidrawEmbedMenuHost' import { resolveQuickNoteTitle } from './lib/quick-note-title' -import { isMacPlatform, matchesShortcut, matchesSequenceToken } from './lib/keymaps' +import { + eventMatchesUserOverride, + isMacPlatform, + matchesShortcut, + matchesSequenceToken, + TAB_SELECT_KEYMAP_IDS +} from './lib/keymaps' +import { selectActiveBuffer } from './lib/buffer-navigation' import { focusPaneOrEdgePanel } from './lib/pane-nav' import { activatePanelRow, @@ -696,6 +703,36 @@ function App(): JSX.Element { state.setWordWrap(!state.wordWrap) return } + // Alt+1..9 (⌃1..9 on macOS): jump straight to tab N (#497). Position + // counts across panes in the same order gt cycles through. Bails while + // a modal, palette, menu, or Settings (with its keymap recorder) is + // open, per the house rule for global key handlers: switching the tab + // under an overlay strands the user on a different note than they left. + const tabSelectBlocked = + state.settingsOpen || + state.searchOpen || + state.vaultTextSearchOpen || + state.commandPaletteOpen || + state.bufferPaletteOpen || + state.templatePaletteOpen || + state.embedDrawingPaletteOpen || + state.outlinePaletteOpen || + document.querySelector('[data-ctx-menu]') || + document.querySelector('[data-prompt-modal]') || + document.querySelector('[data-confirm-modal]') + if (!tabSelectBlocked) { + for (let i = 0; i < TAB_SELECT_KEYMAP_IDS.length; i += 1) { + const id = TAB_SELECT_KEYMAP_IDS[i] + if (!matchesShortcut(e, overrides, id)) continue + // These defaults were inserted mid-handler: when the combination is + // one the user explicitly rebound to another action (checked later + // in this chain), the rebind wins over the shipped default. + if (!overrides[id] && eventMatchesUserOverride(e, overrides, id)) continue + e.preventDefault() + selectActiveBuffer(state, i + 1) + return + } + } if (matchesShortcut(e, overrides, 'global.exportNotePdf')) { e.preventDefault() void state.exportActiveNotePdf() diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 9e7c74fa..7f0af9d9 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -46,7 +46,7 @@ import { type KeymapId, type KeymapOverrides } from '../lib/keymaps' -import { navigateActiveBuffer } from '../lib/buffer-navigation' +import { navigateActiveBuffer, selectActiveBuffer } from '../lib/buffer-navigation' import { applyVimInsertEscape } from '../lib/vim-insert-escape' import { listContinuationPrefix } from '../lib/list-continuation' import { focusEditorNormalMode } from '../lib/editor-focus' @@ -633,12 +633,26 @@ function registerVimCommands(): void { Vim.defineAction('focusPaneRight', () => { focusPaneOrEdgePanel('l') }) - Vim.defineAction('previousBuffer', () => { - navigateActiveBuffer(useStore.getState(), -1) - }) - Vim.defineAction('nextBuffer', () => { - navigateActiveBuffer(useStore.getState(), 1) - }) + // {count}gt goes straight to tab {count}, vim's absolute jump; without a + // count it keeps cycling. {count}gT is relative, vim-style: count tabs + // back. (#497) + Vim.defineAction( + 'previousBuffer', + (_cm: unknown, actionArgs?: { repeat?: number; repeatIsExplicit?: boolean }) => { + const repeat = actionArgs?.repeatIsExplicit ? actionArgs.repeat ?? 1 : 1 + navigateActiveBuffer(useStore.getState(), -repeat) + } + ) + Vim.defineAction( + 'nextBuffer', + (_cm: unknown, actionArgs?: { repeat?: number; repeatIsExplicit?: boolean }) => { + if (actionArgs?.repeatIsExplicit && actionArgs.repeat) { + selectActiveBuffer(useStore.getState(), actionArgs.repeat) + return + } + navigateActiveBuffer(useStore.getState(), 1) + } + ) registerVimNoteCommands() registerCommandPaletteEx() @@ -850,10 +864,15 @@ function registerVimNoteCommands(): void { } Vim.defineEx('qall', 'qa', closeEveryTab) Vim.defineEx('quitall', 'quitall', closeEveryTab) - // :xa / :wa are just aliases for qall in this context (nothing to flush - // that autosave doesn't already handle). + // Quit-and-write variants close everything, like vim's :xa / :wqa. But + // :wa is a SAVE, not a quit: it used to alias qall here on the theory + // that autosave leaves nothing to flush, and every vim user's muscle + // memory (":wa after any change") nuked their tab layout instead (#569). Vim.defineEx('xall', 'xa', closeEveryTab) - Vim.defineEx('wall', 'wa', closeEveryTab) + Vim.defineEx('wqall', 'wqa', closeEveryTab) + Vim.defineEx('wall', 'wa', () => { + void useStore.getState().flushDirtyNotes() + }) Vim.defineEx('help', 'h', () => { void useStore.getState().openHelpView() @@ -964,6 +983,8 @@ const MANUAL_EX_NAMES = new Set([ 'quitall', 'xall', 'xa', + 'wqall', + 'wqa', 'wall', 'wa', 'help', diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 520744a4..11548b7e 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -62,6 +62,7 @@ import { hopMarkerBackward, hopMarkerForward } from '../lib/cm-marker-hop' import { toggleCheckbox } from '../lib/cm-toggle-checkbox' import { completionKeymapForEditor, completionNavKeymap } from '../lib/cm-completion-nav' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from '../lib/cm-vim-default-keymap' +import { isVimAwaitingArgument } from '../lib/vim-nav' import { toCodeMirrorKey, vimHalfPageKeymap } from '../lib/vim-half-page-keymap' import { scrollOff } from '../lib/cm-scrolloff' import { followLinkTarget } from '../lib/follow-link' @@ -1865,7 +1866,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const cm = getCM(view) const insertMode = !!cm?.state.vim?.insertMode const sel = view.state.selection.main - if (!insertMode && !sel.empty) { + // A pending sequence owns the next key: `v f m` jumps to the + // next `m`, it does not open the menu (#568). + if (!insertMode && !sel.empty && !isVimAwaitingArgument(view)) { event.preventDefault() event.stopPropagation() openEditorContextMenu() diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index f60472e3..95941033 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -1059,11 +1059,18 @@ export function VimNav(): JSX.Element | null { } } + // A pending Vim sequence owns the next character: after `f`/`t`/`r` (or + // a count or register prefix), `m` is the operand, not the menu key. + // This runs on window capture, so without the guard Vim never even saw + // the key and the orphaned motion swallowed the next one (#568). The + // native context-menu key is not a character and stays available. const wantsEditorTextContextMenu = isEditorFocused(state.editorViewRef) && !editorInsertMode && !state.editorViewRef?.state.selection.main.empty && - (matchesSequenceToken(e, overrides, 'nav.contextMenu') || wantsNativeContextMenuKey(e)) + ((matchesSequenceToken(e, overrides, 'nav.contextMenu') && + !isVimAwaitingArgument(state.editorViewRef)) || + wantsNativeContextMenuKey(e)) if (wantsEditorTextContextMenu) { e.preventDefault() e.stopImmediatePropagation() @@ -1137,7 +1144,10 @@ export function VimNav(): JSX.Element | null { const wantsTextContextMenu = hasEditorSelection && !isEditorInsertMode(state.editorViewRef, state.vimMode) && - (matchesSequenceToken(e, overrides, 'nav.contextMenu') || wantsNativeContextMenuKey(e)) + // Same #568 guard as above: a pending f/t/r owns the character. + ((matchesSequenceToken(e, overrides, 'nav.contextMenu') && + !isVimAwaitingArgument(state.editorViewRef)) || + wantsNativeContextMenuKey(e)) if (wantsTextContextMenu) { e.preventDefault() e.stopImmediatePropagation() diff --git a/packages/app-core/src/lib/buffer-navigation.test.ts b/packages/app-core/src/lib/buffer-navigation.test.ts index 8b345b93..85d3e01e 100644 --- a/packages/app-core/src/lib/buffer-navigation.test.ts +++ b/packages/app-core/src/lib/buffer-navigation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { NoteMeta } from '@shared/ipc' import type { PaneLayout } from './pane-layout' -import { getBufferNavigationTarget } from './buffer-navigation' +import { getBufferNavigationTarget, getBufferSelectTarget } from './buffer-navigation' function note(path: string, updatedAt: number, folder: NoteMeta['folder'] = 'inbox'): Pick { return { path, folder, updatedAt } @@ -64,4 +64,52 @@ describe('getBufferNavigationTarget', () => { kind: 'create-quick' }) }) + + it('walks back multiple tabs for {count}gT, wrapping past the start', () => { + const layout = leaf('pane-a', ['one.md', 'two.md', 'three.md'], 'two.md') + + // 4 back from index 1 in a 3-tab ring lands on index 0. + expect(getBufferNavigationTarget(layout, 'pane-a', [], -4)).toEqual({ + kind: 'focus', + paneId: 'pane-a', + path: 'one.md' + }) + }) +}) + +describe('getBufferSelectTarget (#497)', () => { + it('selects the Nth tab, counted across panes in cycle order', () => { + const layout: PaneLayout = { + kind: 'split', + id: 'root', + direction: 'row', + sizes: [0.5, 0.5], + children: [ + leaf('pane-a', ['one.md', 'two.md'], 'one.md'), + leaf('pane-b', ['three.md'], 'three.md') + ] + } + + expect(getBufferSelectTarget(layout, 'pane-a', 3)).toEqual({ + kind: 'focus', + paneId: 'pane-b', + path: 'three.md' + }) + }) + + it('clamps a too-large index to the last tab, like a big {count}gt in vim', () => { + const layout = leaf('pane-a', ['one.md', 'two.md'], 'one.md') + + expect(getBufferSelectTarget(layout, 'pane-a', 9)).toEqual({ + kind: 'focus', + paneId: 'pane-a', + path: 'two.md' + }) + }) + + it('targets open tabs only, never the recent-notes fallback', () => { + const layout = leaf('pane-a', [], null) + + expect(getBufferSelectTarget(layout, 'pane-a', 1)).toEqual({ kind: 'none' }) + }) }) diff --git a/packages/app-core/src/lib/buffer-navigation.ts b/packages/app-core/src/lib/buffer-navigation.ts index 23a31db2..d5f7f197 100644 --- a/packages/app-core/src/lib/buffer-navigation.ts +++ b/packages/app-core/src/lib/buffer-navigation.ts @@ -22,15 +22,10 @@ export interface BufferNavigationRuntime { ) => Promise } -export function getBufferNavigationTarget( - paneLayout: PaneLayout, - activePaneId: string, - notes: BufferNote[], - delta: 1 | -1 -): BufferNavigationTarget { - const leaf = findLeaf(paneLayout, activePaneId) - if (!leaf) return { kind: 'none' } - +/** The order gt/gT cycle through and {count}gt / Alt+digits index into: every + * pane's tabs, deduped, in pane-tree order. One list so cycling and direct + * selection can never disagree about which tab is "number 3". */ +function openTabOrder(paneLayout: PaneLayout): string[] { const seen = new Set() const order: string[] = [] for (const candidate of allLeaves(paneLayout)) { @@ -40,6 +35,38 @@ export function getBufferNavigationTarget( order.push(path) } } + return order +} + +function targetFor( + paneLayout: PaneLayout, + leafId: string, + leafTabs: string[], + path: string +): BufferNavigationTarget { + const owningLeaf = allLeaves(paneLayout).find((candidate) => + candidate.tabs.includes(path) + ) + if (owningLeaf && owningLeaf.id !== leafId) { + return { kind: 'focus', paneId: owningLeaf.id, path } + } + if (leafTabs.includes(path)) { + return { kind: 'focus', paneId: leafId, path } + } + return { kind: 'open', paneId: leafId, path } +} + +export function getBufferNavigationTarget( + paneLayout: PaneLayout, + activePaneId: string, + notes: BufferNote[], + delta: number +): BufferNavigationTarget { + const leaf = findLeaf(paneLayout, activePaneId) + if (!leaf) return { kind: 'none' } + + const order = openTabOrder(paneLayout) + const seen = new Set(order) if (order.length < 2) { const fallback = notes @@ -57,32 +84,31 @@ export function getBufferNavigationTarget( const baseIndex = leaf.activeTab ? order.indexOf(leaf.activeTab) : -1 const startIndex = baseIndex >= 0 ? baseIndex : 0 - const nextIndex = (startIndex + delta + order.length) % order.length - const nextPath = order[nextIndex] - const owningLeaf = allLeaves(paneLayout).find((candidate) => - candidate.tabs.includes(nextPath) - ) - - if (owningLeaf && owningLeaf.id !== leaf.id) { - return { kind: 'focus', paneId: owningLeaf.id, path: nextPath } - } - if (leaf.tabs.includes(nextPath)) { - return { kind: 'focus', paneId: leaf.id, path: nextPath } - } - return { kind: 'open', paneId: leaf.id, path: nextPath } + // Proper modulo: {count}gT walks back count tabs, which can pass -length. + const nextIndex = (((startIndex + delta) % order.length) + order.length) % order.length + return targetFor(paneLayout, leaf.id, leaf.tabs, order[nextIndex]) } -export function navigateActiveBuffer( - runtime: BufferNavigationRuntime, - delta: 1 | -1 -): void { - const target = getBufferNavigationTarget( - runtime.paneLayout, - runtime.activePaneId, - runtime.notes, - delta - ) +/** Direct selection for {count}gt and the Alt+digit shortcuts: 1-based index + * into the open-tab order. An index past the end lands on the last tab, the + * same forgiving read vim gives a too-large {count}gt. Never falls back to + * recent notes: "tab 3" means an open tab or nothing. */ +export function getBufferSelectTarget( + paneLayout: PaneLayout, + activePaneId: string, + index: number +): BufferNavigationTarget { + const leaf = findLeaf(paneLayout, activePaneId) + if (!leaf) return { kind: 'none' } + + const order = openTabOrder(paneLayout) + if (order.length === 0) return { kind: 'none' } + + const clamped = Math.min(Math.max(Math.trunc(index), 1), order.length) + return targetFor(paneLayout, leaf.id, leaf.tabs, order[clamped - 1]) +} +function applyTarget(runtime: BufferNavigationRuntime, target: BufferNavigationTarget): void { if (target.kind === 'focus') { void runtime.focusTabInPane(target.paneId, target.path) return @@ -95,3 +121,20 @@ export function navigateActiveBuffer( void runtime.createAndOpen('quick', '', { focusTitle: true }) } } + +export function navigateActiveBuffer( + runtime: BufferNavigationRuntime, + delta: number +): void { + applyTarget( + runtime, + getBufferNavigationTarget(runtime.paneLayout, runtime.activePaneId, runtime.notes, delta) + ) +} + +export function selectActiveBuffer(runtime: BufferNavigationRuntime, index: number): void { + applyTarget( + runtime, + getBufferSelectTarget(runtime.paneLayout, runtime.activePaneId, index) + ) +} diff --git a/packages/app-core/src/lib/cm-live-preview.ts b/packages/app-core/src/lib/cm-live-preview.ts index 051f36e4..06b7125c 100644 --- a/packages/app-core/src/lib/cm-live-preview.ts +++ b/packages/app-core/src/lib/cm-live-preview.ts @@ -23,6 +23,7 @@ import { openVaultAssetExternally } from './external-file-link' import { getExcalidrawPreview, parseEmbedSizeHint, + splitEmbedLabel, resolveExcalidrawEmbedPath } from './excalidraw-preview' @@ -80,6 +81,9 @@ type ParsedImage = { resolvedUrl: string /** Asset mtime, part of the image cache key so an edited file reloads. (#472) */ version: number + /** Obsidian-style `|600` / `|600x400` size hint from the label. (#570) */ + width?: number + height?: number } type ParsedPdf = { @@ -240,11 +244,14 @@ function parseStandaloneLocalImage(lineText: string): ParsedImage | null { if (classifyLocalAssetHref(href) !== 'image') return null const resolvedUrl = resolveLocalAssetUrl(state.vault?.root, state.activeNote?.path, href) if (!resolvedUrl) return null + const { alt, size } = splitEmbedLabel(fromMarkdown[1], 'markdown') return { - alt: (fromMarkdown[1] ?? '').trim(), + alt, href, resolvedUrl, - version: assetVersionFor(href) + version: assetVersionFor(href), + width: size?.width, + height: size?.height } } @@ -254,11 +261,14 @@ function parseStandaloneLocalImage(lineText: string): ParsedImage | null { if (classifyLocalAssetHref(href) !== 'image') return null const resolvedUrl = resolveLocalAssetUrl(state.vault?.root, state.activeNote?.path, href) if (!resolvedUrl) return null + const { alt, size } = splitEmbedLabel(fromEmbed[2], 'wikilink') return { - alt: (fromEmbed[2] ?? '').trim(), + alt, href, resolvedUrl, - version: assetVersionFor(href) + version: assetVersionFor(href), + width: size?.width, + height: size?.height } } @@ -338,7 +348,9 @@ class LocalImageWidget extends WidgetType { private readonly alt: string, private readonly href: string, private readonly resolvedUrl: string, - private readonly version: number + private readonly version: number, + private readonly width?: number, + private readonly height?: number ) { super() } @@ -352,7 +364,9 @@ class LocalImageWidget extends WidgetType { other.alt === this.alt && other.href === this.href && other.resolvedUrl === this.resolvedUrl && - other.version === this.version + other.version === this.version && + other.width === this.width && + other.height === this.height ) } @@ -402,6 +416,28 @@ class LocalImageWidget extends WidgetType { image.alt = this.alt image.loading = 'lazy' image.draggable = false + // Obsidian-style size hints (#570). The attribute carries the semantic + // size, but the embed class stretches images to width: 100% and + // presentational attributes lose to any CSS rule, so the hint also goes + // on as inline style. The class's max-width: 100% still caps a hint + // wider than the pane. The cache key above is url|mtime, not the hint, + // so a reused element may still carry the size of a previous render + // (hint edited away, or the same file embedded elsewhere with another + // hint): clear whatever this widget does not set. + if (this.width) { + image.width = this.width + image.style.width = `${this.width}px` + } else { + image.removeAttribute('width') + image.style.removeProperty('width') + } + if (this.height) { + image.height = this.height + image.style.height = `${this.height}px` + } else { + image.removeAttribute('height') + image.style.removeProperty('height') + } const topControls = document.createElement('div') topControls.className = 'local-image-embed-controls local-image-embed-controls-top' @@ -993,7 +1029,9 @@ function computeDecorations(view: EditorView): DecorationSet { parsedImage.alt, parsedImage.href, parsedImage.resolvedUrl, - parsedImage.version + parsedImage.version, + parsedImage.width, + parsedImage.height ) }) }) diff --git a/packages/app-core/src/lib/cm-template-variables.test.ts b/packages/app-core/src/lib/cm-template-variables.test.ts new file mode 100644 index 00000000..c1db252d --- /dev/null +++ b/packages/app-core/src/lib/cm-template-variables.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { CompletionContext } from '@codemirror/autocomplete' +import { EditorSelection, EditorState } from '@codemirror/state' +import { templateVariableApplySpec, templateVariableSource } from './cm-template-variables' + +function state(doc: string, anchor = doc.length): EditorState { + return EditorState.create({ doc, selection: EditorSelection.cursor(anchor) }) +} + +function applied(doc: string, from: number, to: number, insert: string): EditorState { + const current = state(doc, to) + return current.update(templateVariableApplySpec(current, from, to, insert)).state +} + +describe('templateVariableApplySpec', () => { + it('swallows the auto-paired closers left after the caret (#566)', () => { + // Auto-pair turned `{{` into `{{}}` with the caret in the middle; the + // completion's insert brings its own closers. + const next = applied('{{}}', 0, 2, '{{cursor}}') + + expect(next.doc.toString()).toBe('{{cursor}}') + expect(next.selection.main.head).toBe('{{cursor}}'.length) + }) + + it('swallows closers when completing inside an existing pair', () => { + // Editing `{{date}}` down to `{{ti|}}` and accepting {{time}} must not + // leave the old pair's braces behind. + const next = applied('{{ti}}', 0, 4, '{{time}}') + + expect(next.doc.toString()).toBe('{{time}}') + }) + + it('swallows a single stray closer', () => { + const next = applied('{{cu}', 0, 4, '{{cursor}}') + + expect(next.doc.toString()).toBe('{{cursor}}') + }) + + it('replaces only the typed token when nothing follows (auto-pair off)', () => { + const next = applied('{{cu', 0, 4, '{{cursor}}') + + expect(next.doc.toString()).toBe('{{cursor}}') + expect(next.selection.main.head).toBe('{{cursor}}'.length) + }) + + it('leaves unrelated trailing text alone', () => { + const next = applied('{{cu after', 0, 4, '{{cursor}}') + + expect(next.doc.toString()).toBe('{{cursor}} after') + }) + + it('never consumes more than the two closers the insert provides', () => { + const next = applied('{{cu}}}}', 0, 4, '{{cursor}}') + + expect(next.doc.toString()).toBe('{{cursor}}}}') + }) + + it('leaves closers that belong to an earlier open construct alone', () => { + // Prose documenting Handlebars/Jinja syntax: the `}}` after the caret + // closes `{{var`, so accepting the completion must not delete it. + const next = applied('{{var{{da}}', 5, 9, '{{date}}') + + expect(next.doc.toString()).toBe('{{var{{date}}}}') + }) + + it('still swallows closers when an earlier pair on the line is already closed', () => { + const next = applied('{{a}} {{cu}}', 6, 10, '{{cursor}}') + + expect(next.doc.toString()).toBe('{{a}} {{cursor}}') + }) +}) + +describe('templateVariableSource', () => { + it('offers variables once {{ is typed and anchors the result at the braces', () => { + const doc = 'Line\n{{cu' + const result = templateVariableSource(new CompletionContext(state(doc), doc.length, false)) + + expect(result).not.toBeNull() + expect(result!.from).toBe(doc.length - '{{cu'.length) + expect(result!.options.map((option) => option.label)).toEqual(['{{cursor}}']) + }) + + it('stays quiet outside a {{ token', () => { + const doc = 'plain text' + expect(templateVariableSource(new CompletionContext(state(doc), doc.length, false))).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/cm-template-variables.ts b/packages/app-core/src/lib/cm-template-variables.ts index f61a5240..7971603c 100644 --- a/packages/app-core/src/lib/cm-template-variables.ts +++ b/packages/app-core/src/lib/cm-template-variables.ts @@ -1,4 +1,5 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete' +import type { EditorState, TransactionSpec } from '@codemirror/state' import type { EditorView } from '@codemirror/view' export interface TemplateVariable { @@ -22,6 +23,37 @@ export const TEMPLATE_VARIABLES: TemplateVariable[] = [ { name: 'cursor', insert: '{{cursor}}', detail: 'Where the caret lands' } ] +/** + * Builds the change that applies a chosen variable. The inserted text carries + * its own `}}`, so the replaced range swallows up to two closing braces + * already sitting at the caret: with auto-pair brackets on, typing `{{` + * produced `{{}}` around the caret, and completing inside an existing + * `{{…}}` pair hits the same leftover (#566). The closers are only swallowed + * when they can belong to this variable's own `{{`: if an earlier `{{` on + * the line is still open (prose documenting Handlebars/Jinja syntax), the + * braces after the caret are its closers, literal note content that must + * survive the completion. Kept separate from the completion option so it can + * be tested without a mounted editor. + */ +export function templateVariableApplySpec( + state: EditorState, + from: number, + to: number, + insert: string +): TransactionSpec { + const line = state.doc.lineAt(from) + const prefix = state.doc.sliceString(line.from, from) + const openBefore = (prefix.match(/\{\{/g) ?? []).length + const closedBefore = (prefix.match(/\}\}/g) ?? []).length + const ownsClosers = openBefore <= closedBefore + const after = state.doc.sliceString(to, to + 2) + const consumed = !ownsClosers ? 0 : after.startsWith('}}') ? 2 : after.startsWith('}') ? 1 : 0 + return { + changes: { from, to: to + consumed, insert }, + selection: { anchor: from + insert.length } + } +} + /** * CodeMirror autocomplete source for template `{{variables}}`. Triggers once * `{{` has been typed and replaces the partial token with the full `{{…}}`. @@ -46,10 +78,7 @@ export function templateVariableSource(context: CompletionContext): CompletionRe // these rows render with the same icon/label/detail layout. _icon: '{}', apply: (view: EditorView, _completion: Completion, _from: number, to: number) => { - view.dispatch({ - changes: { from, to, insert: variable.insert }, - selection: { anchor: from + variable.insert.length } - }) + view.dispatch(templateVariableApplySpec(view.state, from, to, variable.insert)) } }) as Completion ) diff --git a/packages/app-core/src/lib/excalidraw-preview.test.ts b/packages/app-core/src/lib/excalidraw-preview.test.ts index d4af4c55..e5b5efe5 100644 --- a/packages/app-core/src/lib/excalidraw-preview.test.ts +++ b/packages/app-core/src/lib/excalidraw-preview.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest' -import { parseEmbedSizeHint, resolveExcalidrawEmbedPath } from './excalidraw-preview' +import { + parseEmbedSizeHint, + resolveExcalidrawEmbedPath, + splitEmbedLabel +} from './excalidraw-preview' describe('parseEmbedSizeHint', () => { it('parses a bare width', () => { @@ -24,6 +28,63 @@ describe('parseEmbedSizeHint', () => { it('trims whitespace before matching', () => { expect(parseEmbedSizeHint(' 800 ')).toEqual({ width: 800, height: undefined }) }) + + it('rejects zero dimensions instead of half-applying them', () => { + // `|0x300` used to eat the caption, skip the falsy width downstream, and + // set only the height, distorting the image. + expect(parseEmbedSizeHint('0')).toBeNull() + expect(parseEmbedSizeHint('0x300')).toBeNull() + expect(parseEmbedSizeHint('300x0')).toBeNull() + }) +}) + +describe('splitEmbedLabel (#570)', () => { + it('treats a pure size label as hint only in the wikilink form', () => { + expect(splitEmbedLabel('100x50', 'wikilink')).toEqual({ + alt: '', + size: { width: 100, height: 50 } + }) + }) + + it('keeps a purely numeric markdown alt as the caption', () => { + // `![2024](chart.png)`: 2024 is a caption (a year), not a resize to + // 2024px. Sizing a markdown image needs the pipe: `![|2024](chart.png)`. + expect(splitEmbedLabel('2024', 'markdown')).toEqual({ alt: '2024', size: null }) + expect(splitEmbedLabel('|2024', 'markdown')).toEqual({ + alt: '', + size: { width: 2024, height: undefined } + }) + }) + + it('splits a trailing hint off a caption', () => { + expect(splitEmbedLabel('cognitive web|300', 'markdown')).toEqual({ + alt: 'cognitive web', + size: { width: 300, height: undefined } + }) + }) + + it('keeps pipes inside the caption and consumes only the last segment', () => { + expect(splitEmbedLabel('a|b|600x400', 'wikilink')).toEqual({ + alt: 'a|b', + size: { width: 600, height: 400 } + }) + }) + + it('leaves captions without a valid hint alone', () => { + expect(splitEmbedLabel('just a caption', 'wikilink')).toEqual({ + alt: 'just a caption', + size: null + }) + expect(splitEmbedLabel('trailing|600x', 'wikilink')).toEqual({ + alt: 'trailing|600x', + size: null + }) + expect(splitEmbedLabel('caption|0x300', 'wikilink')).toEqual({ + alt: 'caption|0x300', + size: null + }) + expect(splitEmbedLabel('', 'wikilink')).toEqual({ alt: '', size: null }) + }) }) describe('resolveExcalidrawEmbedPath', () => { diff --git a/packages/app-core/src/lib/excalidraw-preview.ts b/packages/app-core/src/lib/excalidraw-preview.ts index 8225b099..94afe5e9 100644 --- a/packages/app-core/src/lib/excalidraw-preview.ts +++ b/packages/app-core/src/lib/excalidraw-preview.ts @@ -11,14 +11,50 @@ export interface EmbedSize { height?: number } -/** Parse an Obsidian-style embed size hint: `600`, `600x400`. */ +/** Parse an Obsidian-style embed size hint: `600`, `600x400`. Shared by + * Excalidraw AND image embeds (#570); it just happens to live here because + * drawings grew size hints first. */ const SIZE_HINT_RE = /^(\d+)(?:x(\d+))?$/ export function parseEmbedSizeHint(hint: string | null | undefined): EmbedSize | null { if (!hint) return null const m = hint.trim().match(SIZE_HINT_RE) if (!m) return null - return { width: Number(m[1]), height: m[2] ? Number(m[2]) : undefined } + const width = Number(m[1]) + const height = m[2] ? Number(m[2]) : undefined + // A zero dimension is not a resize. Treating `|0` or `|0x300` as a hint + // used to eat the caption and then skip the zero at the falsy checks + // downstream, distorting the image; an invalid hint stays a caption. + if (width < 1 || (height !== undefined && height < 1)) return null + return { width, height } +} + +/** Split an embed label into its caption and a trailing size hint, covering + * every Obsidian spelling: `caption|600` (from `![caption|600](img)` alt + * text or `![[img|caption|600]]`), and plain captions with no hint. Pipes + * inside the caption survive; only a LAST segment that parses as a size is + * consumed. The whole-label form (`600x400` from `![[img|600x400]]`) is a + * hint only for `source: 'wikilink'`: in standard markdown the alt is the + * author's caption, so `![2024](chart.png)` keeps its numeric alt instead + * of being resized to 2024px (write `![|2024](chart.png)` to size). (#570) */ +export function splitEmbedLabel( + label: string | null | undefined, + source: 'wikilink' | 'markdown' +): { + alt: string + size: EmbedSize | null +} { + const raw = (label ?? '').trim() + if (!raw) return { alt: '', size: null } + if (source === 'wikilink') { + const wholeSize = parseEmbedSizeHint(raw) + if (wholeSize) return { alt: '', size: wholeSize } + } + const pipeAt = raw.lastIndexOf('|') + if (pipeAt < 0) return { alt: raw, size: null } + const size = parseEmbedSizeHint(raw.slice(pipeAt + 1)) + if (!size) return { alt: raw, size: null } + return { alt: raw.slice(0, pipeAt).trim(), size } } interface CacheEntry { diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 99f8b289..f4c1755b 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -382,7 +382,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.' + '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)`).' }, { title: 'Files stay local', @@ -468,6 +468,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Mod+.', action: 'Toggle Zen mode', detail: 'Hide or restore the app chrome so only the active editor, preview, or split view stays on screen.' }, { keys: 'Mod+W', action: 'Close active tab', detail: 'Close the current note or virtual tab.' }, { keys: 'Ctrl+Tab', action: 'Switch to previous note', detail: 'Switch to the most recently used note. Press again to alternate between the last two notes.' }, + { keys: 'Alt+1 … Alt+9', action: 'Go to tab 1 through 9', detail: 'Jump straight to a tab by position, browser-style (Ctrl+1 … Ctrl+9 on macOS, where Option types characters and the ⌘ digits are taken). Tab numbers count across panes in the same order gt cycles; rebindable under Settings → Keymaps. Vim users get the same jump as {count}gt. Heads-up for macOS with multiple Spaces: Mission Control claims Ctrl+digit for Switch to Desktop, so rebind here or free the key under System Settings → Keyboard Shortcuts.' }, { keys: 'Shift+Mod+T', action: 'Reopen closed tab', detail: 'Reopen the most recently closed tab, restoring its position and pinned state. Repeat to walk back through your close history.' }, { keys: 'Mod+O', action: 'Open file', detail: 'Desktop only: pick a Markdown file with the native dialog. A file inside a known vault opens against that vault; anything else opens in a standalone external-file window.' }, { keys: 'Mod+4 / Mod+5 / Mod+6', action: 'Edit / Split / Preview mode', detail: 'Switch the active note between the raw editor, side-by-side split, and rendered preview.' }, @@ -771,9 +772,14 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ detail: 'Keep only the active tab in the current pane.' }, { - command: ':qa / :quitall / :xa / :wa', + command: ':qa / :quitall / :xa / :wqa', summary: 'Close every tab everywhere', - detail: 'Closes all tabs across all panes. The write aliases act the same way here.' + detail: 'Closes all tabs across all panes. The write-and-quit aliases save on the way out.' + }, + { + command: ':wa / :wall', + summary: 'Write every unsaved note', + detail: 'Saves all dirty notes across all panes without closing anything, like vim.' }, { command: ':help / :h', @@ -900,6 +906,11 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Next / previous tab', detail: 'Move through the tabs in the active pane. Also `:tabnext` / `:tabprevious` on the ex line, and rebindable under Settings → Keymaps.' }, + { + command: '{count}gt / {count}gT', + summary: 'Jump straight to a tab', + detail: 'Vim-style direct tab selection: `2gt` goes to tab 2, `5gt` to tab 5 (a count past the end lands on the last tab). `{count}gT` walks that many tabs back instead. Tab numbers count across panes in the same order plain `gt` cycles.' + }, { command: ':closepanel / :closep', summary: 'Close the right panel', diff --git a/packages/app-core/src/lib/keymaps.test.ts b/packages/app-core/src/lib/keymaps.test.ts index 94f09243..e76d34f0 100644 --- a/packages/app-core/src/lib/keymaps.test.ts +++ b/packages/app-core/src/lib/keymaps.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { + eventMatchesUserOverride, findKeymapConflict, getDefaultKeymapBinding, getKeymapDefinition, getKeymapDefinitions, + matchesShortcutBinding, normalizeKeymapOverrides, normalizeShortcutBinding, shortcutBindingFromEvent, @@ -123,6 +125,68 @@ describe('shortcutBindingFromEvent', () => { expect(shortcutBindingFromEvent(event)).toBe('Shift+Mod+=') }) }) + + it('never resolves Alt+numpad digits on Windows (Alt-code character entry)', () => { + // Hold Alt, type 0233 on the numpad: an input method, not a shortcut. + // With Alt+1..9 shipped as tab defaults (#497) each digit would + // otherwise switch tabs mid-entry. + const event = fakeEvent({ key: '2', code: 'Numpad2', altKey: true }) + withPlatform('win32', () => { + expect(shortcutBindingFromEvent(event)).toBeNull() + }) + withPlatform('linux', () => { + expect(shortcutBindingFromEvent(event)).toBe('Alt+2') + }) + }) +}) + +describe('matchesShortcutBinding (digit-row layouts, #497)', () => { + it('matches Alt+1 on AZERTY where the digit row types punctuation', () => { + // French AZERTY: unshifted Digit1 types '&', so the typed-character + // binding is "Alt+&" and the stored default "Alt+1" needs the physical + // digit-row fallback to fire. + const event = fakeEvent({ key: '&', code: 'Digit1', altKey: true }) + withPlatform('win32', () => { + expect(matchesShortcutBinding(event, 'Alt+1')).toBe(true) + }) + }) + + it('still matches a binding recorded from the typed character first', () => { + const event = fakeEvent({ key: '&', code: 'Digit1', altKey: true }) + withPlatform('win32', () => { + expect(matchesShortcutBinding(event, 'Alt+&')).toBe(true) + }) + }) + + it('keeps the numpad out of the digit-row fallback', () => { + const event = fakeEvent({ key: '2', code: 'Numpad2', altKey: true }) + withPlatform('win32', () => { + expect(matchesShortcutBinding(event, 'Alt+2')).toBe(false) + }) + }) +}) + +describe('eventMatchesUserOverride (#497, rebinds outrank new defaults)', () => { + it('flags an event landing on a combination the user rebound elsewhere', () => { + const event = fakeEvent({ key: '3', code: 'Digit3', altKey: true }) + withPlatform('win32', () => { + expect( + eventMatchesUserOverride(event, { 'global.zoomIn': 'Alt+3' }, 'tabs.select3') + ).toBe(true) + }) + }) + + it('ignores the excluded id and unrelated overrides', () => { + const event = fakeEvent({ key: '3', code: 'Digit3', altKey: true }) + withPlatform('win32', () => { + expect( + eventMatchesUserOverride(event, { 'tabs.select3': 'Alt+3' }, 'tabs.select3') + ).toBe(false) + expect( + eventMatchesUserOverride(event, { 'global.zoomIn': 'Alt+4' }, 'tabs.select3') + ).toBe(false) + }) + }) }) describe('sequenceTokenFromEvent', () => { diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index b18776bc..10acbcaa 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -38,6 +38,15 @@ export type KeymapId = | "global.historyBack" | "global.historyForward" | "global.toggleRecentNote" + | "tabs.select1" + | "tabs.select2" + | "tabs.select3" + | "tabs.select4" + | "tabs.select5" + | "tabs.select6" + | "tabs.select7" + | "tabs.select8" + | "tabs.select9" | "vim.leaderPrefix" | "vim.leaderOpenBuffers" | "vim.leaderWorkflows" @@ -395,6 +404,25 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ defaultBinding: "Mod+Tab", defaultBindingMac: "Ctrl+Tab", }, + // Direct tab selection (#497), browser-style. Alt+digit cross-platform; on + // macOS Option+digit types characters on many layouts (the #514 trap) and + // Cmd+1/2/4/5/6 already mean sidebar, connections, and the pane modes, so + // the Mac default is Ctrl+digit (same escape toggleRecentNote uses for + // Ctrl+Tab). Known limit: with multiple Spaces, macOS auto-enables Mission + // Control's Ctrl+digit "Switch to Desktop" shortcuts and consumes the key + // before the app sees it; the description tells those users to rebind. + ...([1, 2, 3, 4, 5, 6, 7, 8, 9] as const).map( + (n): KeymapDefinition => ({ + id: `tabs.select${n}` as KeymapId, + kind: "shortcut", + scope: "app", + group: "global", + title: `Go to tab ${n}`, + description: `Jump straight to tab ${n}, counted across panes in the same order gt cycles. On macOS with multiple Spaces, Mission Control claims Ctrl+${n} for Switch to Desktop; rebind here (or free the key in System Settings) if nothing happens.`, + defaultBinding: `Alt+${n}`, + defaultBindingMac: `Ctrl+${n}`, + }), + ), { id: "vim.leaderPrefix", kind: "sequence", @@ -1145,6 +1173,21 @@ const KEYMAP_INDEX = new Map( KEYMAP_DEFINITIONS.map((definition) => [definition.id, definition] as const), ); +/** The nine direct tab-selection shortcuts (#497), index = position in the + * array + 1. Kept as a list so dispatchers can loop instead of hand-writing + * nine matches. */ +export const TAB_SELECT_KEYMAP_IDS: readonly KeymapId[] = [ + "tabs.select1", + "tabs.select2", + "tabs.select3", + "tabs.select4", + "tabs.select5", + "tabs.select6", + "tabs.select7", + "tabs.select8", + "tabs.select9", +]; + const KEYMAP_GROUP_LABELS: Record = { global: "Global shortcuts", vim: "Vim-specific shortcuts", @@ -1491,17 +1534,38 @@ export function normalizeKeymapOverrides(input: unknown): KeymapOverrides { return overrides; } -export function shortcutBindingFromEvent(event: KeyboardEvent): string | null { +function shortcutModifiersFromEvent(event: KeyboardEvent): string[] { const mac = isMacPlatform(); - const resolved = resolveKeyFromEvent(event); - const key = resolved ?? normalizeKeyName(event.key); - if (!key) return null; const modifiers: string[] = []; if (event.ctrlKey) modifiers.push(mac ? "Ctrl" : "Mod"); if (event.metaKey) modifiers.push(mac ? "Mod" : "Meta"); if (event.altKey) modifiers.push("Alt"); if (event.shiftKey) modifiers.push("Shift"); - return normalizeShortcutBinding([...modifiers, key].join("+")); + return modifiers; +} + +export function shortcutBindingFromEvent(event: KeyboardEvent): string | null { + // Alt+numpad digits are the Windows Alt-code input method (hold Alt, type + // 0233 on the numpad for an accented character). Numpad digits resolve to + // the bare digit, so with Alt+1..9 shipped as tab defaults (#497) every + // Alt-code keystroke would switch tabs mid-entry: on Windows those chords + // belong to the OS and never resolve to a binding. + if ( + event.altKey && + !event.ctrlKey && + !event.metaKey && + /^Numpad\d$/.test(event.code) && + !isMacPlatform() && + !isLinuxPlatform() + ) { + return null; + } + const resolved = resolveKeyFromEvent(event); + const key = resolved ?? normalizeKeyName(event.key); + if (!key) return null; + return normalizeShortcutBinding( + [...shortcutModifiersFromEvent(event), key].join("+"), + ); } export function sequenceTokenFromEvent(event: KeyboardEvent): string | null { @@ -1534,7 +1598,19 @@ export function matchesShortcutBinding( binding: string, ): boolean { const normalized = shortcutBindingFromEvent(event); - return !!normalized && normalized === binding; + if (!!normalized && normalized === binding) return true; + // Layouts with shifted digits (French AZERTY, Czech) type punctuation on + // the digit row, so a digit binding like the Alt+1..9 tab defaults (#497) + // would never fire by typed character (Alt+1 arrives as "Alt+&"). Fall + // back to the physical digit-row position, the same shield class the Mac + // Option+printable defaults needed (#514). Numpad digits stay out of this + // fallback on purpose: see the Alt-code guard in shortcutBindingFromEvent. + const digit = /^Digit(\d)$/.exec(event.code)?.[1]; + if (!digit) return false; + const physical = normalizeShortcutBinding( + [...shortcutModifiersFromEvent(event), digit].join("+"), + ); + return !!physical && physical !== normalized && physical === binding; } export function matchesShortcut( @@ -1545,6 +1621,30 @@ export function matchesShortcut( return matchesShortcutBinding(event, getKeymapBinding(overrides, id)); } +/** + * True when the event lands on a combination the user has explicitly rebound + * to some other action. The #497 tab shortcuts shipped nine new defaults into + * the middle of an ordered dispatch chain, so without this check a + * pre-existing override on e.g. Alt+3 (checked later in the chain) would + * silently lose to the new default. An explicit rebind outranks a shipped + * default; callers skip their default binding when this returns true. + */ +export function eventMatchesUserOverride( + event: KeyboardEvent, + overrides: KeymapOverrides | null | undefined, + excludeId: KeymapId, +): boolean { + if (!overrides) return false; + for (const [id, binding] of Object.entries(overrides)) { + if (id === excludeId || typeof binding !== "string") continue; + const definition = KEYMAP_INDEX.get(id as KeymapId); + if (!definition || definition.kind !== "shortcut") continue; + if (definition.scope !== "app") continue; + if (matchesShortcutBinding(event, binding)) return true; + } + return false; +} + export function matchesSequenceToken( event: KeyboardEvent, overrides: KeymapOverrides | null | undefined, diff --git a/packages/app-core/src/lib/local-assets.ts b/packages/app-core/src/lib/local-assets.ts index 6715b984..61aa0731 100644 --- a/packages/app-core/src/lib/local-assets.ts +++ b/packages/app-core/src/lib/local-assets.ts @@ -251,6 +251,13 @@ function buildImageEmbed( img.classList.add('local-image-embed-image') img.dataset.localAssetUrl = resolvedUrl + // A |WxH size hint arrives as width/height attributes (#570). The embed + // class stretches images to the pane width, and presentational attributes + // lose to any CSS rule, so a hinted size must win through inline style. + const hintWidth = Number(img.getAttribute('width')) || 0 + const hintHeight = Number(img.getAttribute('height')) || 0 + if (hintWidth > 0) img.style.width = `${hintWidth}px` + if (hintHeight > 0) img.style.height = `${hintHeight}px` frame.append(img, controlsTop, controlsBottom) const caption = document.createElement('figcaption') diff --git a/packages/app-core/src/lib/markdown.test.ts b/packages/app-core/src/lib/markdown.test.ts index 9ebcd0ad..b5d4fea1 100644 --- a/packages/app-core/src/lib/markdown.test.ts +++ b/packages/app-core/src/lib/markdown.test.ts @@ -78,6 +78,51 @@ describe('renderMarkdown', () => { expect(html).toContain('alt="CleanShot 2026-04-13 at 14.31.31@2x.png"') }) + it('#570: an Obsidian image embed honors its |WxH size hint', () => { + const html = renderMarkdown('![[assets/cognitive_web.jpg|100x50]]') + + expect(html).toContain(' { + const html = renderMarkdown('![cognitive web|100x50](../../assets/cognitive_web.jpg)') + + expect(html).toContain('width="100"') + expect(html).toContain('height="50"') + expect(html).toContain('alt="cognitive web"') + }) + + it('#570: a width-only hint sets no height, and plain alts stay untouched', () => { + const sized = renderMarkdown('![[assets/pic.png|300]]') + expect(sized).toContain('width="300"') + expect(sized).not.toContain('height=') + + const plain = renderMarkdown('![[assets/pic.png|a nice caption]]') + expect(plain).toContain('alt="a nice caption"') + expect(plain).not.toContain('width=') + }) + + it('#570: a purely numeric markdown alt stays a caption, not a resize', () => { + // Only the wikilink form treats a bare number as a size hint; `![2024]` + // is the author's alt text (a year), and `![|2024]` is the sized form. + const html = renderMarkdown('![2024](assets/chart.png)') + expect(html).toContain('alt="2024"') + expect(html).not.toContain('width=') + + const sized = renderMarkdown('![|2024](assets/chart.png)') + expect(sized).toContain('width="2024"') + expect(sized).toContain('alt=""') + }) + + it('#570: a zero dimension is not a hint and keeps the label', () => { + const html = renderMarkdown('![[assets/pic.png|0x300]]') + expect(html).toContain('alt="0x300"') + expect(html).not.toContain('height=') + }) + it('renders excalidraw embeds as placeholder divs', () => { const html = renderMarkdown('![[diagram.excalidraw]]') diff --git a/packages/app-core/src/lib/markdown.ts b/packages/app-core/src/lib/markdown.ts index 068f0e59..fcde3fa4 100644 --- a/packages/app-core/src/lib/markdown.ts +++ b/packages/app-core/src/lib/markdown.ts @@ -16,7 +16,7 @@ import type { Root as HastRoot, Element as HastElement } from 'hast' import type { VFile } from 'vfile' import { recordRendererPerf } from './perf' import { classifyLocalAssetHref } from './local-assets' -import { parseEmbedSizeHint } from './excalidraw-preview' +import { parseEmbedSizeHint, splitEmbedLabel } from './excalidraw-preview' import { parseColWidthsComment } from './markdown-table' import { scanTaskMetadata, type TaskMetaToken } from './task-metadata-tokens' import { rollupChildDone, rollupCountsChild, rollupLabel, type ChildTaskState } from './task-rollup' @@ -99,7 +99,11 @@ function remarkWikilinks() { type: 'image', url: target, title: null, - alt: label + alt: label, + // Marks the label as wikilink-sourced for remarkImageSizeHints: a + // bare `600x400` is a size hint there, but ordinary markdown alt + // text keeps it as the caption. + data: { zenWikilinkEmbed: true } } } if (bang === '!' && assetKind === 'excalidraw') { @@ -502,6 +506,37 @@ function remarkTaskMetadata() { * which maps to the default highlight color. Inline code is a separate mdast * node (not a `text` child), so code spans are skipped automatically. */ +/** Honor Obsidian-style size hints on image embeds (#570). Both spellings + * arrive here as image nodes with the hint in their alt: `![[img|600x400]]` + * via remarkWikilinks (the whole label is the hint) and `![alt|600](img)` + * straight from the markdown alt text. The hint is stripped from the alt and + * applied through hProperties so remarkRehype writes real width/height + * attributes. Attachment chips (non-image files riding the image node type) + * keep their labels untouched: only image-classified and remote urls + * participate. */ +function remarkImageSizeHints() { + return (tree: MdRoot): void => { + visit(tree, 'image', (node) => { + const image = node as unknown as AnyNode + const url = String(image.url ?? '') + const isRemote = /^(https?:|data:)/i.test(url) + if (!isRemote && classifyLocalAssetHref(url) !== 'image') return + const fromWikilink = + (image.data as { zenWikilinkEmbed?: boolean } | undefined)?.zenWikilinkEmbed === true + const { alt, size } = splitEmbedLabel( + typeof image.alt === 'string' ? image.alt : '', + fromWikilink ? 'wikilink' : 'markdown' + ) + if (!size) return + image.alt = alt + const data = (image.data ??= {}) as { hProperties?: Record } + const hProperties = (data.hProperties ??= {}) + if (size.width) hProperties.width = size.width + if (size.height) hProperties.height = size.height + }) + } +} + function remarkHighlight() { return (tree: MdRoot): void => { visit(tree, 'text', (node, index, parent) => { @@ -965,6 +1000,7 @@ function createProcessor(mathRenderer: 'katex' | 'typst') { .use(remarkTaskStates) .use(remarkTaskRollup) .use(remarkWikilinks) + .use(remarkImageSizeHints) .use(remarkHashtags) .use(remarkTaskMetadata) .use(remarkHighlight) diff --git a/packages/app-core/src/lib/vim-nav-awaiting-argument.test.ts b/packages/app-core/src/lib/vim-nav-awaiting-argument.test.ts new file mode 100644 index 00000000..11c65d57 --- /dev/null +++ b/packages/app-core/src/lib/vim-nav-awaiting-argument.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +// +// Real-editor coverage for isVimAwaitingArgument: vim-nav.test.ts mocks +// @replit/codemirror-vim, so only this suite would catch the library +// renaming `expectLiteralNext` or `inputState.keyBuffer` out from under +// the predicate. The #147 leader guard and the #568 context-menu guards +// (VimNav, EditorPane) all lean on this one helper. +import { afterEach, describe, expect, it } from 'vitest' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { vim } from '@replit/codemirror-vim' +import { isVimAwaitingArgument } from './vim-nav' + +let view: EditorView | null = null + +afterEach(() => { + view?.destroy() + view = null +}) + +function mount(doc: string): EditorView { + view = new EditorView({ + state: EditorState.create({ doc, extensions: [vim()] }), + parent: document.body + }) + return view +} + +function press(target: EditorView, key: string): void { + target.contentDOM.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + ) +} + +describe('isVimAwaitingArgument (real codemirror-vim)', () => { + it('is false in plain normal and visual mode', () => { + const v = mount('meaning of m') + expect(isVimAwaitingArgument(v)).toBe(false) + + press(v, 'v') + expect(isVimAwaitingArgument(v)).toBe(false) + }) + + it('is true after f in visual mode, and false once the target arrives (#568)', () => { + const v = mount('meaning of m') + press(v, 'v') + press(v, 'f') + expect(isVimAwaitingArgument(v)).toBe(true) + + press(v, 'm') + expect(isVimAwaitingArgument(v)).toBe(false) + // The motion actually consumed the key: the selection grew toward "of m" + // instead of the m being available for anything else. + expect(v.state.selection.main.head).toBeGreaterThan(1) + }) + + it('is true while a count is buffered', () => { + const v = mount('meaning of m') + press(v, 'v') + press(v, '2') + expect(isVimAwaitingArgument(v)).toBe(true) + }) + + it('is true after r awaiting its replacement character', () => { + const v = mount('meaning of m') + press(v, 'r') + expect(isVimAwaitingArgument(v)).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/workspace-tabs.test.ts b/packages/app-core/src/lib/workspace-tabs.test.ts index 2caf3798..f5ec84af 100644 --- a/packages/app-core/src/lib/workspace-tabs.test.ts +++ b/packages/app-core/src/lib/workspace-tabs.test.ts @@ -33,20 +33,13 @@ describe('initialWorkspaceRestoreContentPaths', () => { ] } - expect( - initialWorkspaceRestoreContentPaths( - layout, - new Set([ - 'inbox/inactive.md', - 'inbox/active-left.md', - 'inbox/active-right.md', - 'archive/inactive.md' - ]) - ) - ).toEqual(['inbox/active-left.md', 'inbox/active-right.md']) + expect(initialWorkspaceRestoreContentPaths(layout)).toEqual([ + 'inbox/active-left.md', + 'inbox/active-right.md' + ]) }) - it('skips virtual, asset, diagram, missing, and duplicate active tabs', () => { + it('skips virtual, asset, diagram, and duplicate active tabs but keeps unverified paths', () => { const duplicate = 'inbox/shared.md' const diagramPath = diagramTabPath('mermaid', 'flowchart LR\nA --> B') const layout: PaneLayout = { @@ -77,11 +70,14 @@ describe('initialWorkspaceRestoreContentPaths', () => { activeTab: diagramPath }, { + // Restore runs before the notes index exists, so a path that may + // not be on disk anymore is still loaded eagerly: the failed read + // is what prunes its tab (#564). kind: 'leaf', - id: 'missing', - tabs: ['inbox/missing.md'], + id: 'unverified', + tabs: ['inbox/maybe-deleted.md'], pinnedTabs: [], - activeTab: 'inbox/missing.md' + activeTab: 'inbox/maybe-deleted.md' }, { kind: 'split', @@ -108,7 +104,8 @@ describe('initialWorkspaceRestoreContentPaths', () => { ] } - expect(initialWorkspaceRestoreContentPaths(layout, new Set([duplicate]))).toEqual([ + expect(initialWorkspaceRestoreContentPaths(layout)).toEqual([ + 'inbox/maybe-deleted.md', duplicate ]) }) diff --git a/packages/app-core/src/lib/workspace-tabs.ts b/packages/app-core/src/lib/workspace-tabs.ts index 200c0a4f..ac472345 100644 --- a/packages/app-core/src/lib/workspace-tabs.ts +++ b/packages/app-core/src/lib/workspace-tabs.ts @@ -27,16 +27,16 @@ export function isWorkspaceVirtualTabPath(path: string): boolean { ) } -export function initialWorkspaceRestoreContentPaths( - layout: PaneLayout, - existingPaths: Set -): string[] { +/** Restore runs before the vault index exists (#564), so the snapshot's active + * tabs cannot be checked against a note list here: each path is read straight + * from disk, and a failed read prunes its tab. */ +export function initialWorkspaceRestoreContentPaths(layout: PaneLayout): string[] { const seen = new Set() const paths: string[] = [] for (const leaf of allLeaves(layout)) { const path = leaf.activeTab - if (!path || isWorkspaceVirtualTabPath(path) || !existingPaths.has(path) || seen.has(path)) { + if (!path || isWorkspaceVirtualTabPath(path) || seen.has(path)) { continue } seen.add(path) diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 26635ad0..fead1106 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -2421,7 +2421,11 @@ function normalizeWorkspaceSizes(raw: unknown, length: number): number[] { return sizes.map((value) => value / total) } -function sanitizeWorkspaceLayout(raw: unknown, existingPaths: Set): PaneLayout { +/** Shape-checks a saved pane layout without consulting the notes index: the + * snapshot is restored before the vault listing exists (#564). Tabs whose + * notes are gone survive this pass and are pruned later, by the eager + * restore read for active tabs and by `refreshNotes` once the index lands. */ +function sanitizeWorkspaceLayout(raw: unknown): PaneLayout { const usedIds = new Set() const nextId = (rawId: unknown): string => { @@ -2436,8 +2440,8 @@ function sanitizeWorkspaceLayout(raw: unknown, existingPaths: Set): Pane } const sanitizePath = (value: unknown): string | null => { - if (typeof value !== 'string') return null - return existingPaths.has(value) || isWorkspaceVirtualTabPath(value) ? value : null + if (typeof value !== 'string' || !value) return null + return value } const visit = (value: unknown): PaneLayout | null => { @@ -4258,8 +4262,7 @@ export const useStore = create((set, get) => { } const snapshot = rawSnapshot as Partial - const existingPaths = new Set(get().notes.map((note) => note.path)) - let layout = sanitizeWorkspaceLayout(snapshot.paneLayout, existingPaths) + let layout = sanitizeWorkspaceLayout(snapshot.paneLayout) // A workspace saved while Workflows was on (or synced from a machine where // it still is) must not resurrect the canvas for someone who turned the // feature off. @@ -4269,8 +4272,7 @@ export const useStore = create((set, get) => { const unreadable = new Set() const contents: Record = {} const dirty: Record = {} - const pathsToLoad = initialWorkspaceRestoreContentPaths(layout, existingPaths) - const initiallyLoadedPaths = new Set(pathsToLoad) + const pathsToLoad = initialWorkspaceRestoreContentPaths(layout) await Promise.all( pathsToLoad.map(async (path) => { @@ -4287,11 +4289,6 @@ export const useStore = create((set, get) => { if (unreadable.size > 0) { layout = rewritePathsInTree(layout, (path) => (unreadable.has(path) ? null : path)) } - const restorePrefetchPaths = workspaceRestorePrefetchContentPaths( - layout, - existingPaths, - initiallyLoadedPaths - ) const ensured = ensureActivePane( layout, @@ -4330,12 +4327,70 @@ export const useStore = create((set, get) => { scheduleAssetsRefreshForVault(vault) recordRendererPerf('workspace.restore', performance.now() - startedAt, { panes: allLeaves(ensured.layout).length, - eagerNotes: pathsToLoad.length, - deferredNotes: restorePrefetchPaths.length + eagerNotes: pathsToLoad.length }) + } - if (restorePrefetchPaths.length > 0) { - window.setTimeout(() => get().prefetchNotes(restorePrefetchPaths), 120) + /** #564: the saved workspace snapshot is tiny next to the note index, so the + * tabs paint first and the vault scan lands afterwards. The snapshot is + * trusted up front; once the listing arrives, `refreshNotes` prunes tabs + * whose notes are gone (with the #384 guard against transient wipes), the + * freshly discovered folders join the startup-collapsed set, and background + * tabs get the deferred content warm-up the restore itself skipped. */ + const openVaultWorkspace = async (vault: VaultInfo): Promise => { + await restoreWorkspaceForVault(vault) + await refreshVaultIndexes() + if (get().vault?.root !== vault.root) return + // The snapshot was trusted before the index existed; now that the real + // listing is here, run the strict check the pre-2.27 restore order gave + // for free: every restored tab whose note never materialized is closed, + // active or not. refreshNotes cannot do this on its own (its mid-save + // exemption and the #384 transient-wipe guard both assume the tabs they + // keep were verified once), so a snapshot synced from another machine + // would otherwise leave ghost tabs alive for the whole session. Dirty + // tabs stay (unsaved edits beat a stale listing), and an empty listing + // skips the pass: it is indistinguishable from a failed one (#384). + set((s) => { + if (s.notes.length === 0) return {} + const existing = new Set(s.notes.map((note) => note.path)) + const keepTab = (path: string): boolean => + existing.has(path) || isWorkspaceVirtualTabPath(path) || s.noteDirty[path] === true + const stale = allLeaves(s.paneLayout) + .flatMap((leaf) => leaf.tabs) + .filter((tab) => !keepTab(tab)) + if (stale.length === 0) return {} + const validated = rewritePathsInTree(s.paneLayout, (path) => + keepTab(path) ? path : null + ) + const ensured = ensureActivePane(validated, s.activePaneId) + return { + paneLayout: ensured.layout, + activePaneId: ensured.activePaneId, + ...activeFieldsFrom(ensured.layout, ensured.activePaneId, s.noteContents, s.noteDirty) + } + }) + const s = get() + // Folder rows did not exist while the workspace painted, so collapse the + // ones the index just discovered. Quick Notes and Inbox were decided at + // restore time (and may have been toggled since), so they stay untouched. + const startupCollapsed = computeStartupCollapsedFolders( + s.folders, + s.vaultSettings, + s.selectedPath + ) + const discovered = startupCollapsed.filter( + (key) => key !== 'quick:' && key !== 'inbox:' && !s.collapsedFolders.includes(key) + ) + if (discovered.length > 0) { + set({ collapsedFolders: [...s.collapsedFolders, ...discovered] }) + } + const prefetchPaths = workspaceRestorePrefetchContentPaths( + get().paneLayout, + new Set(get().notes.map((note) => note.path)), + new Set(Object.keys(get().noteContents)) + ) + if (prefetchPaths.length > 0) { + window.setTimeout(() => get().prefetchNotes(prefetchPaths), 120) } } @@ -5839,12 +5894,17 @@ export const useStore = create((set, get) => { const applyStartedAt = performance.now() const noteMetaByPath = new Map(notes.map((note) => [note.path, note] as const)) const existingPaths = new Set(notes.map((n) => n.path)) - // Drop tabs whose notes no longer exist — except keep the currently - // focused selectedPath so the editor doesn't blank out mid-save. + // Drop tabs whose notes no longer exist. The currently focused + // selectedPath is exempt so the editor doesn't blank out mid-save, + // but only when its note actually loaded (or holds unsaved edits): + // a tab restored from a stale snapshot and promoted to active after + // its read failed has nothing to blank, and the exemption would keep + // that ghost alive through every refresh (#564). const keep = (path: string): boolean => existingPaths.has(path) || isWorkspaceVirtualTabPath(path) || - path === s.selectedPath + (path === s.selectedPath && + (s.noteContents[path] !== undefined || s.noteDirty[path] === true)) const prunedLayout = rewritePathsInTree(s.paneLayout, (path) => keep(path) ? path : null ) @@ -8734,9 +8794,8 @@ export const useStore = create((set, get) => { vaultSettings, workspaceRestored: false }) - await refreshVaultIndexes() + await openVaultWorkspace(vault) await prefetchInitialVisibleNotes(get()) - await restoreWorkspaceForVault(vault) initializedVault = true } else { set({ @@ -8888,8 +8947,7 @@ export const useStore = create((set, get) => { workspaceRestored: false }) savePrefs(collectPrefs(get())) - await refreshVaultIndexes() - await restoreWorkspaceForVault(vault) + await openVaultWorkspace(vault) }, openLocalVault: async (root: string) => { @@ -8942,8 +9000,7 @@ export const useStore = create((set, get) => { workspaceRestored: false }) savePrefs(collectPrefs(get())) - await refreshVaultIndexes() - await restoreWorkspaceForVault(vault) + await openVaultWorkspace(vault) } catch (err) { console.error('openLocalVault failed', err) window.alert(err instanceof Error ? err.message : String(err)) @@ -9005,8 +9062,7 @@ export const useStore = create((set, get) => { workspaceRestored: false }) savePrefs(collectPrefs(get())) - await refreshVaultIndexes() - await restoreWorkspaceForVault(vaultToOpen) + await openVaultWorkspace(vaultToOpen) return } @@ -9178,8 +9234,7 @@ export const useStore = create((set, get) => { : remoteWorkspaceInfo }) savePrefs(collectPrefs(get())) - await refreshVaultIndexes() - await restoreWorkspaceForVault(vault) + await openVaultWorkspace(vault) } catch (error) { window.alert(error instanceof Error ? error.message : String(error)) } @@ -9259,8 +9314,7 @@ export const useStore = create((set, get) => { workspaceRestored: false }) savePrefs(collectPrefs(get())) - await refreshVaultIndexes() - await restoreWorkspaceForVault(vault) + await openVaultWorkspace(vault) } catch (error) { window.alert(error instanceof Error ? error.message : String(error)) } @@ -9342,8 +9396,7 @@ export const useStore = create((set, get) => { workspaceRestored: false }) savePrefs(collectPrefs(get())) - await refreshVaultIndexes() - await restoreWorkspaceForVault(selectedVault) + await openVaultWorkspace(selectedVault) } catch (error) { window.alert(error instanceof Error ? error.message : String(error)) } @@ -9425,8 +9478,7 @@ export const useStore = create((set, get) => { workspaceRestored: false }) savePrefs(collectPrefs(get())) - await refreshVaultIndexes() - await restoreWorkspaceForVault(vault) + await openVaultWorkspace(vault) } catch (error) { window.alert(error instanceof Error ? error.message : String(error)) } diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 4d5ead4c..68587e11 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.26.0", + "version": "2.27.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 13473d66..e63ca7bd 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.26.0", + "version": "2.27.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 8ee2dab6..ca164a2c 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -70,6 +70,15 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "global.historyBack", group: "global", defaultBinding: "Alt+ArrowLeft", title: "Go back in note history" }, { id: "global.historyForward", group: "global", defaultBinding: "Alt+ArrowRight", title: "Go forward in note history" }, { id: "global.toggleRecentNote", group: "global", defaultBinding: "Mod+Tab", defaultBindingMac: "Ctrl+Tab", title: "Switch to previous note" }, + { id: "tabs.select1", group: "global", defaultBinding: "Alt+1", defaultBindingMac: "Ctrl+1", title: "Go to tab 1" }, + { id: "tabs.select2", group: "global", defaultBinding: "Alt+2", defaultBindingMac: "Ctrl+2", title: "Go to tab 2" }, + { id: "tabs.select3", group: "global", defaultBinding: "Alt+3", defaultBindingMac: "Ctrl+3", title: "Go to tab 3" }, + { id: "tabs.select4", group: "global", defaultBinding: "Alt+4", defaultBindingMac: "Ctrl+4", title: "Go to tab 4" }, + { id: "tabs.select5", group: "global", defaultBinding: "Alt+5", defaultBindingMac: "Ctrl+5", title: "Go to tab 5" }, + { id: "tabs.select6", group: "global", defaultBinding: "Alt+6", defaultBindingMac: "Ctrl+6", title: "Go to tab 6" }, + { id: "tabs.select7", group: "global", defaultBinding: "Alt+7", defaultBindingMac: "Ctrl+7", title: "Go to tab 7" }, + { id: "tabs.select8", group: "global", defaultBinding: "Alt+8", defaultBindingMac: "Ctrl+8", title: "Go to tab 8" }, + { id: "tabs.select9", group: "global", defaultBinding: "Alt+9", defaultBindingMac: "Ctrl+9", title: "Go to tab 9" }, { id: "vim.leaderPrefix", group: "vim", defaultBinding: "Space", title: "Leader key" }, { id: "vim.leaderOpenBuffers", group: "vim", defaultBinding: "o", title: "Leader: open buffers" }, { id: "vim.leaderWorkflows", group: "vim", defaultBinding: "a", title: "Leader: open workflows" }, diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index ec666145..18b551ec 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.26.0", + "version": "2.27.0", "type": "module", "exports": { ".": "./src/index.ts"