diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index 55bbe82e2..fe06c5ba0 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -135,6 +135,8 @@ describe('TOML serialization', () => { kanbanCardSort: 'due', tasksListOrder: 'priority', timelineRange: 'month', + gallerySort: 'title', + galleryIncludeSubfolders: true, kanbanFolderRoot: 'Projects', externalApplicationSchemes: ['zotero', 'obsidian'], ignoredKeys: ['KanaMode', 'F24'] @@ -176,6 +178,11 @@ describe('TOML serialization', () => { // The Timeline panel's reach travels as [view].timeline_range (#1007). expect(text).toMatch(/\[view\][^[]*timeline_range = "month"/) expect(round.timelineRange).toBe('month') + // A folder Gallery's order and reach travel under [view] too. + expect(text).toMatch(/\[view\][^[]*gallery_sort = "title"/) + expect(text).toMatch(/\[view\][^[]*gallery_include_subfolders = true/) + expect(round.gallerySort).toBe('title') + expect(round.galleryIncludeSubfolders).toBe(true) expect(round.kanbanFolderRoot).toBe('Projects') expect(text).toContain('ignored_keys = ["KanaMode", "F24"]') expect(round.externalApplicationSchemes).toEqual(['zotero', 'obsidian']) diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index be0015910..c56940907 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -118,6 +118,11 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'render_tables', comment: 'render tables as widgets in live preview; off keeps them as plain text' }, + detectCodeLanguages: { + section: 'editor', + tomlKey: 'detect_code_languages', + comment: 'guess the language of a code block that names none, and highlight it; never written to the note' + }, syncTitleHeadingOnRename: { section: 'editor', tomlKey: 'sync_title_heading_on_rename', @@ -446,6 +451,16 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'timeline_range', comment: 'two-weeks | month | all: how far ahead the Timeline panel reaches; overdue tasks always show' }, + gallerySort: { + section: 'view', + tomlKey: 'gallery_sort', + comment: 'updated | created | title: the order of a folder Gallery\'s cards (newest first for the dates)' + }, + galleryIncludeSubfolders: { + section: 'view', + tomlKey: 'gallery_include_subfolders', + comment: 'a folder Gallery also shows the notes in its subfolders' + }, kanbanFolderRoot: { section: 'view', tomlKey: 'kanban_folder_root', diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 22dd7168a..c808cbe4a 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -52,6 +52,7 @@ import { import { isAppOverlayOpen, isOverlayOrDialogOpen } from './lib/overlay-open' import { installMarkdownFileDropHandler } from './lib/markdown-file-drop' import { + setMarkdownDetectCodeLanguages, setMarkdownLooseMathDelimiters, setMarkdownMathRenderer } from './lib/markdown-settings' @@ -383,6 +384,7 @@ function App(): JSX.Element { const completedTaskStyle = useStore((s) => s.completedTaskStyle) const mathRenderer = useStore((s) => s.mathRenderer) const looseMathDelimiters = useStore((s) => s.looseMathDelimiters) + const detectCodeLanguages = useStore((s) => s.detectCodeLanguages) const lineNumberPosition = useStore((s) => s.lineNumberPosition) const interfaceFont = useStore((s) => s.interfaceFont) const textFont = useStore((s) => s.textFont) @@ -700,6 +702,10 @@ function App(): JSX.Element { setMarkdownLooseMathDelimiters(looseMathDelimiters) }, [looseMathDelimiters]) + useEffect(() => { + setMarkdownDetectCodeLanguages(detectCodeLanguages) + }, [detectCodeLanguages]) + // The app now always runs fully opaque. useEffect(() => { document.documentElement.setAttribute('data-opaque', '') diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index aab06455f..7404d1e4b 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -361,7 +361,9 @@ export function CommandPalette(): JSX.Element { // command opened Settings or another palette (search, vault text search, // outline, …) so we don't pull focus behind it, and likewise for the Cloud // conflict queue, the Cloud settings prompt and the Publish Note dialog, - // which claim focus themselves and are tracked outside the store. + // which claim focus themselves and are tracked outside the store, and + // for a menu the command opened (Set Callout Type…): the menu holds the + // keyboard, and the letters typed to filter it would land in the note. // On a phone the command decides where the keyboard goes // (lib/command-landing.ts): a note that was already there is left to // read, a command whose point is typing takes the editor, and anything @@ -372,7 +374,8 @@ export function CommandPalette(): JSX.Element { { ...latest, focusedPanel: landedPanel }, useCloudSyncStatusStore.getState().conflictReviewOpen || useCloudSyncStatusStore.getState().settingsConflictPromptOpen || - getPublishNoteRequest() !== null + getPublishNoteRequest() !== null || + document.querySelector('[data-ctx-menu]') !== null ) ) { if (landing === 'read') focusEditorAfterReadOpen() @@ -409,7 +412,9 @@ export function CommandPalette(): JSX.Element { setTheme({ id: theme.id, family: theme.family, mode: theme.mode }) committedRef.current = true setOpen(false) - focusEditorNormalMode() + // A theme is a `stay` landing (lib/command-landing.ts): on a phone the + // keyboard stays down over the note being read. + focusEditorAfterPaletteClose() } const runPickedWorkflow = (entry: WorkflowIndexEntry): void => { diff --git a/packages/app-core/src/components/ContextMenu.test.ts b/packages/app-core/src/components/ContextMenu.test.ts index dbdd5e566..64f46d4e4 100644 --- a/packages/app-core/src/components/ContextMenu.test.ts +++ b/packages/app-core/src/components/ContextMenu.test.ts @@ -2,7 +2,8 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useStore } from '../store' import { ContextMenu } from './ContextMenu' describe('ContextMenu focus', () => { @@ -45,3 +46,276 @@ describe('ContextMenu focus', () => { expect(document.activeElement).toBe(opener) }) }) + +describe('ContextMenu picking one of a set (#1016)', () => { + let host: HTMLDivElement + let root: Root + let picked: string[] + + const items = () => + ['Note', 'Tip', 'Danger (Error)', 'Warning'].map((label) => ({ + label, + checked: label === 'Tip', + keywords: label === 'Warning' ? ['caution'] : [], + onSelect: () => { + picked.push(label) + } + })) + + const menuEl = (): HTMLElement => document.querySelector('[data-ctx-menu]') as HTMLElement + const press = (key: string): void => { + menuEl().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) + } + const active = (): string | undefined => + [...menuEl().querySelectorAll('button')].find((b) => b.className.includes('bg-paper-200 ')) + ?.textContent ?? undefined + + beforeEach(async () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + picked = [] + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + await act(async () => + root.render( + createElement(ContextMenu, { x: 10, y: 10, title: 'Callout type', items: items(), onClose: () => {} }) + ) + ) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + }) + + it('shows its title and names the menu by it', () => { + expect(menuEl().getAttribute('aria-label')).toBe('Callout type') + expect(menuEl().textContent).toContain('Callout type') + }) + + it('starts on the checked item and marks it for assistive tech', () => { + expect(active()).toBe('Tip') + const tip = [...menuEl().querySelectorAll('button')].find((b) => b.textContent === 'Tip') + expect(tip?.getAttribute('role')).toBe('menuitemradio') + expect(tip?.getAttribute('aria-checked')).toBe('true') + }) + + it('finds an item by its label or by a keyword', async () => { + await act(async () => { + for (const ch of 'error') press(ch) + }) + expect([...menuEl().querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Danger (Error)']) + await act(async () => { + for (let i = 0; i < 5; i++) press('Backspace') + for (const ch of 'caution') press(ch) + }) + expect([...menuEl().querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Warning']) + await act(async () => press('Enter')) + expect(picked).toEqual(['Warning']) + }) +}) + +describe('ContextMenu letters with Vim mode on and off', () => { + let host: HTMLDivElement + let root: Root + + const render = async (): Promise => { + await act(async () => + root.render( + createElement(ContextMenu, { + x: 10, + y: 10, + items: [{ label: 'Note' }, { label: 'Tip' }, { label: 'Quote' }].map((i) => ({ + ...i, + onSelect: () => {} + })), + onClose: () => {} + }) + ) + ) + } + const menuEl = (): HTMLElement => document.querySelector('[data-ctx-menu]') as HTMLElement + const press = (key: string): void => { + menuEl().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) + } + const labels = (): Array => + [...menuEl().querySelectorAll('button')].map((b) => b.textContent) + const active = (): string | null | undefined => + [...menuEl().querySelectorAll('button')].find((b) => b.className.includes('bg-paper-200 ')) + ?.textContent + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState({ vimMode: false }) + }) + + it('moves with j and k in Vim mode', async () => { + useStore.setState({ vimMode: true }) + await render() + await act(async () => press('j')) + expect(active()).toBe('Tip') + await act(async () => press('k')) + expect(active()).toBe('Note') + }) + + it('reads j and k as filter letters with Vim mode off; the arrows still move', async () => { + useStore.setState({ vimMode: false }) + await render() + await act(async () => press('ArrowDown')) + expect(active()).toBe('Tip') + await act(async () => press('k')) + expect(labels()).toEqual([]) + expect(menuEl().textContent).toContain('No matches') + }) +}) + +describe('ContextMenu with a find field (#1014)', () => { + let host: HTMLDivElement + let root: Root + let picked: string[] + + const names = ['Text (plain)', 'Brainfuck', 'C', 'C++', 'Java', 'JavaScript', 'JSON', 'Kotlin', 'Python'] + const render = async (): Promise => { + await act(async () => + root.render( + createElement(ContextMenu, { + x: 10, + y: 10, + title: 'Code block language', + findPlaceholder: 'Find a language', + items: names.map((label) => ({ + label, + checked: label === 'Text (plain)', + keywords: label === 'JavaScript' ? ['js'] : label === 'Python' ? ['py'] : [], + onSelect: () => { + picked.push(label) + } + })), + onClose: () => {} + }) + ) + ) + } + const menuEl = (): HTMLElement => document.querySelector('[data-ctx-menu]') as HTMLElement + const field = (): HTMLInputElement => menuEl().querySelector('input') as HTMLInputElement + const keydown = (target: Element, key: string, init: KeyboardEventInit = {}): KeyboardEvent => { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }) + target.dispatchEvent(event) + return event + } + const typeInField = (text: string): void => { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + for (const ch of text) { + if (keydown(field(), ch).defaultPrevented) continue + setValue.call(field(), field().value + ch) + field().dispatchEvent(new Event('input', { bubbles: true })) + } + } + const rows = (): Array => [...menuEl().querySelectorAll('button')].map((b) => b.textContent) + const active = (): string | null | undefined => + [...menuEl().querySelectorAll('button')].find((b) => b.hasAttribute('data-ctx-active'))?.textContent + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + picked = [] + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useStore.setState({ vimMode: false }) + vi.unstubAllGlobals() + }) + + it('gives the field the keyboard with Vim off, and every letter is text there', async () => { + await render() + expect(document.activeElement).toBe(field()) + expect(active()).toBe('Text (plain)') + await act(async () => typeInField('j')) + expect(field().value).toBe('j') + expect(rows()).toEqual(['Java', 'JavaScript', 'JSON']) + }) + + it('puts the exact match first, so a letter and Enter pick it', async () => { + await render() + await act(async () => typeInField('c')) + expect(rows()[0]).toBe('C') + expect(active()).toBe('C') + await act(async () => keydown(field(), 'Enter')) + expect(picked).toEqual(['C']) + }) + + it('finds by keyword and moves with the arrows and Ctrl+N / Ctrl+P from the field', async () => { + await render() + await act(async () => typeInField('py')) + expect(rows()).toEqual(['Python']) + await act(async () => { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + setValue.call(field(), 'ja') + field().dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(active()).toBe('Java') + await act(async () => keydown(field(), 'ArrowDown')) + expect(active()).toBe('JavaScript') + await act(async () => keydown(field(), 'p', { ctrlKey: true })) + expect(active()).toBe('Java') + await act(async () => keydown(field(), 'n', { ctrlKey: true })) + expect(active()).toBe('JavaScript') + }) + + it('clears the field on the first Escape and closes on the second', async () => { + let closed = 0 + await act(async () => + root.render( + createElement(ContextMenu, { + x: 10, + y: 10, + findPlaceholder: 'Find a language', + items: names.map((label) => ({ label, onSelect: () => {} })), + onClose: () => { + closed++ + } + }) + ) + ) + await act(async () => typeInField('ko')) + await act(async () => keydown(field(), 'Escape')) + expect(field().value).toBe('') + expect(closed).toBe(0) + await act(async () => keydown(field(), 'Escape')) + expect(closed).toBe(1) + }) + + it('keeps the list in charge with Vim on: j and k move, / goes to the field', async () => { + useStore.setState({ vimMode: true }) + await render() + expect(document.activeElement).toBe(menuEl()) + await act(async () => keydown(menuEl(), 'j')) + expect(active()).toBe('Brainfuck') + await act(async () => keydown(menuEl(), 'k')) + expect(active()).toBe('Text (plain)') + await act(async () => keydown(menuEl(), '/')) + expect(document.activeElement).toBe(field()) + await act(async () => typeInField('j')) + expect(field().value).toBe('j') + expect(rows()).toEqual(['Java', 'JavaScript', 'JSON']) + }) + + it('opens on the list on a touch screen, so no keyboard covers it', async () => { + vi.stubGlobal('matchMedia', (query: string) => ({ matches: query === '(pointer: coarse)' })) + await render() + expect(document.activeElement).toBe(menuEl()) + expect(field()).not.toBeNull() + }) +}) diff --git a/packages/app-core/src/components/ContextMenu.tsx b/packages/app-core/src/components/ContextMenu.tsx index 131ad2423..367c05d7a 100644 --- a/packages/app-core/src/components/ContextMenu.tsx +++ b/packages/app-core/src/components/ContextMenu.tsx @@ -1,5 +1,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' +import { useStore } from '../store' +import { CheckIcon, SearchIcon } from './icons' export interface ContextMenuItem { kind?: 'item' | 'separator' @@ -11,6 +13,11 @@ export interface ContextMenuItem { /** Displayed in muted red (used for destructive actions). */ danger?: boolean disabled?: boolean + /** The choice in force, of a set the menu picks from: drawn with a check, + * and where the cursor starts. */ + checked?: boolean + /** More words the filter finds the item by (aliases), besides its label. */ + keywords?: string[] onSelect?: () => void | Promise } @@ -18,9 +25,38 @@ interface Props { x: number y: number items: ContextMenuItem[] + /** A heading above the items, for a menu that picks one of a set. */ + title?: string + /** A find field above the items, with this placeholder, for a long list: + * the list scrolls under it, the best matches come first, and a touch + * keyboard has somewhere to type (the code block language picker). */ + findPlaceholder?: string + /** The top edge of what opened the menu, when (x, y) is just below it: a + * menu with no room below opens above it instead of covering it. */ + anchorTop?: number onClose: () => void } +/** How well a row matches the filter, best first: its label or a keyword is + * the query, starts with it, has a word that starts with it, or holds it. + * Null when it does not match. */ +export function contextMenuMatchRank(item: ContextMenuItem, query: string): number | null { + const q = query.trim().toLowerCase() + if (!q) return 0 + const texts = [item.label?.toLowerCase() ?? '', ...(item.keywords ?? []).map((k) => k.toLowerCase())] + if (texts.some((text) => text === q)) return 0 + if (texts.some((text) => text.startsWith(q))) return 1 + if (texts.some((text) => text.split(/[^a-z0-9+#]+/).some((word) => word.startsWith(q)))) return 2 + if (texts.some((text) => text.includes(q))) return 3 + return null +} + +/** A touch screen with a list to tap opens on the list, not the keyboard + * (the PromptModal rule): the keyboard would cover the very rows it picks. */ +function coarsePointer(): boolean { + return typeof window !== 'undefined' && (window.matchMedia?.('(pointer: coarse)').matches ?? false) +} + /** * Floating context menu. Positions itself at (x, y), flips when it * would run off the edge of the viewport, and closes on outside @@ -28,46 +64,61 @@ interface Props { * * Supports type-to-filter: any printable character typed while the menu * is open narrows the visible items by case-insensitive substring match - * on their label. A small header bar appears showing the current query + * on their label. With Vim mode on, j and k move instead; with it off they + * are letters like any other, and only the arrows, Enter and Escape act. A small header bar appears showing the current query * so the user knows they're filtering. Backspace deletes a character, * Escape clears the query (one level) before closing the menu. + * + * With `findPlaceholder` the query lives in a find field instead of that bar, + * the rows scroll under it with the best matches first, and Ctrl+N / Ctrl+P + * and the arrows move from inside the field, where every letter is text. */ -export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element { +export function ContextMenu({ x, y, items, title, findPlaceholder, anchorTop, onClose }: Props): JSX.Element { const ref = useRef(null) + const findRef = useRef(null) + const listRef = useRef(null) const previousFocusRef = useRef(null) const [pos, setPos] = useState({ left: x, top: y }) const [activeIdx, setActiveIdx] = useState(0) const [query, setQuery] = useState('') + const vimMode = useStore((s) => s.vimMode) // Build the filtered list. Each entry knows its original index so // reorder / filter doesn't lose the onSelect wiring. const visible = useMemo(() => { const q = query.trim().toLowerCase() - const out: Array<{ item: ContextMenuItem; originalIdx: number }> = [] + const out: Array<{ item: ContextMenuItem; originalIdx: number; rank: number }> = [] for (let i = 0; i < items.length; i++) { const item = items[i] + let rank = 0 if (q) { // Hide separators during filtering — they don't pair with any // specific item once the list has been shuffled. if (item.kind === 'separator') continue - const label = item.label?.toLowerCase() ?? '' - if (!label.includes(q)) continue + const matched = contextMenuMatchRank(item, q) + if (matched === null) continue + rank = matched } - out.push({ item, originalIdx: i }) + out.push({ item, originalIdx: i, rank }) } + // A long list puts the best match first, so `c` then Enter is C, not the + // first name with a c in it. Stable: equal matches keep their order. + if (q && findPlaceholder) out.sort((a, b) => a.rank - b.rank) return out - }, [items, query]) + }, [items, query, findPlaceholder]) // Track the cursor as an index into the *visible* list so filtering // doesn't send us to a separator / hidden row. const [visibleActive, setVisibleActive] = useState(0) useEffect(() => { - // Snap to the first enabled row whenever filtering changes the set. - const firstEnabled = visible.findIndex( - (v) => v.item.kind !== 'separator' && !v.item.disabled - ) - setVisibleActive(firstEnabled >= 0 ? firstEnabled : 0) - }, [visible]) + // Snap to the first enabled row whenever filtering changes the set; with + // no filter, a menu that picks one of a set starts on the current one. + const enabled = (v: { item: ContextMenuItem }): boolean => + v.item.kind !== 'separator' && !v.item.disabled + const checked = query ? -1 : visible.findIndex((v) => enabled(v) && v.item.checked) + const firstEnabled = visible.findIndex(enabled) + setVisibleActive(checked >= 0 ? checked : firstEnabled >= 0 ? firstEnabled : 0) + }, [visible, query]) // Clamp inside the viewport so the menu never spills off-screen. useLayoutEffect(() => { @@ -79,19 +130,28 @@ export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element { let left = x let top = y if (left + rect.width + 8 > vw) left = Math.max(8, vw - rect.width - 8) - if (top + rect.height + 8 > vh) top = Math.max(8, vh - rect.height - 8) + if (top + rect.height + 8 > vh) { + const above = anchorTop === undefined ? -1 : anchorTop - rect.height - 4 + top = above >= 8 ? above : Math.max(8, vh - rect.height - 8) + } setPos({ left, top }) - }, [x, y]) + }, [x, y, anchorTop]) // Take the keyboard as the menu mounts, so a key pressed before the next // frame cannot reach whatever held focus (the calendar panel, after a // keyboard drop), and again on that frame in case the event that opened the // menu moves focus after mount (#850). Closing hands focus back. + // + // A menu with a find field gives it the keyboard when letters are text, + // that is with Vim off; with Vim on the list keeps it, where j and k move + // and `/` goes to the field. On a touch screen the list keeps it either way. useEffect(() => { previousFocusRef.current = document.activeElement as HTMLElement | null - ref.current?.focus({ preventScroll: true }) + const target = (): HTMLElement | null => + findPlaceholder && !useStore.getState().vimMode && !coarsePointer() ? findRef.current : ref.current + target()?.focus({ preventScroll: true }) const id = requestAnimationFrame(() => { - ref.current?.focus({ preventScroll: true }) + target()?.focus({ preventScroll: true }) }) return () => { cancelAnimationFrame(id) @@ -117,7 +177,21 @@ export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element { setActiveIdx(visible[visibleActive]?.originalIdx ?? -1) }, [visible, visibleActive]) + // A list that scrolls keeps the cursor's row in sight. + useEffect(() => { + const row = listRef.current?.querySelector('[data-ctx-active]') + row?.scrollIntoView?.({ block: 'nearest' }) + }, [visible, visibleActive]) + const handleKeyDown = (e: React.KeyboardEvent): void => { + // Typing in the find field is the field's: every letter is text there, j + // and k too. Only the keys that act on the list come through. + const inFind = findRef.current !== null && e.target === findRef.current + if (inFind && e.nativeEvent.isComposing) { + // An input method's Enter commits the word; it does not pick a row. + e.stopPropagation() + return + } if (e.key === 'Escape') { e.preventDefault() e.stopPropagation() @@ -125,19 +199,20 @@ export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element { else onClose() return } - if ( - e.key === 'ArrowDown' || - (!e.metaKey && !e.ctrlKey && !e.altKey && e.key === 'j') - ) { + const plain = !e.metaKey && !e.ctrlKey && !e.altKey + if (!inFind && findRef.current && vimMode && plain && e.key === '/') { + e.preventDefault() + e.stopPropagation() + findRef.current.focus({ preventScroll: true }) + return + } + if (e.key === 'ArrowDown' || (vimMode && plain && !inFind && e.key === 'j')) { e.preventDefault() e.stopPropagation() setVisibleActive((i) => stepVisible(visible, i, 1)) return } - if ( - e.key === 'ArrowUp' || - (!e.metaKey && !e.ctrlKey && !e.altKey && e.key === 'k') - ) { + if (e.key === 'ArrowUp' || (vimMode && plain && !inFind && e.key === 'k')) { e.preventDefault() e.stopPropagation() setVisibleActive((i) => stepVisible(visible, i, -1)) @@ -165,6 +240,11 @@ export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element { } return } + if (inFind) { + // The field edits itself; the keys must not reach the editor below. + e.stopPropagation() + return + } if (e.key === 'Backspace') { if (query) { e.preventDefault() @@ -186,19 +266,45 @@ export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element { ref={ref} data-ctx-menu role="menu" + aria-label={title} tabIndex={-1} onKeyDown={handleKeyDown} - className="fixed z-[60] min-w-[220px] overflow-hidden rounded-xl bg-paper-100 p-1 shadow-float ring-1 ring-paper-300" + className={[ + 'fixed z-[60] overflow-hidden rounded-xl bg-paper-100 p-1 shadow-float ring-1 ring-paper-300', + // A fixed width, so filtering a long list does not resize it under + // the pointer; never wider than a phone's screen. + findPlaceholder ? 'w-72 max-w-[calc(100vw-1rem)]' : 'min-w-[220px]' + ].join(' ')} style={{ left: pos.left, top: pos.top }} onContextMenu={(e) => e.preventDefault()} > - {query && ( + {title && ( +
{title}
+ )} + {findPlaceholder && ( +
+ + setQuery(e.target.value)} + placeholder={findPlaceholder} + aria-label={findPlaceholder} + spellCheck={false} + autoCapitalize="off" + autoCorrect="off" + className="min-w-0 flex-1 bg-transparent py-1.5 text-sm text-ink-900 outline-none placeholder:text-ink-400" + /> +
+ )} + {query && !findPlaceholder && (
filter {query} {visible.length}
)} +
{visible.length === 0 ? (
No matches @@ -217,6 +323,9 @@ export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element { return ( ) }) )} +
, document.body ) diff --git a/packages/app-core/src/components/DatabaseBoardView.tsx b/packages/app-core/src/components/DatabaseBoardView.tsx index fab75984d..9e2c5411f 100644 --- a/packages/app-core/src/components/DatabaseBoardView.tsx +++ b/packages/app-core/src/components/DatabaseBoardView.tsx @@ -1,12 +1,15 @@ import { useMemo, useState } from 'react' import type { DatabaseDoc, DbField, DbView } from '@shared/databases' import { EMPTY_GROUP } from '@shared/databases' -import { boardColumns, filterRows } from '@shared/database-transforms' +import { boardColumns, viewRows } from '@shared/database-transforms' import { useStore } from '../store' import { setCell, ensureSelectOption, updateView, + clearViewPicks, + pickedRowCount, + viewHasPicks, fieldsById, formatDate, optionLabel, @@ -17,6 +20,7 @@ import { import { isImeComposing } from '../lib/ime' import { PlusIcon, ArrowUpRightIcon } from './icons' import { IconButton } from './ui/Button' +import { DatabasePicksStrip } from './DatabasePicksStrip' interface Props { csvPath: string @@ -39,9 +43,11 @@ export function DatabaseBoardView({ csvPath, doc, view }: Props): JSX.Element { doc.fields.find((f) => f.id === view.groupByFieldId && f.type === 'select') ?? selectFields[0] const map = useMemo(() => fieldsById(doc), [doc]) + // The same pipeline as the Table (picks, then filters, then sorts), so a + // view shows the same records on either surface. const visibleRows = useMemo( - () => filterRows(doc.rows, view.filters, map, view.filterConjunction), - [doc.rows, view.filters, view.filterConjunction, map] + () => viewRows(doc.rows, view, map), + [doc.rows, view, map] ) const titleField = doc.fields.find((f) => f.id !== doc.idFieldId) @@ -76,6 +82,14 @@ export function DatabaseBoardView({ csvPath, doc, view }: Props): JSX.Element { return (
+ {viewHasPicks(view) && ( + // A board has no row selection, so its picks come from a copied + // view (or schema.json); here they can only be shown or dropped. + updateDatabaseSchema(csvPath, clearViewPicks(doc, view.id))} + /> + )}
Group by setQuery(e.target.value)} + onKeyDown={onFindKeyDown} + placeholder="Find a column" + aria-label="Find a column" + className="min-w-0 flex-1 bg-transparent py-1 text-xs text-ink-900 outline-none placeholder:text-ink-400" + /> +
+ +
+ + {shownCount} of {columns.length} shown + + + +
+ +
+ {matches.length === 0 ? ( +

No column matches.

+ ) : ( + matches.map((field, index) => { + const checked = !hidden.has(field.id) + const locked = checked && isLastVisibleColumn(doc, view, field.id) + return ( + + ) + }) + )} +
+ + {vimMode && ( +

j / k move · Space or x toggles · / finds · Esc closes

+ )} +
, + document.body + ) +} diff --git a/packages/app-core/src/components/DatabasePicksStrip.tsx b/packages/app-core/src/components/DatabasePicksStrip.tsx new file mode 100644 index 000000000..667fc46ab --- /dev/null +++ b/packages/app-core/src/components/DatabasePicksStrip.tsx @@ -0,0 +1,39 @@ +import { Button } from './ui/Button' + +/** "Showing 1 picked row" / "Showing 12 picked rows". */ +export function pickedRowsLabel(count: number): string { + return `Showing ${count} picked ${count === 1 ? 'row' : 'rows'}` +} + +/** + * The line under the database header while a view shows only its picked + * rows, so a view that hides most records always says so. Edit picks is the + * Table's (a board has no row selection to edit them with); Show all rows + * drops the picks on either. + */ +export function DatabasePicksStrip({ + count, + onEdit, + onShowAll +}: { + count: number + onEdit?: () => void + onShowAll: () => void +}): JSX.Element { + return ( +
+ {pickedRowsLabel(count)} + {onEdit && ( + + )} + +
+ ) +} diff --git a/packages/app-core/src/components/DatabaseTableView.tsx b/packages/app-core/src/components/DatabaseTableView.tsx index 6dd2ab59e..9c0421afc 100644 --- a/packages/app-core/src/components/DatabaseTableView.tsx +++ b/packages/app-core/src/components/DatabaseTableView.tsx @@ -9,16 +9,23 @@ import { } from 'react' import { createPortal } from 'react-dom' import type { DatabaseDoc, DbField, DbRow, DbView, FieldType, SelectOptionsSource } from '@shared/databases' -import { filterRows, sortRows } from '@shared/database-transforms' +import { viewRows } from '@shared/database-transforms' import { useStore } from '../store' import { - addRow, + addRowInView, setCell, renameField, retypeField, deleteField, moveColumn, moveColumnToField, + setColumnHidden, + isLastVisibleColumn, + setViewPicks, + clearViewPicks, + removeFromViewPicks, + viewHasPicks, + pickedRowCount, ensureSelectOption, setFieldOptionsSource, updateView, @@ -31,11 +38,16 @@ import { isCheckboxTrue } from '../lib/database-cells' import { ContextMenu, type ContextMenuItem } from './ContextMenu' -import { IconButton } from './ui/Button' +import { Button, IconButton } from './ui/Button' +import { DatabasePicksStrip } from './DatabasePicksStrip' import { MoreIcon, TrashIcon, PlusIcon, DocumentIcon, DocumentTextIcon, ArrowUpRightIcon } from './icons' import { focusEditorNormalMode } from '../lib/editor-focus' import { isImeComposing } from '../lib/ime' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isOverlayOrDialogOpen } from '../lib/overlay-open' +import { databaseGridKeyAction } from '../lib/database-grid-keys' +import { useDatabaseViewCommand } from '../lib/database-view-commands' +import { useToastStore } from '../lib/toast' import { resolveWikilinkTarget } from '../lib/wikilinks' import { followLinkTarget } from '../lib/follow-link' import { NoteSuggestRows, stepSuggestIndex, useNoteSuggestions } from './DatabaseNoteSuggest' @@ -58,9 +70,11 @@ interface Props { view: DbView /** True when this grid is the active pane's content — drives auto-focus. */ isActive: boolean + /** Open the header's Columns popover for this view. */ + onOpenColumns?: () => void } -export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX.Element { +export function DatabaseTableView({ csvPath, doc, view, isActive, onOpenColumns }: Props): JSX.Element { const updateDatabaseRows = useStore((s) => s.updateDatabaseRows) const updateDatabaseSchema = useStore((s) => s.updateDatabaseSchema) const deleteDatabaseRows = useStore((s) => s.deleteDatabaseRows) @@ -68,6 +82,7 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. const renameRecordPage = useStore((s) => s.renameRecordPage) const focusedPanel = useStore((s) => s.focusedPanel) const setFocusedPanel = useStore((s) => s.setFocusedPanel) + const vimMode = useStore((s) => s.vimMode) const titleFieldId = doc.fields.find((f) => f.id !== doc.idFieldId)?.id @@ -95,6 +110,10 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. return [...tags].sort().map((value) => ({ value })) }, [sourcePrompt]) const [selected, setSelected] = useState>(new Set()) + // Edit picks: every row shows, the picked ones selected, and the selection + // bar offers Update picks or Cancel instead of its usual actions. + const [editingPicks, setEditingPicks] = useState(false) + const hasPicks = viewHasPicks(view) // #317: header drag-to-reorder columns. Ref holds the field being dragged; // state drives the drop-target indicator. const dragFieldRef = useRef(null) @@ -119,13 +138,33 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. .filter((f): f is DbField => !!f && !f.hidden && !hidden.has(f.id)) }, [doc.fields, view.columnOrder, view.hiddenFieldIds, map]) - const rows = useMemo(() => { - return sortRows( - filterRows(doc.rows, view.filters, map, view.filterConjunction), - view.sorts, - map - ) - }, [doc.rows, view.filters, view.filterConjunction, view.sorts, map]) + // While editing picks the view shows every row (no picks, no filters) in + // its sort order, so any record can be picked or unpicked. + const rows = useMemo( + () => viewRows(doc.rows, editingPicks ? { sorts: view.sorts, filters: [] } : view, map), + [doc.rows, view, map, editingPicks] + ) + + // Another view is another set of picks and columns: leave edit mode and + // the selection behind when the tab switches, and leave edit mode when the + // picks go away from under it (Show All Rows from the palette). + useEffect(() => { + setEditingPicks(false) + setSelected(new Set()) + }, [view.id]) + useEffect(() => { + if (editingPicks && !hasPicks) setEditingPicks(false) + }, [editingPicks, hasPicks]) + // A row added while editing (the header's + Row included) joins the picks; + // check it here too, or Update picks would drop it again. + const picksAtEditStart = useRef>(new Set()) + useEffect(() => { + if (!editingPicks) return + const fresh = (view.pickedRowIds ?? []).filter((id) => !picksAtEditStart.current.has(id)) + if (fresh.length === 0) return + for (const id of fresh) picksAtEditStart.current.add(id) + setSelected((prev) => new Set([...prev, ...fresh])) + }, [editingPicks, view.pickedRowIds]) const anySelected = selected.size > 0 const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id)) @@ -142,6 +181,50 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. setSelected(new Set()) } + // --- picked rows --------------------------------------------------------- + /** Make the selected rows this view's picks (or update them while editing). */ + const pickSelected = (): void => { + if (selected.size === 0) return + updateDatabaseSchema(csvPath, setViewPicks(doc, view.id, selected)) + setSelected(new Set()) + setEditingPicks(false) + } + const startEditPicks = (): void => { + const live = new Set(doc.rows.map((r) => r.id)) + picksAtEditStart.current = new Set(view.pickedRowIds ?? []) + setSelected(new Set((view.pickedRowIds ?? []).filter((id) => live.has(id)))) + setEditingPicks(true) + } + const cancelEditPicks = (): void => { + setSelected(new Set()) + setEditingPicks(false) + } + const showAllRows = (): void => { + updateDatabaseSchema(csvPath, clearViewPicks(doc, view.id)) + cancelEditPicks() + } + // Show Only Selected Rows from the palette (or a key bound to it). + useDatabaseViewCommand(csvPath, isActive, (command) => { + if (command !== 'pick-selected') return + if (selected.size === 0) { + // Name x only where Vim mode is on, as every key hint does. + const how = useStore.getState().vimMode + ? 'Space or x in the grid, or the row checkboxes' + : 'the row checkboxes, or Space in the grid' + useToastStore.getState().addToast(`Select rows first: ${how}.`, 'info') + return + } + pickSelected() + }) + + /** Add a row; in a picks view it joins the picks so it does not vanish the + * moment it is made. */ + const addRowHere = (): void => { + const added = addRowInView(doc, view.id) + if (added.picksChanged) updateDatabaseSchema(csvPath, added.doc) + else updateDatabaseRows(csvPath, added.doc) + } + // Open a record's page note, then move the cursor into its editor (otherwise // focus lingers on the table / sidebar after the tab swaps). const openPage = (rowId: string): void => { @@ -230,7 +313,13 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. // letters (e.g. the "l" in "url"), and let the arrow keys move the caret. (#110) const target = e.target as HTMLElement | null if (target?.closest('input, textarea') || target?.isContentEditable) return - if (e.metaKey || e.ctrlKey || e.altKey) return // leave global shortcuts alone + // Which keys answer in which mode lives in databaseGridKeyAction: with + // Vim mode off only the arrows, Enter, Space, Escape and the menu keys. + const action = databaseGridKeyAction(e, useStore.getState().vimMode) + // Reset pending multi-key sequences when a different key arrives. + if (gPending.current && action !== 'g') gPending.current = false + if (dPending.current && action !== 'd') dPending.current = false + if (!action) return const lastRow = rows.length - 1 const lastCol = columns.length - 1 const clampRow = (n: number): number => Math.max(-1, Math.min(n, lastRow)) // -1 = column-header row @@ -247,24 +336,16 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. const toggleCheckbox = (row: DbRow, field: DbField): void => commitCell(row.id, field, isCheckboxTrue(row.cells[field.id] ?? '') ? 'false' : 'true') - // Reset pending multi-key sequences when a different key arrives. - if (gPending.current && e.key !== 'g') gPending.current = false - if (dPending.current && e.key !== 'd') dPending.current = false - - switch (e.key) { - case 'j': - case 'ArrowDown': + switch (action) { + case 'down': return move(active.row + 1, active.col) - case 'k': - case 'ArrowUp': + case 'up': return move(active.row - 1, active.col) - case 'h': - case 'ArrowLeft': + case 'left': return move(active.row, active.col - 1) - case 'l': - case 'ArrowRight': + case 'right': return move(active.row, active.col + 1) - case 'H': { + case 'move-column-left': { // #317: move the current column one step left, keeping the cursor on it. e.preventDefault() const field = columns[active.col] @@ -274,7 +355,7 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. } return } - case 'L': { + case 'move-column-right': { // #317: move the current column one step right, keeping the cursor on it. e.preventDefault() const field = columns[active.col] @@ -284,12 +365,11 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. } return } - case '0': - case '^': + case 'first-column': return move(active.row, 0) - case '$': + case 'last-column': return move(active.row, lastCol) - case 'G': + case 'last-row': return move(lastRow, active.col) case 'g': e.preventDefault() @@ -310,8 +390,7 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. if (c) void deleteDatabaseRows(csvPath, [c.row.id]) return } - case 'Enter': - case 'i': { + case 'edit': { e.preventDefault() if (active.row < 0) { // On the column header — rename the field, keyboard-only. @@ -325,36 +404,44 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. setEditing({ rowId: c.row.id, fieldId: c.field.id }) return } - case ' ': { + case 'space': { const c = cell() if (!c) return e.preventDefault() if (c.field.type === 'checkbox') return toggleCheckbox(c.row, c.field) return toggleRow(c.row.id) } - case 'x': { + case 'select': { const c = cell() if (!c) return e.preventDefault() return toggleRow(c.row.id) } - case 'o': { + case 'open-page': { const c = cell() if (!c) return e.preventDefault() return openPage(c.row.id) } - case 'O': { - // Every record's page at once (o opens one). A new single-key - // shortcut, so it stays off with Vim mode off. - if (!useStore.getState().vimMode) return + case 'columns': { + // The Columns popover, to pick the few columns this view shows (the + // header's Columns button and the palette's Choose Database Columns + // do it with Vim off). Never behind a menu or a dialog. + if (isOverlayOrDialogOpen()) return + e.preventDefault() + onOpenColumns?.() + return + } + case 'create-pages': { + // Every record's page at once (o opens one). e.preventDefault() void useStore.getState().createAllRecordPages(csvPath) return } - case 'm': { - // Open the context menu (like the sidebar's `m`), anchored to the - // active header or cell. On a header → field menu; on a cell → row menu. + case 'menu': { + // Open the context menu (m with Vim on, like the sidebar's; Shift+F10 + // or the Context Menu key in either mode), anchored to the active + // header or cell. On a header → field menu; on a cell → row menu. e.preventDefault() if (active.row < 0) { const field = columns[active.col] @@ -371,14 +458,18 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. ?.getBoundingClientRect() return setRowMenu({ rowId: c.row.id, x: r?.left ?? 0, y: r?.bottom ?? 0 }) } - case 'a': { + case 'add-row': { e.preventDefault() - updateDatabaseRows(csvPath, addRow(doc)) + addRowHere() setActive((s) => ({ row: rows.length, col: s.col })) return } - case 'Escape': + case 'escape': e.preventDefault() + if (editingPicks) { + cancelEditPicks() + return + } if (selected.size > 0) { setSelected(new Set()) return @@ -397,8 +488,23 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. const rowMenuItems = (rowId: string): ContextMenuItem[] => { const multi = selected.has(rowId) && selected.size > 1 + const pickItems: ContextMenuItem[] = [] + if (!editingPicks && selected.size > 0) { + pickItems.push({ label: 'Show only selected rows', onSelect: pickSelected }) + } + if (!editingPicks && hasPicks) { + pickItems.push({ + label: "Remove from this view's picks", + onSelect: () => { + const ids = multi ? [...selected] : [rowId] + updateDatabaseSchema(csvPath, removeFromViewPicks(doc, view.id, ids)) + if (multi) setSelected(new Set()) + } + }) + } return [ { label: 'Open page', onSelect: () => openPage(rowId) }, + ...(pickItems.length > 0 ? ([{ kind: 'separator' }, ...pickItems] as ContextMenuItem[]) : []), { kind: 'separator' }, { label: multi ? `Delete ${selected.size} rows` : 'Delete row', @@ -409,6 +515,7 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. ] } + // Key hints name the grid's letters only where they work: in Vim mode. const fieldMenuItems = (field: DbField): ContextMenuItem[] => [ { label: 'Sort ascending', onSelect: () => updateDatabaseSchema(csvPath, updateView(doc, view.id, { sorts: [{ fieldId: field.id, direction: 'asc' }] })) }, { label: 'Sort descending', onSelect: () => updateDatabaseSchema(csvPath, updateView(doc, view.id, { sorts: [{ fieldId: field.id, direction: 'desc' }] })) }, @@ -416,16 +523,28 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. { kind: 'separator' }, { label: 'Move left', - hint: 'H', + hint: vimMode ? 'H' : undefined, disabled: columns[0]?.id === field.id, onSelect: () => updateDatabaseSchema(csvPath, moveColumn(doc, view.id, field.id, 'left')) }, { label: 'Move right', - hint: 'L', + hint: vimMode ? 'L' : undefined, disabled: columns[columns.length - 1]?.id === field.id, onSelect: () => updateDatabaseSchema(csvPath, moveColumn(doc, view.id, field.id, 'right')) }, + { + // In this view only: another saved view keeps its own columns. + label: 'Hide column', + disabled: isLastVisibleColumn(doc, view, field.id), + onSelect: () => updateDatabaseSchema(csvPath, setColumnHidden(doc, view.id, field.id, true)) + }, + { + label: 'Choose columns…', + hint: vimMode ? 'c' : undefined, + disabled: !onOpenColumns, + onSelect: () => onOpenColumns?.() + }, { kind: 'separator' }, { label: 'Rename field', onSelect: () => setRenamingField(field.id) }, ...(Object.keys(FIELD_TYPE_LABELS) as FieldType[]).map((t) => ({ @@ -488,9 +607,43 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. gridRef.current?.focus({ preventScroll: true }) }} > - {anySelected && ( -
+ {hasPicks && !editingPicks && ( + + )} + {editingPicks ? ( +
+ + Editing picks: {selected.size} selected + + + +
+ ) : anySelected && ( +
{selected.size} selected + + {activeView.type === 'table' && ( + + )} @@ -241,7 +362,13 @@ export function DatabaseView({ {serializeRows(doc.rows, doc.fields)} ) : activeView.type === 'table' ? ( - + openColumnsFor(activeView.id)} + /> ) : ( )} @@ -258,6 +385,17 @@ export function DatabaseView({ /> )} + {columnsAnchor && !rawMode && activeView.type === 'table' && ( + + )} + {databaseMenu && ( )} + {addViewMenu && ( + setAddViewMenu(null)} + /> + )} + {viewMenu && ( > = {}; @@ -740,6 +741,16 @@ function registerVimCommands(): void { Vim.defineEx("unresolved", "unres", () => { void useStore.getState().openUnresolvedLinksView(); }); + // `:gallery` (`:gal`) opens this note's folder as a Gallery; `:gallery + // Projects/Website` names the folder (a vault path, a folder in the notes + // area, or a system folder such as `archive`). + Vim.defineEx( + "gallery", + "gal", + (_cm: unknown, params: { argString?: string } | undefined) => { + void openGalleryFromArgument(params?.argString ?? ""); + }, + ); // Quick-add a whole-note task file. `:newtask` (or `:task`) prompts for a // title and creates it at the configured tasks location; `:newtask ` diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 83de982c5..0b2676895 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -83,6 +83,12 @@ import { import { isInsideCode } from '../lib/cm-wikilink-render' import { isUnresolvedLinksTabPath } from '@shared/unresolved-links' import { UnresolvedLinksView } from './UnresolvedLinksView' +import { GalleryView } from './GalleryView' +import { + galleryFolderFromTabPath, + galleryFolderName, + isGalleryTabPath +} from '../lib/folder-gallery' import { setHoveredLink } from '../lib/hovered-link' import { setYankToClipboardEnabled, @@ -116,6 +122,17 @@ import { findLeaf, inferPaneDropEdge } from '../lib/pane-layout' import { livePreviewPlugin } from '../lib/cm-live-preview' import { sourceRevealExtension, sourceRevealForNewNote } from '../lib/cm-source-reveal' import { codeBlockFlairPlugin } from '../lib/cm-code-block-flair' +import { codeLanguageDetection, detectedCodeLanguage } from '../lib/cm-code-language-detect' +import { + codeFenceAt, + codeFenceStartingAt, + codeLanguagePickerItems, + setCodeFenceLanguage +} from '../lib/code-language-menu' +import { + ZEN_OPEN_CODE_LANGUAGE_PICKER_EVENT, + type CodeLanguagePickerRequest +} from '../lib/code-language-request' import { tablePlugin, tableVimEntry } from '../lib/cm-table' import { wysiwygBlocksPlugin } from '../lib/cm-wysiwyg-blocks' import { calloutFolding } from '../lib/cm-callout-fold' @@ -274,6 +291,7 @@ import { CloseIcon, PaperclipIcon, LinkIcon, + GalleryIcon, DocumentIcon, FileDownIcon, FeedbackIcon, @@ -322,6 +340,20 @@ import { } from '../lib/pane-mode' import { resolveCommentAnchor, selectionToCommentAnchor } from '../lib/comments' import { ZEN_OPEN_EDITOR_CONTEXT_MENU_EVENT } from '../lib/keyboard-context-menu' +import { + calloutRefusalMessage, + editorCalloutTarget, + removeCalloutIn, + setCalloutType, + ZEN_OPEN_CALLOUT_MENU_EVENT, + type CalloutRange +} from '../lib/callout-menu' +import { + calloutMenuEntries, + calloutTypeChoices, + type CalloutTarget +} from '../lib/callout-transform' +import { useToastStore } from '../lib/toast' import { armMiddleClickPasteGuard } from '../lib/middle-click-paste-guard' import { isWorkspaceVirtualTabPath } from '../lib/workspace-tabs' import { @@ -516,7 +548,8 @@ function wysiwygExtensions( renderTables: boolean, mathRenderer: MathRenderer, typstPreamble: string, - diagramTheme: DiagramTheme + diagramTheme: DiagramTheme, + detectCodeLanguages: boolean ): Extension[] { return [ livePreviewPlugin, @@ -526,6 +559,9 @@ function wysiwygExtensions( // A cover's frontmatter lines fold to one line in that card. coverFoldExtension, codeBlockFlairPlugin, + // An unlabeled code block is highlighted with a guess its label owns up + // to ("python · detected"), so the two load together (#1014). + ...(detectCodeLanguages ? [codeLanguageDetection()] : []), // Table widgets are gated on a setting — off keeps tables as plain editable // markdown for full keyboard/Vim editing (#232). ...(renderTables ? [tablePlugin, tableVimEntry] : []), @@ -556,7 +592,8 @@ function currentWysiwygExtensions(notePath: string | null): Extension[] { s.renderTablesInLivePreview, s.mathRenderer, selectTypstPreambleFor(s, notePath), - documentDiagramTheme() + documentDiagramTheme(), + s.detectCodeLanguages ) } @@ -643,6 +680,9 @@ const paperHighlight = HighlightStyle.define([ * switch) so the update listener skips the save schedule. */ const programmatic = Annotation.define() const OUTLINE_JUMP_TOP_MARGIN = 24 +/** The code block language picker's width (ContextMenu's `w-72`), to line its + * right edge up with the label's. */ +const CODE_LANGUAGE_PICKER_WIDTH = 288 const OUTLINE_JUMP_SCROLL_SYNC_LOCK_MS = 450 const OUTLINE_JUMP_SCROLL_SYNC_SETTLE_MS = 120 const TASK_JUMP_HIGHLIGHT_MS = 1400 @@ -930,6 +970,25 @@ function getEditorContextMenuPosition(view: EditorView): { x: number; y: number } } +/** + * What a right-click's callout items act on (#1016): the selection, when the + * click landed inside it, or else the block under the pointer, so a click on + * a rendered callout in Live Preview means that callout wherever the caret + * is. A menu opened from the keyboard has no pointer and takes the selection. + */ +function calloutRangeForMenu( + view: EditorView, + selection: CalloutRange, + pointer: { x: number; y: number } | null +): CalloutRange { + if (!pointer) return selection + const pos = view.posAtCoords(pointer, false) + if (selection.from < selection.to && pos >= selection.from && pos <= selection.to) { + return { from: selection.from, to: selection.to } + } + return { from: pos, to: pos } +} + /** * Follow a link target extracted from the editor (Cmd/Ctrl-click): an external * URL opens in the browser; a Markdown link to another note or a `[[wikilink]]` @@ -1023,6 +1082,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const harperVaultState = useStore((s) => s.vaultSettings.harper) const showHeadingLevelLabels = useStore((s) => s.showHeadingLevelLabels) const renderTablesInLivePreview = useStore((s) => s.renderTablesInLivePreview) + const detectCodeLanguages = useStore((s) => s.detectCodeLanguages) // Diagrams carry their palette inside the SVG, so a theme switch has to // reconfigure this pane and redraw them (#530). const diagramTheme = useDiagramTheme() @@ -1041,6 +1101,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const canGoBack = useStore((s) => s.noteBackstack.length > 0) const canGoForward = useStore((s) => s.noteForwardstack.length > 0) const tabNavOverrides = useStore((s) => s.keymapOverrides) + // `Space l a` beside the callout rows of the right-click menu, in Vim mode + // only: with Vim off the chord does nothing, so no row may name it. + const calloutChordParts = ( + ['vim.leaderPrefix', 'vim.leaderNoteActions', 'vim.leaderCalloutType'] as KeymapId[] + ).map((id) => getKeymapDisplay(tabNavOverrides, id)) + const calloutChordHint = + vimMode && calloutChordParts.every(Boolean) ? calloutChordParts.join(' ') : undefined const workspaceMode = useStore((s) => s.workspaceMode) const wordWrap = useStore((s) => s.wordWrap) const cursorBlink = useStore((s) => s.cursorBlink) @@ -1167,7 +1234,35 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { /** The outside link under the pointer (or the caret, when opened from the * keyboard), so the menu can offer to open or copy it. */ link: CopyableLink | null + /** What the callout items act on (#1016): the block or callout under the + * pointer, or the selection the click landed in. */ + calloutRange: CalloutRange + callout: CalloutTarget } | null>(null) + // The callout type menu (#1016), opened from the editor menu, the palette + // or `Space l a` at the same spot. `current` is the type it checks. + const [calloutMenu, setCalloutMenu] = useState<{ + x: number + y: number + range: CalloutRange + current: string | null + } | null>(null) + // The code block language picker (#1014), opened from a block's label, the + // palette or `Space l l`. `fenceFrom` is where the block's fence starts; + // `current` is its language as written and `detected` the guess, if any. + const [codeLanguageMenu, setCodeLanguageMenu] = useState<{ + x: number + y: number + /** The label's top, to open above it when there is no room below. */ + anchorTop?: number + fenceFrom: number + current: string | null + detected: string | null + } | null>(null) + // The selection when a right-button press began. A right-click on Mac + // selects the word under the pointer before the menu opens, so the + // selection the menu sees is not the one the user made. + const rightPressRef = useRef<{ from: number; to: number; at: number } | null>(null) const [editorHydration, setEditorHydration] = useState(null) const [assetDropActive, setAssetDropActive] = useState(false) const [imageDropIndicatorTop, setImageDropIndicatorTop] = useState(null) @@ -1290,6 +1385,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { if (!view || !viewPathRef.current) return false const pos = getEditorContextMenuPosition(view) const sel = view.state.selection.main + const calloutRange = { from: sel.from, to: sel.to } setActivePane(paneId) setFocusedPanel('editor') setSelectionCommentAction(null) @@ -1299,12 +1395,105 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { hasSelection: !sel.empty, selFrom: sel.from, selTo: sel.to, - link: copyableLinkAt(view, sel.head) + link: copyableLinkAt(view, sel.head), + calloutRange, + callout: editorCalloutTarget(view, calloutRange) }) view.focus() return true }, [paneId, setActivePane, setFocusedPanel]) + /** Open the callout type menu at the caret (the palette and `Space l a`), + * or say why the caret's block cannot be a callout. */ + const openCalloutMenuAtCaret = useCallback((): void => { + const view = viewRef.current + if (!view || !viewPathRef.current) return + const sel = view.state.selection.main + const range = { from: sel.from, to: sel.to } + const target = editorCalloutTarget(view, range) + if (target.kind === 'refused') { + useToastStore.getState().addToast(calloutRefusalMessage(target.reason), 'info') + return + } + const pos = getEditorContextMenuPosition(view) + setActivePane(paneId) + setFocusedPanel('editor') + setSelectionCommentAction(null) + setCalloutMenu({ + x: pos.x, + y: pos.y, + range, + current: target.kind === 'callout' ? target.callout.type : null + }) + }, [paneId, setActivePane, setFocusedPanel]) + + /** Open the code block language picker (#1014): under a block's label when + * the label was pressed, else for the block at the caret (the palette and + * `Space l l`), or say the caret is in none. */ + const openCodeLanguagePicker = useCallback( + (detail: CodeLanguagePickerRequest | undefined): void => { + const view = viewRef.current + if (!view || !viewPathRef.current) return + const { state } = view + const fence = + detail?.fenceFrom != null + ? codeFenceStartingAt(state, detail.fenceFrom) + : codeFenceAt(state, state.selection.main.head) + if (!fence) { + if (!detail?.view) useToastStore.getState().addToast('The cursor is not in a code block.', 'info') + return + } + // Under the block's label, right edges lined up, when the label is on + // screen (Edit mode); at the caret otherwise (Split has no label). + const label = + detail?.anchor ?? + view.dom + .querySelector(`[data-code-fence-from="${fence.from}"] .cm-code-flair`) + ?.getBoundingClientRect() + const at: { x: number; y: number; anchorTop?: number } = + label && label.width > 0 + ? { x: Math.max(8, label.right - CODE_LANGUAGE_PICKER_WIDTH), y: label.bottom + 4, anchorTop: label.top } + : getEditorContextMenuPosition(view) + setActivePane(paneId) + setFocusedPanel('editor') + setSelectionCommentAction(null) + setCodeLanguageMenu({ + x: at.x, + y: at.y, + anchorTop: at.anchorTop, + fenceFrom: fence.from, + current: fence.language, + detected: fence.language === null ? detectedCodeLanguage(state, fence.from) : null + }) + }, + [paneId, setActivePane, setFocusedPanel] + ) + + const codeLanguageMenuItems = useMemo((): ContextMenuItem[] => { + if (!codeLanguageMenu) return [] + const picks = codeLanguagePickerItems(codeLanguageMenu.current, codeLanguageMenu.detected) + const items: ContextMenuItem[] = [] + picks.forEach((pick, index) => { + if (pick.role === 'language' && picks[index - 1]?.role !== 'language') { + items.push({ kind: 'separator' }) + } + items.push({ + label: pick.label, + hint: pick.hint, + keywords: pick.keywords, + checked: pick.checked, + onSelect: () => { + const view = viewRef.current + if (!view) return + setCodeFenceLanguage(view, codeLanguageMenu.fenceFrom, pick.tag) + // A phone that opened the note to read keeps its keyboard down. + if (!hostOpensNotesToRead()) view.focus() + } + }) + }) + return items + }, [codeLanguageMenu]) + const rememberCurrentTabScroll = useCallback((path = viewPathRef.current): void => { if (!path) return const prev = recallTabScroll(path) @@ -1596,6 +1785,24 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { return () => window.removeEventListener(ZEN_OPEN_EDITOR_CONTEXT_MENU_EVENT, handler) }, [isActive, openEditorContextMenu]) + useEffect(() => { + if (!isActive) return + window.addEventListener(ZEN_OPEN_CALLOUT_MENU_EVENT, openCalloutMenuAtCaret) + return () => window.removeEventListener(ZEN_OPEN_CALLOUT_MENU_EVENT, openCalloutMenuAtCaret) + }, [isActive, openCalloutMenuAtCaret]) + + useEffect(() => { + const handler = (event: Event): void => { + const detail = (event as CustomEvent).detail + // A label belongs to one editor, active pane or not; the keyboard's + // request is the active pane's. + if (detail?.view ? detail.view !== viewRef.current : !isActive) return + openCodeLanguagePicker(detail) + } + window.addEventListener(ZEN_OPEN_CODE_LANGUAGE_PICKER_EVENT, handler) + return () => window.removeEventListener(ZEN_OPEN_CODE_LANGUAGE_PICKER_EVENT, handler) + }, [isActive, openCodeLanguagePicker]) + // `zen:set-pane-mode` — active-pane-only route for command palette and // vim ex commands that switch the current note between Edit / Split / // Preview without touching the toolbar. @@ -2203,7 +2410,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { s0.renderTablesInLivePreview, s0.mathRenderer, selectTypstPreambleFor(s0, initialPath), - documentDiagramTheme() + documentDiagramTheme(), + s0.detectCodeLanguages ) : [] ), @@ -2825,7 +3033,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { // `typstPreamble` is in the deps so retagging a note — or editing the // preamble note it points at — reconfigures this pane and repaints its // formulas with the new definitions. (#486) - }, [livePreview, renderTablesInLivePreview, mathRenderer, typstPreamble, diagramTheme.key]) + }, [livePreview, renderTablesInLivePreview, detectCodeLanguages, mathRenderer, typstPreamble, diagramTheme.key]) useEffect(() => { const view = viewRef.current const comp = markdownCompartmentRef.current @@ -3400,6 +3608,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { isTrash: false, isAssetsView: false, isUnresolvedLinks: false, + isGallery: false, isAsset: false, isDiagram: false, isDatabase: false @@ -3474,6 +3683,14 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { isUnresolvedLinks: true } } + if (isGalleryTabPath(path)) { + const gallery = galleryFolderFromTabPath(path) + return { + ...base, + title: gallery ? galleryFolderName(gallery, folderLabels) : 'Gallery', + isGallery: true + } + } if (isAssetTabPath(path)) { const assetPath = assetPathFromTab(path) return { @@ -3504,7 +3721,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { } }) }, - [tabs, pinnedTabs, previewTab, content, notes, folderLabels.quick, folderLabels.archive, folderLabels.trash, folderLabels.tasks] + [tabs, pinnedTabs, previewTab, content, notes, folderLabels.inbox, folderLabels.quick, folderLabels.archive, folderLabels.trash, folderLabels.tasks] ) const tabMenuItems = useMemo(() => { @@ -3573,6 +3790,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { isArchiveTabPath(path) || isTrashTabPath(path) || isUnresolvedLinksTabPath(path) || + isGalleryTabPath(path) || isAssetTabPath(path) || isDiagramTabPath(path) || isDatabaseTabPath(path) @@ -3682,6 +3900,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { isTrash: boolean isAssetsView: boolean isUnresolvedLinks: boolean + isGallery: boolean isAsset: boolean isDiagram: boolean }) => { @@ -3696,6 +3915,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { tab.isTrash || tab.isAssetsView || tab.isUnresolvedLinks || + tab.isGallery || tab.isAsset || tab.isDiagram return ( @@ -3873,6 +4093,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { {tab.isUnresolvedLinks && ( )} + {tab.isGallery && ( + + )} {tab.isAsset && ( )} @@ -4600,6 +4823,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { ) : isUnresolvedLinksTabPath(activeTab) ? ( + ) : activeTab && isGalleryTabPath(activeTab) ? ( + ) : activeTab && isAssetTabPath(activeTab) ? ( isDatabaseCsvPath(assetPathFromTab(activeTab) ?? '') ? ( { + const view = viewRef.current + if (!view) return + const sel = view.state.selection.main + rightPressRef.current = + e.button === 2 ? { from: sel.from, to: sel.to, at: Date.now() } : null + }} onContextMenu={(e) => { // Native browser context menu in Electron is threadbare // (no Copy/Cut/Paste unless dev tools are open), so we @@ -4683,6 +4915,14 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { e.preventDefault() view.focus() const sel = view.state.selection.main + const press = rightPressRef.current + rightPressRef.current = null + const recentPress = press && Date.now() - press.at < 5000 ? press : null + const calloutRange = calloutRangeForMenu( + view, + recentPress ?? { from: sel.from, to: sel.to }, + recentPress || e.button === 2 ? { x: e.clientX, y: e.clientY } : null + ) setSelectionCommentAction(null) setEditorMenu({ x: e.clientX, @@ -4690,7 +4930,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { hasSelection: !sel.empty, selFrom: sel.from, selTo: sel.to, - link: copyableLinkAtPointer(view, e.clientX, e.clientY) + link: copyableLinkAtPointer(view, e.clientX, e.clientY), + calloutRange, + callout: editorCalloutTarget(view, calloutRange) }) }} > @@ -4853,11 +5095,71 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { editorMenu.hasSelection, captureCommentDraft, { from: editorMenu.selFrom, to: editorMenu.selTo }, - editorMenu.link + editorMenu.link, + calloutMenuEntries(editorMenu.callout).map( + (entry): ContextMenuItem => ({ + label: entry.label, + disabled: entry.disabled, + hint: entry.reason ?? (entry.action === 'remove' ? undefined : calloutChordHint), + onSelect: () => { + const view = viewRef.current + if (!view) return + const { calloutRange, callout } = editorMenu + if (entry.action === 'remove') { + removeCalloutIn(view, calloutRange) + view.focus() + return + } + setCalloutMenu({ + x: editorMenu.x, + y: editorMenu.y, + range: calloutRange, + current: callout.kind === 'callout' ? callout.callout.type : null + }) + } + }) + ) )} onClose={() => setEditorMenu(null)} /> )} + {calloutMenu && ( + ({ + label: choice.label, + icon: ( + + {choice.icon} + + ), + checked: choice.checked, + keywords: choice.keywords, + onSelect: () => { + const view = viewRef.current + if (!view) return + setCalloutType(view, calloutMenu.range, choice.type) + view.focus() + } + }) + )} + onClose={() => setCalloutMenu(null)} + /> + )} + {codeLanguageMenu && ( + setCodeLanguageMenu(null)} + /> + )} ) } @@ -4965,7 +5267,9 @@ function buildEditorContextItems( // Selection snapshotted when the menu opened, applied by the highlight actions // so they don't depend on the live selection surviving the menu. (#416) selRange: { from: number; to: number }, - link: CopyableLink | null + link: CopyableLink | null, + // Turn into callout, or Change type and Remove inside one (#1016). + calloutItems: ContextMenuItem[] ): ContextMenuItem[] { if (!view) return [] @@ -5049,6 +5353,7 @@ function buildEditorContextItems( { label: 'Copy', hint: formatKeyToken('Mod+C'), disabled: !hasSelection, onSelect: copy }, { label: 'Paste', hint: formatKeyToken('Mod+V'), onSelect: paste }, { kind: 'separator' }, + ...(calloutItems.length > 0 ? [...calloutItems, { kind: 'separator' as const }] : []), { label: 'Select All', hint: formatKeyToken('Mod+A'), diff --git a/packages/app-core/src/components/GalleryView.tsx b/packages/app-core/src/components/GalleryView.tsx new file mode 100644 index 000000000..346b9fbf5 --- /dev/null +++ b/packages/app-core/src/components/GalleryView.tsx @@ -0,0 +1,673 @@ +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { NoteMeta } from '@shared/ipc' +import type { NoteCover } from '@shared/note-cover' +import { noteCoverCreditLine } from '@shared/unsplash' +import { useStore } from '../store' +import { + GALLERY_SORTS, + GALLERY_SORT_LABELS, + filterGalleryNotes, + formatGalleryDate, + galleryCardTimestamp, + galleryContentHeight, + galleryFolderExists, + galleryFolderFromTabPath, + galleryFolderName, + galleryGeometry, + galleryInitial, + galleryNotesIn, + galleryRowRange, + galleryScrollTopFor, + galleryThumbnail, + normalizeGallerySort, + sortGalleryNotes, + stepGalleryCursor, + type GalleryGeometry, + type GalleryMove, + type GalleryThumbnailSource +} from '../lib/folder-gallery' +import { galleryCoverCache, runLimited } from '../lib/gallery-covers' +import { resolveLocalAsset } from '../lib/local-assets' +import { focusEditorNormalMode } from '../lib/editor-focus' +import { isOverlayOrDialogOpen } from '../lib/overlay-open' +import { advanceSequence, getKeymapBinding, type KeymapId } from '../lib/keymaps' +import { + listViewBindingMatches, + listViewKey, + listViewSequencesLive, + listViewYieldsKey +} from '../lib/list-view-keys' +import { resolveSystemFolderLabels } from '../lib/system-folder-labels' +import { NoteCoverCredit } from './NoteCoverCredit' +import { GalleryIcon, SearchIcon } from './icons' + +// Every action this view answers, so a chord bound to one reaches it (#972). +const GALLERY_VIEW_ACTIONS: readonly KeymapId[] = [ + 'nav.filter', + 'nav.moveDown', + 'nav.moveUp', + 'nav.moveLeft', + 'nav.moveRight', + 'nav.jumpBottom', + 'nav.jumpTop', + 'nav.openResult' +] + +/** Reads at once for the covers on screen; a fast scroll queues no more. */ +const COVER_READ_CONCURRENCY = 4 +/** A row flying past during a scroll is not worth a read. */ +const COVER_READ_DELAY_MS = 60 +/** Rows mounted above and below the screen, so a scroll never shows a gap. */ +const OVERSCAN_ROWS = 2 + +/** Where each Gallery tab was, so coming back to it (Ctrl+O after opening a + * note, or its tab) lands on the card it left from. */ +interface GalleryMemory { + cursorPath: string | null + scrollTop: number + filter: string +} +const galleryMemory = new Map() + +function coverScope(s: { + workspaceMode: string + vault: { root: string } | null + remoteWorkspaceInfo: { baseUrl?: string | null } | null +}): string { + return [s.workspaceMode, s.vault?.root ?? '', s.remoteWorkspaceInfo?.baseUrl ?? ''].join('\0') +} + +/** The note's text the way the editor gets it: an open buffer first (it has + * unsaved edits, and costs no read), the bridge otherwise. */ +async function readNoteBody(path: string): Promise { + const open = useStore.getState().noteContents[path]?.body + if (open != null) return open + return (await window.zen.readNote(path)).body +} + +/** + * Reads the covers of `notes` (the cards on screen) that the cache does not + * know yet, a few at a time, and answers with a number that changes as they + * land. A scroll that moves on cancels what has not started. + */ +function useGalleryCovers(notes: readonly NoteMeta[], scope: string): number { + const [version, setVersion] = useState(0) + galleryCoverCache.setScope(scope) + const key = notes.map((note) => `${note.path}\0${note.updatedAt}`).join('\n') + useEffect(() => { + const missing = notes.filter((note) => galleryCoverCache.peek(note) === undefined) + if (missing.length === 0) return + let cancelled = false + let frame = 0 + const bump = (): void => { + if (cancelled || frame) return + frame = requestAnimationFrame(() => { + frame = 0 + setVersion((v) => v + 1) + }) + } + const timer = window.setTimeout(() => { + void runLimited( + missing, + COVER_READ_CONCURRENCY, + async (note) => { + await galleryCoverCache.load(note, readNoteBody) + bump() + }, + () => cancelled + ) + }, COVER_READ_DELAY_MS) + return () => { + cancelled = true + window.clearTimeout(timer) + if (frame) cancelAnimationFrame(frame) + } + // `notes` is read through `key`: a new array with the same notes must not + // restart the reads. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, scope]) + return version +} + +interface GalleryCardProps { + note: NoteMeta + index: number + cursor: boolean + /** Undefined while the note has not been read. */ + cover: NoteCover | null | undefined + /** The picture to draw (galleryThumbnail), as plain values so a card + * whose picture did not change skips the render. */ + imageUrl: string | null + imageY: number + imageSource: GalleryThumbnailSource | null + dateLabel: string + geometry: GalleryGeometry + onOpen: (index: number) => void +} + +const GalleryCard = memo(function GalleryCard({ + note, + index, + cursor, + cover, + imageUrl, + imageY, + imageSource, + dateLabel, + geometry, + onOpen +}: GalleryCardProps): JSX.Element { + const [failed, setFailed] = useState(false) + useEffect(() => setFailed(false), [imageUrl]) + const showImage = !!imageUrl && !failed + const state = cover === undefined ? 'loading' : showImage ? 'image' : 'placeholder' + const credit = showImage && cover ? noteCoverCreditLine(cover) : null + return ( +
onOpen(index)} + className={[ + 'group relative flex min-w-0 cursor-pointer flex-col overflow-hidden rounded-lg border bg-paper-50 outline-none transition-colors', + cursor + ? 'border-accent ring-2 ring-accent/60' + : 'border-paper-300/70 hover:border-paper-400' + ].join(' ')} + style={{ height: geometry.cardHeight }} + > +
+ {showImage && imageUrl ? ( + setFailed(true)} + className="pointer-events-none h-full w-full object-cover" + style={{ objectPosition: `50% ${Math.round(imageY * 1000) / 10}%` }} + /> + ) : state === 'placeholder' ? ( + + ) : null} + {credit && ( + // The credit's links open the photographer's page, not the note. +
e.stopPropagation()}> + +
+ )} +
+
+
+ {note.title} +
+
+ {dateLabel} +
+
+
+ ) +}) + +/** + * A folder's Gallery: its notes as a grid of cards, each led by the note's + * cover (an image in the vault, an Unsplash photo or a gradient), then its + * title and date. Asked for by charles b. once covers shipped. + * + * Only the rows on screen are mounted, and only the cards on screen have + * their notes read for a cover (gallery-covers.ts), so a folder of a + * thousand notes opens as fast as one of ten. With Vim on, h/j/k/l move + * across the grid, gg/G jump to the ends, Enter or o opens and / filters by + * title; with it off the arrows, Enter and Escape do the work. The view holds + * the keyboard the way the Unresolved Links view does: VimNav stands down + * for it except for the leader. + */ +export function GalleryView({ tabPath, isActive }: { tabPath: string; isActive: boolean }): JSX.Element { + const target = useMemo(() => galleryFolderFromTabPath(tabPath), [tabPath]) + const notes = useStore((s) => s.notes) + const folders = useStore((s) => s.folders) + const vaultSettings = useStore((s) => s.vaultSettings) + const systemFolderLabels = useStore((s) => s.systemFolderLabels) + const vaultRoot = useStore((s) => s.vault?.root ?? null) + // Subscribed so a vault cover resolves as soon as the file list lands. + const assetFiles = useStore((s) => s.assetFiles) + const assetFilesListed = useStore((s) => s.assetFilesListed) + const sort = useStore((s) => s.gallerySort) + const setSort = useStore((s) => s.setGallerySort) + const includeSubfolders = useStore((s) => s.galleryIncludeSubfolders) + const setIncludeSubfolders = useStore((s) => s.setGalleryIncludeSubfolders) + const keymapOverrides = useStore((s) => s.keymapOverrides) + const vimMode = useStore((s) => s.vimMode) + const selectNote = useStore((s) => s.selectNote) + const scope = useStore(coverScope) + + const remembered = galleryMemory.get(tabPath) + const [filter, setFilter] = useState(remembered?.filter ?? '') + const [cursorPath, setCursorPath] = useState(remembered?.cursorPath ?? null) + const [scrollTop, setScrollTop] = useState(remembered?.scrollTop ?? 0) + const [size, setSize] = useState({ width: 0, height: 0 }) + const rootRef = useRef(null) + const scrollRef = useRef(null) + const filterRef = useRef(null) + const revealCursor = useRef(false) + const gPending = useRef(0) + const gTimer = useRef>() + + const labels = useMemo(() => resolveSystemFolderLabels(systemFolderLabels), [systemFolderLabels]) + const exists = target ? galleryFolderExists(target, folders) : false + + const inFolder = useMemo( + () => (target ? galleryNotesIn(notes, target, includeSubfolders, vaultSettings) : []), + [notes, target, includeSubfolders, vaultSettings] + ) + // Notes the switch would add: an empty folder with full subfolders says so. + const belowCount = useMemo( + () => + target && !includeSubfolders + ? galleryNotesIn(notes, target, true, vaultSettings).length - inFolder.length + : 0, + [notes, target, includeSubfolders, vaultSettings, inFolder.length] + ) + const sorted = useMemo(() => sortGalleryNotes(inFolder, sort), [inFolder, sort]) + const items = useMemo(() => filterGalleryNotes(sorted, filter), [sorted, filter]) + + const geometry = useMemo(() => galleryGeometry(size.width), [size.width]) + const cursor = Math.max( + 0, + cursorPath ? items.findIndex((note) => note.path === cursorPath) : 0 + ) + const current = items[cursor] ?? null + + // Remember where this tab was, for the next time it shows. + useEffect(() => { + galleryMemory.set(tabPath, { cursorPath: current?.path ?? null, scrollTop, filter }) + }, [tabPath, current, scrollTop, filter]) + + // The scroller's size decides the columns and which rows are on screen. + useLayoutEffect(() => { + const el = scrollRef.current + if (!el) return + const measure = (): void => { + setSize((prev) => + prev.width === el.clientWidth && prev.height === el.clientHeight + ? prev + : { width: el.clientWidth, height: el.clientHeight } + ) + } + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => observer.disconnect() + }, []) + + // Put a remembered scroll back once the grid has a size to scroll in. + const restored = useRef(false) + useLayoutEffect(() => { + if (restored.current || size.width === 0) return + restored.current = true + const el = scrollRef.current + if (el && remembered?.scrollTop) el.scrollTop = remembered.scrollTop + // Only on the first measured layout. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [size.width]) + + // Keep the cursor's card in view when the keyboard moves it. + useEffect(() => { + if (!revealCursor.current) return + revealCursor.current = false + const el = scrollRef.current + if (!el || size.height === 0) return + const next = galleryScrollTopFor(cursor, geometry, el.scrollTop, el.clientHeight) + if (next !== el.scrollTop) el.scrollTop = next + }, [cursor, geometry, size.height]) + + // Take the keyboard when the view comes up with nothing else holding it. + useEffect(() => { + if (!isActive) return + const raf = requestAnimationFrame(() => { + const active = document.activeElement + if (!active || active === document.body) rootRef.current?.focus({ preventScroll: true }) + }) + return () => cancelAnimationFrame(raf) + }, [isActive]) + + const onScroll = useCallback(() => { + const el = scrollRef.current + if (el) setScrollTop(el.scrollTop) + }, []) + + const rendered = galleryRowRange(items.length, geometry, scrollTop, size.height, OVERSCAN_ROWS) + const onScreen = galleryRowRange(items.length, geometry, scrollTop, size.height, 0) + const visibleNotes = useMemo( + () => + size.width === 0 + ? [] + : items.slice(onScreen.start * geometry.columns, onScreen.end * geometry.columns), + [items, onScreen.start, onScreen.end, geometry.columns, size.width] + ) + useGalleryCovers(visibleNotes, scope) + + const openAt = useCallback( + async (index: number) => { + const note = items[index] + if (!note) return + setCursorPath(note.path) + galleryMemory.set(tabPath, { + cursorPath: note.path, + scrollTop: scrollRef.current?.scrollTop ?? 0, + filter + }) + await selectNote(note.path) + focusEditorNormalMode() + }, + [filter, items, selectNote, tabPath] + ) + + // One callback for every card, so a card that did not change skips the + // render; it reaches the latest openAt through the ref. + const openRef = useRef(openAt) + openRef.current = openAt + const onOpenCard = useCallback((index: number) => void openRef.current(index), []) + + const move = useCallback( + (direction: GalleryMove) => { + const next = stepGalleryCursor(cursor, items.length, geometry.columns, direction) + if (next < 0) return + revealCursor.current = true + setCursorPath(items[next]?.path ?? null) + }, + [cursor, geometry.columns, items] + ) + + useEffect(() => { + if (!isActive) return + const handler = (e: KeyboardEvent): void => { + if (isOverlayOrDialogOpen()) return + const focusedPanel = useStore.getState().focusedPanel + if (focusedPanel !== null && focusedPanel !== 'editor') return + const eventTarget = e.target instanceof HTMLElement ? e.target : null + if (eventTarget) { + const tag = eventTarget.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || eventTarget.isContentEditable) + return + } + const overrides = keymapOverrides + const consume = (): void => { + e.preventDefault() + e.stopImmediatePropagation() + } + // A chord gets through only as the key of one of this view's actions, + // and never past a global shortcut (#972, lib/list-view-keys.ts). + if (listViewYieldsKey(e, overrides, GALLERY_VIEW_ACTIONS, vimMode)) return + // With Vim off, single-key shortcuts stay off: the arrows, Enter and + // Escape, plus any action the user bound to a chord. + const seq = (id: KeymapId): boolean => listViewBindingMatches(e, overrides, id, vimMode) + const key = listViewKey(e) + if (key === 'Escape') { + if (!filter) return + consume() + setFilter('') + return + } + if (seq('nav.filter')) { + consume() + filterRef.current?.focus() + filterRef.current?.select() + return + } + if (seq('nav.moveLeft') || key === 'ArrowLeft') { + consume() + move('left') + return + } + if (seq('nav.moveRight') || key === 'ArrowRight') { + consume() + move('right') + return + } + if (seq('nav.moveDown') || key === 'ArrowDown') { + consume() + move('down') + return + } + if (seq('nav.moveUp') || key === 'ArrowUp') { + consume() + move('up') + return + } + if (seq('nav.jumpBottom')) { + consume() + move('last') + return + } + if ( + listViewSequencesLive(e, vimMode) && + advanceSequence( + e, + getKeymapBinding(overrides, 'nav.jumpTop'), + gPending, + gTimer, + () => move('first'), + consume, + 500 + ) + ) { + return + } + if ((key === 'Enter' || seq('nav.openResult')) && current) { + consume() + void openAt(cursor) + } + } + window.addEventListener('keydown', handler, true) + return () => { + if (gTimer.current) clearTimeout(gTimer.current) + window.removeEventListener('keydown', handler, true) + } + }, [cursor, current, filter, isActive, keymapOverrides, move, openAt, vimMode]) + + const resolveVault = useCallback( + (notePath: string) => + (href: string): string | null => { + const asset = resolveLocalAsset(vaultRoot, notePath, href) + return asset && !asset.missing ? asset.url : null + }, + // The asset list is read inside resolveLocalAsset; a new list means new + // answers. + // eslint-disable-next-line react-hooks/exhaustive-deps + [vaultRoot, assetFiles, assetFilesListed] + ) + + if (!target) { + return ( +
+ This Gallery tab does not name a folder. +
+ ) + } + + const name = galleryFolderName(target, labels) + const place = target.subpath ? `${labels[target.folder]} / ${target.subpath}` : labels[target.folder] + const total = inFolder.length + const countLabel = filter.trim() + ? `${items.length} of ${total} ${total === 1 ? 'note' : 'notes'}` + : `${total} ${total === 1 ? 'note' : 'notes'}` + const now = Date.now() + + const rows: JSX.Element[] = [] + if (size.width > 0) { + for (let row = rendered.start; row < rendered.end; row += 1) { + const cards: JSX.Element[] = [] + for (let col = 0; col < geometry.columns; col += 1) { + const index = row * geometry.columns + col + const note = items[index] + if (!note) break + const cover = galleryCoverCache.peek(note) + const thumbnail = galleryThumbnail(cover ?? null, resolveVault(note.path)) + const image = thumbnail.kind === 'image' ? thumbnail : null + cards.push( + + ) + } + rows.push( +
+ {cards} +
+ ) + } + } + + const empty = !exists + ? 'This folder is no longer in the vault.' + : total === 0 + ? belowCount > 0 + ? `No notes here yet. Include subfolders to see the ${belowCount} ${belowCount === 1 ? 'note' : 'notes'} below this folder.` + : 'No notes in this folder yet.' + : items.length === 0 + ? `No note titles match “${filter.trim()}”.` + : null + + return ( +
+
+
+ +

+ {name} +

+ + Gallery · {countLabel} + +
+
+ + +
+ + { + setFilter(e.target.value) + setCursorPath(null) + if (scrollRef.current) scrollRef.current.scrollTop = 0 + }} + onKeyDown={(e) => { + if (e.key === 'Escape' || e.key === 'Enter' || e.key === 'ArrowDown') { + e.preventDefault() + if (e.key === 'Escape') setFilter('') + rootRef.current?.focus({ preventScroll: true }) + } + }} + placeholder="Filter by title" + aria-label="Filter the Gallery by title" + className="w-36 bg-transparent text-xs text-ink-900 outline-none placeholder:text-ink-400" + /> +
+
+
+
+ {empty ? ( +
+ {empty} +
+ ) : ( +
+ {rows} +
+ )} +
+
+ ) +} diff --git a/packages/app-core/src/components/HelpView.tsx b/packages/app-core/src/components/HelpView.tsx index 88784a3a0..116ae9326 100644 --- a/packages/app-core/src/components/HelpView.tsx +++ b/packages/app-core/src/components/HelpView.tsx @@ -104,6 +104,19 @@ function resolveShortcutKeys( if (action === 'Note outline') return leaderShortcut(overrides, 'vim.leaderNoteOutline') if (action === 'Toggle timeline') return leaderShortcut(overrides, 'vim.leaderTimeline') if (action === 'Unresolved links') return leaderShortcut(overrides, 'vim.leaderUnresolvedLinks') + if (action === 'Folder gallery') return leaderShortcut(overrides, 'vim.leaderFolderGallery') + if (action === 'Callout type') { + return chord( + leaderShortcut(overrides, 'vim.leaderNoteActions'), + shortcut(overrides, 'vim.leaderCalloutType') + ) + } + if (action === 'Code block language') { + return chord( + leaderShortcut(overrides, 'vim.leaderNoteActions'), + shortcut(overrides, 'vim.leaderCodeLanguage') + ) + } if (action === 'Switch vault') return leaderShortcut(overrides, 'vim.leaderSwitchVault') if (action === 'Open workflows') return leaderShortcut(overrides, 'vim.leaderWorkflows') if (action === 'Review Cloud conflicts') { @@ -142,6 +155,22 @@ function resolveShortcutKeys( if (action === 'Open context menu') return shortcut(overrides, 'nav.contextMenu') } + if (sectionId === 'gallery-view') { + if (action === 'Move across the grid') { + return alternatives([ + shortcut(overrides, 'nav.moveLeft'), + shortcut(overrides, 'nav.moveDown'), + shortcut(overrides, 'nav.moveUp'), + shortcut(overrides, 'nav.moveRight') + ]) + } + if (action === 'First or last card') { + return alternatives([shortcut(overrides, 'nav.jumpTop'), shortcut(overrides, 'nav.jumpBottom')]) + } + if (action === 'Open the note') return alternatives(['Enter', shortcut(overrides, 'nav.openResult')]) + if (action === 'Filter by title') return shortcut(overrides, 'nav.filter') + } + if (sectionId === 'preview-and-connections') { if (action === 'Scroll preview') { return alternatives([shortcut(overrides, 'nav.moveDown'), shortcut(overrides, 'nav.moveUp')]) diff --git a/packages/app-core/src/components/NoteCoverCredit.tsx b/packages/app-core/src/components/NoteCoverCredit.tsx index b4b7c5e73..22ada4efd 100644 --- a/packages/app-core/src/components/NoteCoverCredit.tsx +++ b/packages/app-core/src/components/NoteCoverCredit.tsx @@ -9,10 +9,14 @@ import type { NoteCoverCreditLine } from '@shared/unsplash' */ export function NoteCoverCredit({ line, - persistent + persistent, + compact = false }: { line: NoteCoverCreditLine persistent: boolean + /** A small frame (a Gallery card): tighter to the corner, and as wide as + * the frame at most. */ + compact?: boolean }): JSX.Element { const link = (label: string, href: string | null): JSX.Element => href ? ( @@ -27,7 +31,10 @@ export function NoteCoverCredit({ data-note-cover-credit="" className={[ // The cover's buttons' own surface, so the corner reads as one set of chrome. - 'zen-note-cover-credit absolute bottom-3 left-3 max-w-md truncate rounded-md bg-paper-50/90 px-2 py-1 text-2xs text-ink-600', + 'zen-note-cover-credit absolute truncate bg-paper-50/90 text-2xs text-ink-600', + compact + ? 'bottom-1.5 left-1.5 right-1.5 rounded px-1.5 py-0.5' + : 'bottom-3 left-3 max-w-md rounded-md px-2 py-1', persistent ? '' : 'opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100' ].join(' ')} > diff --git a/packages/app-core/src/components/PaletteFocusHandoff.test.ts b/packages/app-core/src/components/PaletteFocusHandoff.test.ts index 8aedc3cf4..1b058d286 100644 --- a/packages/app-core/src/components/PaletteFocusHandoff.test.ts +++ b/packages/app-core/src/components/PaletteFocusHandoff.test.ts @@ -140,6 +140,15 @@ describe('palette hand-offs', () => { expect(focusEditorAfterReadOpen).not.toHaveBeenCalled() }) + it('a theme picked from Themes hands back like a close, so a phone keeps its keyboard down', async () => { + useStore.setState({ commandPaletteOpen: true, commandPaletteInitialMode: 'theme' }) + render(CommandPalette) + await key('Enter') + expect(useStore.getState().commandPaletteOpen).toBe(false) + expect(focusEditorAfterPaletteClose).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + }) + it('the template palette closes through the palette close hand-off', async () => { useStore.setState({ templatePaletteOpen: true }) render(TemplatePalette) diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index 45395d5db..6f0ddac18 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -6,6 +6,7 @@ import type { NoteMeta } from "@shared/ipc"; import { renderMarkdown } from "../lib/markdown"; import { substituteLiveTokens } from "../lib/live-template-tokens"; import { + setMarkdownDetectCodeLanguages, setMarkdownLooseMathDelimiters, setMarkdownMathRenderer, } from "../lib/markdown-settings"; @@ -196,6 +197,7 @@ export const Preview = memo(function Preview({ const ref = useRef(null); const mathRenderer = useStore((s) => s.mathRenderer); const looseMathDelimiters = useStore((s) => s.looseMathDelimiters); + const detectCodeLanguages = useStore((s) => s.detectCodeLanguages); const vault = useStore((s) => s.vault); const notes = useStore((s) => s.notes); const folders = useStore((s) => s.folders); @@ -346,6 +348,7 @@ export const Preview = memo(function Preview({ // takes effect on the very next render without an effect-ordering race. setMarkdownMathRenderer(mathRenderer); setMarkdownLooseMathDelimiters(looseMathDelimiters); + setMarkdownDetectCodeLanguages(detectCodeLanguages); return renderMarkdown( substituteLiveTokens(expandedForCurrent ?? markdown, noteUpdatedAt), ); @@ -357,6 +360,7 @@ export const Preview = memo(function Preview({ noteUpdatedAt, mathRenderer, looseMathDelimiters, + detectCodeLanguages, customCodeLanguagesRevision, ]); const assetFilesKey = useMemo( diff --git a/packages/app-core/src/components/SearchPaletteBlocks.test.ts b/packages/app-core/src/components/SearchPaletteBlocks.test.ts index e9ab44b7f..37cf90db1 100644 --- a/packages/app-core/src/components/SearchPaletteBlocks.test.ts +++ b/packages/app-core/src/components/SearchPaletteBlocks.test.ts @@ -132,11 +132,24 @@ describe('SearchPalette: tagged blocks under a #tag query', () => { await new Promise((r) => setTimeout(r, 80)) }) } + /** Wait until the first block row is drawn, then settle. A fixed wait was + * not enough when the gate ran every package's suite at once: the reads + * and their notify timer took longer than 80 ms and the rows were not + * there yet. */ + const settleBlocks = async (): Promise => { + const deadline = Date.now() + 3000 + while (!document.querySelector('[data-search-block]') && Date.now() < deadline) { + await act(async () => { + await new Promise((r) => setTimeout(r, 10)) + }) + } + await settle() + } it('lists two blocks under a note, then a row for the rest; a frontmatter-only note is plain', async () => { act(() => root.render(createElement(SearchPalette))) type('#idea') - await settle() + await settleBlocks() expect(rows()).toEqual([ 'Ideas', ' inbox/Ideas.md:3', @@ -149,7 +162,7 @@ describe('SearchPalette: tagged blocks under a #tag query', () => { it('opens a block at its line, highlighted, and closes the palette', async () => { act(() => root.render(createElement(SearchPalette))) type('#idea') - await settle() + await settleBlocks() await key('ArrowDown') await key('Enter') await settle() @@ -167,7 +180,7 @@ describe('SearchPalette: tagged blocks under a #tag query', () => { it('opens the Tags view on the query tags from the "+N more" row', async () => { act(() => root.render(createElement(SearchPalette))) type('#idea') - await settle() + await settleBlocks() for (let i = 0; i < 3; i++) await key('j', { ctrlKey: true }) await key('Enter') expect(openTagViewFor).toHaveBeenCalledWith(['idea'], '') @@ -176,7 +189,7 @@ describe('SearchPalette: tagged blocks under a #tag query', () => { it('leaves Ctrl+D inert on a block row', async () => { act(() => root.render(createElement(SearchPalette))) type('#idea') - await settle() + await settleBlocks() await key('ArrowDown') await key('d', { ctrlKey: true }) expect(confirmApp).not.toHaveBeenCalled() @@ -186,7 +199,7 @@ describe('SearchPalette: tagged blocks under a #tag query', () => { it('narrows the blocks to the free text and marks the match', async () => { act(() => root.render(createElement(SearchPalette))) type('#idea plugin') - await settle() + await settleBlocks() expect(rows().filter((r) => r.startsWith(' '))).toEqual([' inbox/Ideas.md:4']) expect(document.querySelector('[data-block-match]')?.textContent).toBe('Plugin') }) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index b113347a5..d4c71a901 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -734,6 +734,8 @@ export function SettingsModal(): JSX.Element { const setRenderTablesInLivePreview = useStore( (s) => s.setRenderTablesInLivePreview, ); + const detectCodeLanguages = useStore((s) => s.detectCodeLanguages); + const setDetectCodeLanguages = useStore((s) => s.setDetectCodeLanguages); const completedTaskStyle = useStore((s) => s.completedTaskStyle); const setCompletedTaskStyle = useStore((s) => s.setCompletedTaskStyle); const showArchivedTasks = useStore((s) => s.showArchivedTasks); @@ -2340,6 +2342,25 @@ export function SettingsModal(): JSX.Element { "source", ], }, + { + id: "detect-code-languages", + title: "Detect code block languages", + description: + "Guess the language of a code block that names none, and highlight it with the guess.", + keywords: [ + "code", + "code block", + "fence", + "language", + "syntax", + "highlight", + "highlighting", + "detect", + "detection", + "guess", + "auto", + ], + }, { id: "completed-task-style", title: "Completed task style", @@ -3014,6 +3035,7 @@ export function SettingsModal(): JSX.Element { searchIds: [ "live-preview", "render-tables", + "detect-code-languages", "completed-task-style", "math-renderer", "math-font-scale", @@ -3066,6 +3088,13 @@ export function SettingsModal(): JSX.Element { onChange={setRenderTablesInLivePreview} /> )} + s.openHelpView); const helpViewActive = useStore(isHelpViewActive); const openArchiveView = useStore((s) => s.openArchiveView); + const openFolderGallery = useStore((s) => s.openFolderGallery); const archiveViewActive = useStore(isArchiveViewActive); const openTrashView = useStore((s) => s.openTrashView); const trashViewActive = useStore(isTrashViewActive); @@ -1969,6 +1971,17 @@ export function Sidebar(): JSX.Element { }, }); } + // The folder's notes as cards, led by their covers. A database's record + // pages have its own grid, so a `.base` folder has no Gallery. + if (!isFormDirName(subpath.split("/").slice(-1)[0])) { + items.push({ + label: "Open as Gallery", + icon: , + onSelect: async () => { + await openFolderGallery(folder, subpath); + }, + }); + } // Quick Notes is a flat folder — no nested subfolders allowed. if (folder !== "quick") { items.push({ @@ -2177,6 +2190,7 @@ export function Sidebar(): JSX.Element { openTemplatePaletteForFolder, openArchiveView, openQuickNotesView, + openFolderGallery, createFolderAction, renameFolderAction, deleteFolderAction, diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index 6b5d2b884..21afbe25b 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -2,11 +2,13 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { completionStatus } from '@codemirror/autocomplete' import { isAtlasViewActive, + isGalleryViewActive, isTagsViewActive, isTasksViewActive, isUnresolvedLinksViewActive, useStore } from '../store' +import { openContextGallery } from '../lib/folder-gallery-open' import { HintOverlay } from './HintOverlay' import { WhichKeyOverlay, type WhichKeyItem } from './WhichKeyOverlay' import { @@ -61,6 +63,8 @@ import { trashSidebarCursorRow } from '../lib/sidebar-note-actions' import { convertTableToDatabase } from '../lib/table-to-database' +import { requestCalloutTypeMenu } from '../lib/callout-menu' +import { requestCodeLanguagePicker } from '../lib/code-language-request' import { confirmDeleteComment } from '../lib/confirm-trash' import { ZEN_OPEN_EDITOR_CONTEXT_MENU_EVENT, @@ -313,6 +317,16 @@ export function VimNav(): JSX.Element | null { label: 'Cover image', detail: 'Add, change or remove the banner image across the top of the note.' }, + { + keyLabel: getKeymapDisplay(keymapOverrides, 'vim.leaderCalloutType'), + label: 'Callout type', + detail: 'Turn the paragraph or selected lines into a callout, or change the callout here.' + }, + { + keyLabel: getKeymapDisplay(keymapOverrides, 'vim.leaderCodeLanguage'), + label: 'Code block language', + detail: 'Pick the language of the code block the cursor is in, or confirm a detected one.' + }, ...(unsplashSearchAvailable() ? [ { @@ -403,6 +417,11 @@ export function VimNav(): JSX.Element | null { label: 'Unresolved links', detail: 'Every link to a note that does not exist yet, with the lines that link to it.' }, + { + keyLabel: getKeymapDisplay(keymapOverrides, 'vim.leaderFolderGallery'), + label: 'Folder gallery', + detail: 'This folder’s notes as cards, led by their covers.' + }, ...(canSwitchVaults ? [ { @@ -1017,11 +1036,12 @@ export function VimNav(): JSX.Element | null { ) { return } - // The Unresolved Links view stands where the editor would, so it holds - // the keyboard whenever the editor panel does, through its own - // capture handler. The leader still reaches the logic below. + // The Unresolved Links view and a folder's Gallery stand where the + // editor would, so they hold the keyboard whenever the editor panel + // does, through their own capture handlers. The leader still reaches + // the logic below. if ( - isUnresolvedLinksViewActive(state) && + (isUnresolvedLinksViewActive(state) || isGalleryViewActive(state)) && (state.focusedPanel == null || state.focusedPanel === 'editor') && !leaderPending.current && sequenceTokenFromEvent(e) !== leaderToken @@ -1125,6 +1145,13 @@ export function VimNav(): JSX.Element | null { void state.openUnresolvedLinksView() return } + if (matchesSequenceToken(e, overrides, 'vim.leaderFolderGallery')) { + e.preventDefault() + e.stopImmediatePropagation() + resetLeader() + void openContextGallery() + return + } if (canSwitchVaults && matchesSequenceToken(e, overrides, 'vim.leaderSwitchVault')) { e.preventDefault() e.stopImmediatePropagation() @@ -1311,6 +1338,20 @@ export function VimNav(): JSX.Element | null { openUnsplashCoverPicker() return } + if (matchesSequenceToken(e, overrides, 'vim.leaderCalloutType') && editorNormalMode) { + e.preventDefault() + e.stopImmediatePropagation() + resetLeader() + requestCalloutTypeMenu() + return + } + if (matchesSequenceToken(e, overrides, 'vim.leaderCodeLanguage') && editorNormalMode) { + e.preventDefault() + e.stopImmediatePropagation() + resetLeader() + requestCodeLanguagePicker() + return + } resetLeader() } diff --git a/packages/app-core/src/components/icons.tsx b/packages/app-core/src/components/icons.tsx index a7d4273e5..099620365 100644 --- a/packages/app-core/src/components/icons.tsx +++ b/packages/app-core/src/components/icons.tsx @@ -182,6 +182,16 @@ export const ImageIcon = (p: IconProps): JSX.Element => ( ) +/** A folder's Gallery: its notes as cards. */ +export const GalleryIcon = (p: IconProps): JSX.Element => ( + + + + + + +) + export const PaperclipIcon = (p: IconProps): JSX.Element => ( diff --git a/packages/app-core/src/folder-actions.test.ts b/packages/app-core/src/folder-actions.test.ts index c3683cf9a..1e5cd4849 100644 --- a/packages/app-core/src/folder-actions.test.ts +++ b/packages/app-core/src/folder-actions.test.ts @@ -178,6 +178,24 @@ describe('folder mutation state', () => { } ) + it.each([false, true])( + 'moves a Gallery tab with its renamed folder and closes it with a deleted one, root mode %s', + async (root) => { + const s = await setup(root) + const { galleryTabPath } = await import('./lib/folder-gallery') + const leaf = s.useStore.getState().paneLayout + if (leaf.kind !== 'leaf') throw new Error('expected one pane') + const tabs = [...leaf.tabs, galleryTabPath('inbox', 'Work'), galleryTabPath('inbox', 'Other')] + s.useStore.setState({ paneLayout: { ...leaf, tabs } }) + await s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + expect(s.tabs()).toContain(galleryTabPath('inbox', 'Renamed')) + expect(s.tabs()).not.toContain(galleryTabPath('inbox', 'Work')) + await s.useStore.getState().deleteFolder('inbox', 'Renamed') + expect(s.tabs()).not.toContain(galleryTabPath('inbox', 'Renamed')) + expect(s.tabs()).toContain(galleryTabPath('inbox', 'Other')) + } + ) + it.each(['createFolder', 'renameFolder', 'deleteFolder'] as const)( 'ignores a %s response after a vault switch', async (action) => { diff --git a/packages/app-core/src/lib/callout-menu.test.ts b/packages/app-core/src/lib/callout-menu.test.ts new file mode 100644 index 000000000..21167cc5d --- /dev/null +++ b/packages/app-core/src/lib/callout-menu.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment jsdom + +import { history, undo } from '@codemirror/commands' +import { EditorSelection, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, describe, expect, it } from 'vitest' +import { removeCalloutAtCursor, removeCalloutIn, setCalloutType } from './callout-menu' +import { useToastStore } from './toast' + +let views: EditorView[] = [] +afterEach(() => { + for (const view of views) view.destroy() + views = [] + useToastStore.setState({ toasts: [] }) +}) + +function mount(doc: string, anchor: number, head = anchor): EditorView { + const view = new EditorView({ + state: EditorState.create({ + doc, + selection: EditorSelection.single(anchor, head), + extensions: [history()] + }), + parent: document.body.appendChild(document.createElement('div')) + }) + views.push(view) + return view +} + +/** Type a character the way the keyboard does, so history sees typing. */ +function type(view: EditorView, text: string): void { + const at = view.state.selection.main.head + view.dispatch({ + changes: { from: at, insert: text }, + selection: { anchor: at + text.length }, + userEvent: 'input.type' + }) +} + +describe('callout actions in the editor (#1016)', () => { + it('turns two selected lines into a warning callout, undone in one step', () => { + const doc = 'Intro\n\nfirst line\nsecond line\n\nEnd' + const view = mount(doc, doc.indexOf('first'), doc.indexOf('second') + 3) + expect(setCalloutType(view, view.state.selection.main, 'warning')).toBe(true) + expect(view.state.doc.toString()).toBe('Intro\n\n> [!warning]\n> first line\n> second line\n\nEnd') + const { from, to } = view.state.selection.main + expect(view.state.sliceDoc(from, to)).toBe('first line\n> sec') + undo(view) + expect(view.state.doc.toString()).toBe(doc) + }) + + it('keeps the change apart from typing just before it, for Undo', () => { + const view = mount('word', 4) + type(view, 's') + setCalloutType(view, view.state.selection.main, 'tip') + expect(view.state.doc.toString()).toBe('> [!tip]\n> words') + undo(view) + expect(view.state.doc.toString()).toBe('words') + }) + + it('changes the type of the callout at the caret, keeping its title', () => { + const doc = '> [!warning]- Careful\n> body' + const view = mount(doc, doc.indexOf('body')) + setCalloutType(view, view.state.selection.main, 'tip') + expect(view.state.doc.toString()).toBe('> [!tip]- Careful\n> body') + expect(view.state.sliceDoc(view.state.selection.main.head)).toBe('body') + }) + + it('changes the callout a range names, wherever the caret is', () => { + const doc = 'top line\n\n> [!note]\n> body' + const view = mount(doc, 0) + const at = doc.indexOf('body') + setCalloutType(view, { from: at, to: at }, 'bug') + expect(view.state.doc.toString()).toBe('top line\n\n> [!bug]\n> body') + expect(view.state.selection.main.head).toBe(0) + }) + + it('refuses a caret in a code block and leaves the note alone', () => { + const doc = '```\ncode\n```' + const view = mount(doc, doc.indexOf('code')) + expect(setCalloutType(view, view.state.selection.main, 'note')).toBe(false) + expect(view.state.doc.toString()).toBe(doc) + }) + + it('removes the callout, undone in one step', () => { + const doc = '> [!tip] Title\n> body' + const view = mount(doc, doc.indexOf('body')) + expect(removeCalloutIn(view, view.state.selection.main)).toBe(true) + expect(view.state.doc.toString()).toBe('Title\nbody') + undo(view) + expect(view.state.doc.toString()).toBe(doc) + }) + + it('says so when Remove Callout runs outside a callout', () => { + const view = mount('plain', 1) + removeCalloutAtCursor(view) + expect(view.state.doc.toString()).toBe('plain') + expect(useToastStore.getState().toasts.map((t) => t.message)).toEqual([ + 'The cursor is not in a callout.' + ]) + }) +}) diff --git a/packages/app-core/src/lib/callout-menu.ts b/packages/app-core/src/lib/callout-menu.ts new file mode 100644 index 000000000..9ff1e31bf --- /dev/null +++ b/packages/app-core/src/lib/callout-menu.ts @@ -0,0 +1,99 @@ +/** + * The callout actions in the editor (#1016): the right-click menu, the "Set + * Callout Type…" and "Remove Callout" palette commands, and `Space l a` all + * land here, so each is one undoable change made the same way. + * + * The type menu lives in the active EditorPane (it is that pane's state), so + * the palette and the leader key ask for it with a window event, the way the + * keyboard opens the editor's context menu. + */ +import { EditorSelection } from '@codemirror/state' +import type { EditorView } from '@codemirror/view' +import { + calloutTargetAt, + changeCalloutType, + removeCallout, + turnIntoCallout, + type CalloutEdit, + type CalloutRefusal, + type CalloutTarget +} from './callout-transform' +import { useToastStore } from './toast' + +export const ZEN_OPEN_CALLOUT_MENU_EVENT = 'zen:open-callout-type-menu' + +/** Open the callout type menu at the caret of the active editor. */ +export function requestCalloutTypeMenu(): void { + window.dispatchEvent(new Event(ZEN_OPEN_CALLOUT_MENU_EVENT)) +} + +export interface CalloutRange { + from: number + to: number +} + +export function editorCalloutTarget(view: EditorView, range: CalloutRange): CalloutTarget { + return calloutTargetAt(view.state.doc.toString(), range) +} + +const REFUSAL_MESSAGE: Record = { + code: 'A callout cannot go inside a code block.', + frontmatter: 'A callout cannot go in the note’s properties.', + blank: 'Put the cursor in a paragraph, or select lines, to make a callout.' +} + +export function calloutRefusalMessage(reason: CalloutRefusal): string { + return REFUSAL_MESSAGE[reason] +} + +function liveSelection(view: EditorView): { anchor: number; head: number } { + const main = view.state.selection.main + return { anchor: main.anchor, head: main.head } +} + +function apply(view: EditorView, edit: CalloutEdit): void { + view.dispatch({ + changes: edit.changes, + selection: EditorSelection.single(edit.selection.anchor, edit.selection.head), + scrollIntoView: true, + // Not `input.type`: history would fold the change into typing that came + // just before it, and one Undo has to take back exactly this. + userEvent: 'input.callout' + }) +} + +/** + * Make what `range` targets a callout of `type`: change the callout it is in, + * or turn its block into one. False when the range refuses a callout. + */ +export function setCalloutType(view: EditorView, range: CalloutRange, type: string): boolean { + const doc = view.state.doc.toString() + const target = calloutTargetAt(doc, range) + if (target.kind === 'callout') { + const edit = changeCalloutType(target.callout, type, liveSelection(view)) + if (edit) apply(view, edit) + return true + } + if (target.kind === 'block') { + apply(view, turnIntoCallout(doc, target, type, liveSelection(view))) + return true + } + return false +} + +/** Remove the callout `range` is in. False when it is in none. */ +export function removeCalloutIn(view: EditorView, range: CalloutRange): boolean { + const doc = view.state.doc.toString() + const target = calloutTargetAt(doc, range) + if (target.kind !== 'callout') return false + apply(view, removeCallout(doc, target.callout, liveSelection(view))) + return true +} + +/** The "Remove Callout" command: the callout at the caret, or a toast. */ +export function removeCalloutAtCursor(view: EditorView): void { + const main = view.state.selection.main + if (!removeCalloutIn(view, { from: main.from, to: main.to })) { + useToastStore.getState().addToast('The cursor is not in a callout.', 'info') + } +} diff --git a/packages/app-core/src/lib/callout-transform.test.ts b/packages/app-core/src/lib/callout-transform.test.ts new file mode 100644 index 000000000..286215101 --- /dev/null +++ b/packages/app-core/src/lib/callout-transform.test.ts @@ -0,0 +1,412 @@ +import { describe, expect, it } from 'vitest' +import { CALLOUT_TYPES } from './callout-types' +import { + applyCalloutChanges, + calloutMenuEntries, + calloutTargetAt, + calloutTypeChoices, + changeCalloutType, + mapCalloutPos, + removeCallout, + turnIntoCallout, + type CalloutBlock, + type CalloutTarget +} from './callout-transform' + +/** Offset of `needle` in `doc` (plus `shift`), failing loudly when absent. */ +function at(doc: string, needle: string, shift = 0): number { + const i = doc.indexOf(needle) + if (i < 0) throw new Error(`"${needle}" not in doc`) + return i + shift +} + +function target(doc: string, from: number, to = from): CalloutTarget { + return calloutTargetAt(doc, { from, to }) +} + +function calloutAt(doc: string, pos: number): CalloutBlock { + const t = target(doc, pos) + if (t.kind !== 'callout') throw new Error(`expected a callout, got ${t.kind}`) + return t.callout +} + +/** Turn whatever `from..to` targets into a callout of `type`. */ +function turnInto(doc: string, from: number, to = from, type = 'warning') { + const t = target(doc, from, to) + if (t.kind !== 'block') throw new Error(`expected a block, got ${JSON.stringify(t)}`) + const edit = turnIntoCallout(doc, t, type, { anchor: from, head: to }) + return { text: applyCalloutChanges(doc, edit.changes), selection: edit.selection } +} + +function change(doc: string, pos: number, type: string) { + const edit = changeCalloutType(calloutAt(doc, pos), type, { anchor: pos, head: pos }) + return edit && { text: applyCalloutChanges(doc, edit.changes), selection: edit.selection } +} + +function remove(doc: string, pos: number) { + const edit = removeCallout(doc, calloutAt(doc, pos), { anchor: pos, head: pos }) + return { text: applyCalloutChanges(doc, edit.changes), selection: edit.selection } +} + +describe('turn into a callout (#1016)', () => { + it('takes the paragraph at the caret, its lines and no further', () => { + const doc = 'Intro\n\nFirst line\nsecond line\n\nAfter' + const { text, selection } = turnInto(doc, at(doc, 'second', 3)) + expect(text).toBe('Intro\n\n> [!warning]\n> First line\n> second line\n\nAfter') + // The caret stays on the same letter. + expect(text.slice(selection.head, selection.head + 4)).toBe('ond ') + }) + + it('covers the whole lines a selection touches', () => { + const doc = 'alpha one\nbeta two\ngamma three' + const { text, selection } = turnInto(doc, at(doc, 'one'), at(doc, 'two')) + expect(text).toBe('> [!warning]\n> alpha one\n> beta two\n\ngamma three') + expect(text.slice(selection.anchor, selection.head)).toBe('one\n> beta ') + }) + + it('leaves out the line a selection ends at the very start of', () => { + const doc = 'one\ntwo\nthree' + const { text } = turnInto(doc, 0, at(doc, 'three')) + expect(text).toBe('> [!warning]\n> one\n> two\n\nthree') + }) + + it('keeps blank lines inside a selection in the callout as `>`', () => { + const doc = 'one\n\ntwo\n' + const { text } = turnInto(doc, 0, at(doc, 'two', 3)) + expect(text).toBe('> [!warning]\n> one\n>\n> two\n') + }) + + it('drops blank lines at the edges of a selection', () => { + const doc = 'top\n\none\n\nbottom' + const { text } = turnInto(doc, at(doc, '\none') , at(doc, 'one', 4)) + expect(text).toBe('top\n\n> [!warning]\n> one\n\nbottom') + }) + + it('puts the title line above a plain blockquote instead of quoting it twice', () => { + const doc = 'Text\n\n> quoted one\n>\n> quoted two\n\nMore' + const { text, selection } = turnInto(doc, at(doc, 'two'), at(doc, 'two'), 'tip') + expect(text).toBe('Text\n\n> [!tip]\n> quoted one\n>\n> quoted two\n\nMore') + expect(text.slice(selection.head, selection.head + 3)).toBe('two') + }) + + it('grows a selection inside a blockquote to the whole quote', () => { + const doc = '> first\n> second\n> third' + const { text } = turnInto(doc, at(doc, 'second'), at(doc, 'second', 3), 'note') + expect(text).toBe('> [!note]\n> first\n> second\n> third') + }) + + it('wraps a whole list from a caret in one of its items', () => { + const doc = 'Shopping:\n\n- apples\n- pears\n - green\n\nDone' + const { text } = turnInto(doc, at(doc, 'pears')) + expect(text).toBe('Shopping:\n\n> [!warning]\n> - apples\n> - pears\n> - green\n\nDone') + }) + + it('takes the whole list from a hard-wrapped item or a nested one', () => { + const doc = '- first item\n wraps here\n- second\n - nested' + const whole = '> [!warning]\n> - first item\n> wraps here\n> - second\n> - nested' + expect(turnInto(doc, at(doc, 'wraps')).text).toBe(whole) + expect(turnInto(doc, at(doc, 'nested')).text).toBe(whole) + }) + + it('keeps a callout made from a list item paragraph inside that item', () => { + const doc = '- item\n\n More about the item\n on two lines\n- next' + const { text } = turnInto(doc, at(doc, 'More')) + expect(text).toBe('- item\n\n > [!warning]\n > More about the item\n > on two lines\n- next') + }) + + it('stops a paragraph at a heading, and takes a heading on its own', () => { + const doc = '# Title\nBody line\nmore body' + expect(turnInto(doc, at(doc, 'Body')).text).toBe('# Title\n> [!warning]\n> Body line\n> more body') + expect(turnInto(doc, at(doc, 'Title')).text).toBe('> [!warning]\n> # Title\nBody line\nmore body') + }) + + it('stops a paragraph at the edge of a quote, and keeps the quote out of the callout', () => { + const doc = 'Plain text\n> a quote right after' + expect(turnInto(doc, at(doc, 'Plain')).text).toBe('> [!warning]\n> Plain text\n\n> a quote right after') + }) + + it('splits a callout made from the middle of a paragraph off the lines below it', () => { + const doc = 'one\ntwo\nthree' + const { text, selection } = turnInto(doc, at(doc, 'two'), at(doc, 'two', 3)) + expect(text).toBe('one\n> [!warning]\n> two\n\nthree') + expect(text.slice(selection.anchor, selection.head)).toBe('two') + }) + + it('puts a blank line between a quote and a callout made from the text under it', () => { + const doc = '> quote\nlazy line' + expect(turnInto(doc, at(doc, 'lazy')).text).toBe('> quote\n\n> [!warning]\n> lazy line') + }) + + it('needs no blank line under a callout that ends in a fence or a heading', () => { + const doc = '# Head\nnext' + expect(turnInto(doc, 1).text).toBe('> [!warning]\n> # Head\nnext') + }) + + it('works on the last line of a document with no newline after it', () => { + const doc = 'one\n\nlast line' + const { text, selection } = turnInto(doc, doc.length) + expect(text).toBe('one\n\n> [!warning]\n> last line') + expect(selection.head).toBe(text.length) + }) + + it('works on a one-line document', () => { + expect(turnInto('solo', 2).text).toBe('> [!warning]\n> solo') + }) + + it('keeps CRLF line endings, the new title line included', () => { + const doc = 'a\r\n\r\nfirst\r\nsecond\r\n\r\nz' + const { text } = turnInto(doc, at(doc, 'first')) + expect(text).toBe('a\r\n\r\n> [!warning]\r\n> first\r\n> second\r\n\r\nz') + }) + + it('gives a CRLF document a CRLF title line from its last line', () => { + const doc = 'a\r\n\r\nend' + expect(turnInto(doc, doc.length).text).toBe('a\r\n\r\n> [!warning]\r\n> end') + }) + + it('grows a selection that reaches into part of a callout to all of it, nested', () => { + const doc = 'Lead in\n> [!note] Title\n> body\n\nafter' + const { text } = turnInto(doc, 0, at(doc, 'Title')) + expect(text).toBe('> [!warning]\n> Lead in\n> > [!note] Title\n> > body\n\nafter') + }) + + it('wraps two callouts and what lies between when a selection spans them', () => { + const doc = '> [!note]\n> one\n\n> [!tip]\n> two' + const { text } = turnInto(doc, at(doc, 'one'), at(doc, 'two')) + expect(text).toBe('> [!warning]\n> > [!note]\n> > one\n>\n> > [!tip]\n> > two') + }) +}) + +describe('frontmatter and code fences', () => { + const withFrontmatter = '---\ntags: [a]\n---\nFirst paragraph\nstill first\n\nSecond' + + it('refuses a caret in the frontmatter', () => { + expect(target(withFrontmatter, at(withFrontmatter, 'tags'))).toEqual({ + kind: 'refused', + reason: 'frontmatter' + }) + }) + + it('never pulls the closing fence into the paragraph right after it', () => { + const { text } = turnInto(withFrontmatter, at(withFrontmatter, 'still')) + expect(text).toBe('---\ntags: [a]\n---\n> [!warning]\n> First paragraph\n> still first\n\nSecond') + }) + + it('leaves the frontmatter out of a selection that starts in it', () => { + const { text } = turnInto(withFrontmatter, at(withFrontmatter, 'tags'), at(withFrontmatter, 'still', 3)) + expect(text).toBe('---\ntags: [a]\n---\n> [!warning]\n> First paragraph\n> still first\n\nSecond') + }) + + const fenced = 'Before\n```js\nconst a = 1\n\n> [!note] not a callout\n```\nAfter' + + it('refuses a caret inside a code fence, its fence lines included', () => { + expect(target(fenced, at(fenced, 'const'))).toEqual({ kind: 'refused', reason: 'code' }) + expect(target(fenced, at(fenced, '```js'))).toEqual({ kind: 'refused', reason: 'code' }) + expect(target(fenced, at(fenced, 'not a callout'))).toEqual({ kind: 'refused', reason: 'code' }) + }) + + it('stops a paragraph at a fence that follows it with no blank line', () => { + expect(turnInto(fenced, 0).text).toBe('> [!warning]\n> Before\n```js\nconst a = 1\n\n> [!note] not a callout\n```\nAfter') + }) + + it('wraps a whole fence that is selected, and grows a selection that reaches into one', () => { + const whole = turnInto(fenced, at(fenced, '```js'), at(fenced, '```\nAfter', 3)).text + const partial = turnInto(fenced, 0, at(fenced, 'const')).text + expect(whole).toBe('Before\n> [!warning]\n> ```js\n> const a = 1\n>\n> > [!note] not a callout\n> ```\nAfter') + expect(partial).toBe('> [!warning]\n> Before\n> ```js\n> const a = 1\n>\n> > [!note] not a callout\n> ```\nAfter') + }) + + it('reads a `[!note]` line in a fence inside a callout as code, not a nested callout', () => { + const doc = '> [!tip]\n> ```md\n> > [!note] example\n> ```' + expect(calloutAt(doc, at(doc, 'example')).type).toBe('tip') + }) + + it('refuses a blank line, which has no paragraph', () => { + expect(target('a\n\nb', 2)).toEqual({ kind: 'refused', reason: 'blank' }) + }) +}) + +describe('the callout a caret or selection is in', () => { + const nested = '> [!note] Outer\n> outer body\n> > [!tip] Inner\n> > inner body\n> outer again' + + it('finds the callout from its title line and from its body', () => { + const doc = 'x\n\n> [!warning]- Heads up\n> body\n\ny' + for (const pos of [at(doc, 'Heads'), at(doc, 'body')]) { + const c = calloutAt(doc, pos) + expect(c).toMatchObject({ type: 'warning', marker: '-', title: 'Heads up', depth: 1 }) + } + }) + + it('takes the innermost callout', () => { + expect(calloutAt(nested, at(nested, 'inner body')).title).toBe('Inner') + expect(calloutAt(nested, at(nested, 'outer again')).title).toBe('Outer') + }) + + it('takes the outer callout for a selection that leaves the inner one', () => { + const t = target(nested, at(nested, 'outer body'), at(nested, 'inner body')) + expect(t.kind === 'callout' && t.callout.title).toBe('Outer') + }) + + it('does not take a `[!type]` that continues a quote as a callout', () => { + const doc = '> plain quote\n> [!note] just text' + const t = target(doc, at(doc, 'just')) + expect(t.kind).toBe('block') + }) + + it('takes a callout right after the frontmatter', () => { + const doc = '---\na: 1\n---\n> [!info]\n> hi' + expect(calloutAt(doc, at(doc, 'hi')).type).toBe('info') + }) +}) + +describe('change the callout type', () => { + it('rewrites only the keyword, keeping the fold marker and the title', () => { + const doc = 'a\n\n> [!warning]- Mind the gap\n> body' + const result = change(doc, at(doc, 'body'), 'tip') + expect(result?.text).toBe('a\n\n> [!tip]- Mind the gap\n> body') + expect(result && result.text.slice(result.selection.head, result.selection.head + 4)).toBe('body') + }) + + it('keeps a caret on the title line on the same letter', () => { + const doc = '> [!note] Title' + const result = change(doc, at(doc, 'itle'), 'question') + expect(result?.text).toBe('> [!question] Title') + expect(result && result.text.slice(result.selection.head)).toBe('itle') + }) + + it('does nothing when the callout already is that type, an alias or any case included', () => { + expect(change('> [!error] Boom', 2, 'danger')).toBeNull() + expect(change('> [!WARNING]\n> x', 2, 'warning')).toBeNull() + expect(change('> [!error] Boom', 2, 'failure')?.text).toBe('> [!failure] Boom') + }) + + it('changes an unknown type to a known one', () => { + expect(change('> [!custom]\n> x', 2, 'note')?.text).toBe('> [!note]\n> x') + }) + + it('changes a nested callout and leaves the outer one alone', () => { + const doc = '> [!note]\n> > [!tip] Inner\n> > text' + expect(change(doc, at(doc, 'text'), 'bug')?.text).toBe('> [!note]\n> > [!bug] Inner\n> > text') + }) +}) + +describe('remove the callout', () => { + it('keeps a custom title as a plain first line and strips one level of `> `', () => { + const doc = 'a\n\n> [!warning]+ Heads up\n> first\n>\n> second\n\nb' + expect(remove(doc, at(doc, 'first')).text).toBe('a\n\nHeads up\nfirst\n\nsecond\n\nb') + }) + + it('drops a title line with no title, the caret on it landing on the body', () => { + const doc = 'a\n\n> [!note]\n> body\n\nb' + const { text, selection } = remove(doc, at(doc, '[!note]')) + expect(text).toBe('a\n\nbody\n\nb') + expect(text.slice(selection.head, selection.head + 4)).toBe('body') + }) + + it('keeps the caret on the same letter of the body', () => { + const doc = '> [!tip] T\n> some body' + const { text, selection } = remove(doc, at(doc, 'body')) + expect(text).toBe('T\nsome body') + expect(text.slice(selection.head)).toBe('body') + }) + + it('removes a nested callout and leaves the outer quote marker', () => { + const doc = '> [!note] Outer\n> > [!tip] Inner\n> > text\n> outer' + expect(remove(doc, at(doc, 'text')).text).toBe('> [!note] Outer\n> Inner\n> text\n> outer') + }) + + it('removes the outer callout and keeps the nested one whole', () => { + const doc = '> [!note] Outer\n> > [!tip] Inner\n> > text\n> outer' + expect(remove(doc, at(doc, 'outer')).text).toBe('Outer\n> [!tip] Inner\n> text\nouter') + }) + + it('strips the quote marker from a fence inside the callout, not the code', () => { + const doc = '> [!example]\n> ```\n> > quoted in code\n> ```' + expect(remove(doc, at(doc, 'quoted')).text).toBe('```\n> quoted in code\n```') + }) + + it('keeps CRLF line endings', () => { + const doc = 'a\r\n\r\n> [!note] T\r\n> body\r\n' + expect(remove(doc, at(doc, 'body')).text).toBe('a\r\n\r\nT\r\nbody\r\n') + }) + + it('removes a title-only callout on the last line of the document', () => { + const doc = 'text\n\n> [!note]' + const { text, selection } = remove(doc, doc.length) + expect(text).toBe('text\n') + expect(selection.head).toBe(text.length) + }) + + it('keeps the indentation of a callout inside a list item', () => { + const doc = '- item\n\n > [!info] Note\n > body' + expect(remove(doc, at(doc, 'body')).text).toBe('- item\n\n Note\n body') + }) + + it('round-trips a turned-into paragraph back to the same text', () => { + const doc = 'one\n\nalpha\nbeta\n\ntwo' + const wrapped = turnInto(doc, at(doc, 'alpha')).text + expect(remove(wrapped, at(wrapped, 'beta')).text).toBe(doc) + }) +}) + +describe('the callout menus', () => { + it('lists all twelve types in CALLOUT_TYPES order, Danger named for Error', () => { + const choices = calloutTypeChoices(null) + expect(choices.map((c) => c.type)).toEqual(CALLOUT_TYPES.map((d) => d.type)) + expect(choices).toHaveLength(12) + expect(choices.find((c) => c.type === 'danger')?.label).toBe('Danger (Error)') + expect(choices.find((c) => c.type === 'warning')?.icon).toBe('⚠️') + expect(choices.some((c) => c.checked)).toBe(false) + }) + + it('finds Error under Danger, as the aliases say', () => { + const matches = calloutTypeChoices(null).filter( + (c) => c.label.toLowerCase().includes('error') || c.keywords.includes('error') + ) + expect(matches.map((c) => c.type)).toEqual(['danger']) + }) + + it('checks the current type, an alias resolved to its entry', () => { + expect(calloutTypeChoices('tip').filter((c) => c.checked).map((c) => c.type)).toEqual(['tip']) + expect(calloutTypeChoices('Error').filter((c) => c.checked).map((c) => c.type)).toEqual(['danger']) + expect(calloutTypeChoices('custom').some((c) => c.checked)).toBe(false) + }) + + it('offers Turn into outside a callout, Change and Remove inside one', () => { + const doc = 'Para\n\n> [!note]\n> body\n\n```\ncode\n```\n\n' + expect(calloutMenuEntries(target(doc, 0))).toEqual([ + { action: 'turn-into', label: 'Turn into callout…' } + ]) + expect(calloutMenuEntries(target(doc, at(doc, 'body'))).map((e) => e.label)).toEqual([ + 'Change callout type…', + 'Remove callout' + ]) + }) + + it('shows Turn into disabled with the reason in code and in the frontmatter, and nothing on a blank line', () => { + const doc = '---\na: 1\n---\n\n```\ncode\n```\n' + expect(calloutMenuEntries(target(doc, at(doc, 'code')))).toEqual([ + { action: 'turn-into', label: 'Turn into callout…', disabled: true, reason: 'in a code block' } + ]) + expect(calloutMenuEntries(target(doc, at(doc, 'a: 1')))).toEqual([ + { action: 'turn-into', label: 'Turn into callout…', disabled: true, reason: 'in properties' } + ]) + expect(calloutMenuEntries(target(doc, at(doc, '\n\n```', 1)))).toEqual([]) + }) +}) + +describe('mapCalloutPos', () => { + it('puts a position after text inserted right at it, or before with assoc -1', () => { + const changes = [{ from: 2, to: 2, insert: 'xx' }] + expect(mapCalloutPos(changes, 2)).toBe(4) + expect(mapCalloutPos(changes, 2, -1)).toBe(2) + expect(mapCalloutPos(changes, 1)).toBe(1) + expect(mapCalloutPos(changes, 5)).toBe(7) + }) + + it('moves a position inside deleted text to where the deletion was', () => { + const changes = [{ from: 2, to: 6, insert: '' }] + expect(mapCalloutPos(changes, 4)).toBe(2) + expect(mapCalloutPos(changes, 8)).toBe(4) + }) +}) diff --git a/packages/app-core/src/lib/callout-transform.ts b/packages/app-core/src/lib/callout-transform.ts new file mode 100644 index 000000000..57b06bb13 --- /dev/null +++ b/packages/app-core/src/lib/callout-transform.ts @@ -0,0 +1,589 @@ +/** + * Turn a block into a callout, change a callout's type, or remove one (#1016), + * as pure text edits on a note body. The editor menu, the palette commands and + * the leader key all go through here, so each action is one change with one + * rule for what it touches. + * + * What a right-click or the caret targets: + * - Inside a callout, the innermost callout around the whole selection. + * - Otherwise a non-empty selection covers its whole lines (a selection that + * ends at the start of a line leaves that line out), and an empty one takes + * the paragraph at the caret: the non-blank lines around it, stopping at a + * heading, a code fence, and the edge of a quote. A caret in a quote takes + * the whole quote. + * - A block is never cut in half: a target that reaches into a code fence or + * a blockquote (callouts included) grows to hold all of it, so the wrapped + * block keeps its own structure inside the new callout. + * - Nothing inside a code fence or the leading frontmatter: there the lines + * are code and YAML, and a `> ` in front of them would change what they say. + * + * Every line reader here splits at `\n` and keeps a `\r` as part of the line + * ending, so a CRLF body comes back CRLF, new lines included. + */ +import { FENCE_CLOSE_RE, FENCE_OPEN_RE, frontmatterEndIndex } from '@shared/markdown-lines' +import { CALLOUT_TYPES, type CalloutTypeDef } from './callout-types' + +export interface CalloutChange { + from: number + to: number + insert: string +} + +export interface CalloutSelection { + anchor: number + head: number +} + +/** One edit: changes in original-document positions, sorted and disjoint (the + * shape CodeMirror's `dispatch({ changes })` takes), and the selection after + * it, in new-document positions. */ +export interface CalloutEdit { + changes: CalloutChange[] + selection: CalloutSelection +} + +export interface CalloutBlock { + /** 0-based line of the `> [!type]` title. */ + head: number + /** 0-based last line of the callout, inclusive. */ + end: number + /** Quote depth of the callout: 1 for `> [!note]`, 2 for `> > [!note]`. */ + depth: number + /** The type keyword as written, case kept. */ + type: string + marker: '-' | '+' | null + title: string + /** Offsets of the type keyword between `[!` and `]`. */ + typeFrom: number + typeTo: number + /** Offset where the title starts (the line end when there is none). */ + titleFrom: number +} + +export type CalloutRefusal = 'code' | 'frontmatter' | 'blank' + +export type CalloutTarget = + | { kind: 'callout'; callout: CalloutBlock } + | { kind: 'block'; from: number; to: number } + | { kind: 'refused'; reason: CalloutRefusal } + +interface Line { + from: number + /** End of the text, before the line ending. */ + to: number + text: string + /** `\n`, `\r\n`, or '' on the last line. */ + eol: string + /** Quote depth the Markdown structure gives this line. Inside a fence, the + * fence's own depth: a `>` in the code is code. */ + depth: number + /** Offsets of the structural `>` markers, outermost first. */ + markers: number[] + /** Offset of the text after the structural markers and one space. */ + contentFrom: number + frontmatter: boolean + /** Index into `fences` when the line opens, sits in, or closes a fence. */ + fence: number +} + +interface Fence { + start: number + end: number +} + +interface Model { + lines: Line[] + fences: Fence[] + callouts: CalloutBlock[] + /** The ending a new line gets. */ + eol: string +} + +/** `[!type]`, an optional fold marker, then the title. Matches what Live + * Preview draws as a callout (`CALLOUT_HEAD_RE` in cm-callout-fold.ts), which + * also takes a title right after the bracket. */ +const HEAD_RE = /^\[!(\w+)\]([-+])?[ \t]?(.*)$/ + +const ATX_HEADING_RE = /^ {0,3}#{1,6}(?:[ \t]|$)/ + +const THEMATIC_BREAK_RE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/ + +/** A list item's marker and the space after it: its length is the column the + * item's text, and its continuation lines, start at. */ +const LIST_ITEM_RE = /^[ \t]*(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)/ + +/** Columns `text` takes, a tab counting four. */ +function columnWidth(text: string): number { + let width = 0 + for (const ch of text) width += ch === '\t' ? 4 : 1 + return width +} + +/** Columns of leading whitespace. */ +function indentWidth(text: string): number { + return columnWidth(text.match(/^[ \t]*/)?.[0] ?? '') +} + +/** The structural `>` markers at the start of `text`: leading whitespace, then + * each `>` with the one space after it, allowing spaces between markers. */ +function readMarkers(text: string): { markers: number[]; contentAt: number[] } { + const markers: number[] = [] + // contentAt[k] is where the content starts when only k markers count. + const contentAt: number[] = [] + let i = 0 + while (i < text.length && (text[i] === ' ' || text[i] === '\t')) i++ + contentAt.push(0) + for (;;) { + let j = i + if (markers.length > 0) { + while (j < text.length && text[j] === ' ') j++ + } + if (text[j] !== '>') break + markers.push(j) + j++ + if (text[j] === ' ' || text[j] === '\t') j++ + contentAt.push(j) + i = j + } + return { markers, contentAt } +} + +function parse(doc: string): Model { + const raw = doc.split('\n') + const lines: Line[] = [] + const fences: Fence[] = [] + const frontmatterEnd = frontmatterEndIndex(raw.map((r) => r.replace(/\r$/, ''))) + let open: { marker: string; depth: number; index: number } | null = null + let offset = 0 + + for (let i = 0; i < raw.length; i++) { + const last = i === raw.length - 1 + let text = raw[i] + let eol = last ? '' : '\n' + if (text.endsWith('\r')) { + text = text.slice(0, -1) + eol = '\r' + eol + } + const from = offset + offset += raw[i].length + (last ? 0 : 1) + const line: Line = { + from, + to: from + text.length, + text, + eol, + depth: 0, + markers: [], + contentFrom: from, + frontmatter: i <= frontmatterEnd, + fence: -1 + } + lines.push(line) + if (line.frontmatter) continue + + const { markers, contentAt } = readMarkers(text) + if (open && markers.length < open.depth) { + // The quote holding the fence ended, and the fence with it. + fences[open.index].end = i - 1 + open = null + } + const depth: number = open ? open.depth : markers.length + line.depth = depth + line.markers = markers.slice(0, depth).map((m) => from + m) + line.contentFrom = from + contentAt[depth] + const content = text.slice(contentAt[depth]) + + if (open) { + line.fence = open.index + const close = content.match(FENCE_CLOSE_RE) + if (close && close[1][0] === open.marker[0] && close[1].length >= open.marker.length) { + fences[open.index].end = i + open = null + } + continue + } + const opener = content.match(FENCE_OPEN_RE) + // A backtick fence's info string may not hold a backtick: such a line is + // an inline code span, the same rule as scanMarkdownLines. + if (opener && (opener[1][0] !== '`' || !opener[2].includes('`'))) { + open = { marker: opener[1], depth, index: fences.length } + fences.push({ start: i, end: raw.length - 1 }) + line.fence = open.index + } + } + + const callouts: CalloutBlock[] = [] + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (line.frontmatter || line.fence >= 0 || line.depth === 0) continue + // A title opens its quote: on a line that continues a quote at the same + // depth, `[!type]` is just text in that quote. + if (i > 0 && !lines[i - 1].frontmatter && lines[i - 1].depth >= line.depth) continue + const content = line.text.slice(line.contentFrom - line.from) + const m = content.match(HEAD_RE) + if (!m) continue + let end = i + while (end + 1 < lines.length && lines[end + 1].depth >= line.depth) end++ + const typeFrom = line.contentFrom + 2 + const rest = m[3] + const title = rest.trim() + callouts.push({ + head: i, + end, + depth: line.depth, + type: m[1], + marker: (m[2] as '-' | '+' | undefined) ?? null, + title, + typeFrom, + typeTo: typeFrom + m[1].length, + titleFrom: title ? line.to - rest.trimStart().length : line.to + }) + } + + return { lines, fences, callouts, eol: doc.includes('\r\n') ? '\r\n' : '\n' } +} + +function lineIndexAt(lines: Line[], pos: number): number { + let lo = 0 + let hi = lines.length - 1 + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (lines[mid].from <= pos) lo = mid + else hi = mid - 1 + } + return lo +} + +function isBlank(line: Line): boolean { + return line.text.trim() === '' +} + +/** The callout, block or refusal a selection from `from` to `to` targets. */ +export function calloutTargetAt(doc: string, range: { from: number; to: number }): CalloutTarget { + return targetIn(parse(doc), range) +} + +function targetIn(model: Model, range: { from: number; to: number }): CalloutTarget { + const { lines, fences, callouts } = model + const from = Math.max(0, Math.min(range.from, range.to)) + const to = Math.max(range.from, range.to) + let a = lineIndexAt(lines, from) + let b = lineIndexAt(lines, to) + if (to > from && b > a && to === lines[b].from) b-- + + let inner: CalloutBlock | null = null + for (const c of callouts) { + if (c.head <= a && c.end >= b && (!inner || c.depth > inner.depth)) inner = c + } + if (inner) return { kind: 'callout', callout: inner } + + while (a <= b && lines[a].frontmatter) a++ + if (a > b) return { kind: 'refused', reason: 'frontmatter' } + + const fa = lines[a].fence + if (fa >= 0 && lines[b].fence === fa && !(a === fences[fa].start && b === fences[fa].end)) { + return { kind: 'refused', reason: 'code' } + } + + if (from === to) { + if (isBlank(lines[a])) return { kind: 'refused', reason: 'blank' } + const quoted = lines[a].depth > 0 + const joins = (n: number): boolean => { + const l = lines[n] + if (l.frontmatter || isBlank(l)) return false + if (quoted) return l.depth > 0 + return l.depth === 0 && l.fence < 0 && !ATX_HEADING_RE.test(l.text) + } + // A heading is a paragraph of its own. Outside a quote, a line indented + // less than the paragraph is another block (the next item of the list + // whose item the paragraph sits in), except the item a continuation line + // belongs to: from there on the paragraph is that item's whole list. + if (quoted || !ATX_HEADING_RE.test(lines[a].text)) { + let base = indentWidth(lines[a].text) + for (;;) { + if (a === 0 || !joins(a - 1)) break + const up = lines[a - 1].text + const width = indentWidth(up) + if (!quoted && width < base) { + const item = up.match(LIST_ITEM_RE) + if (!item || columnWidth(item[0]) > indentWidth(lines[a].text)) break + base = width + } + a-- + } + while ( + b + 1 < lines.length && + joins(b + 1) && + (quoted || indentWidth(lines[b + 1].text) >= base) + ) { + b++ + } + } + } else { + while (a < b && isBlank(lines[a])) a++ + while (b > a && isBlank(lines[b])) b-- + if (isBlank(lines[a])) return { kind: 'refused', reason: 'blank' } + } + + // Grow to whole fences and whole quotes until nothing more is cut. + for (let changed = true; changed; ) { + changed = false + const startFence = lines[a].fence + if (startFence >= 0 && fences[startFence].start < a) { + a = fences[startFence].start + changed = true + } + const endFence = lines[b].fence + if (endFence >= 0 && fences[endFence].end > b) { + b = fences[endFence].end + changed = true + } + while (lines[a].depth > 0 && a > 0 && lines[a - 1].depth > 0 && !lines[a - 1].frontmatter) { + a-- + changed = true + } + while (lines[b].depth > 0 && b + 1 < lines.length && lines[b + 1].depth > 0) { + b++ + changed = true + } + } + return { kind: 'block', from: lines[a].from, to: lines[b].to } +} + +/** Where `pos` lands after `changes`. `assoc` > 0 puts it after text inserted + * exactly there; a position inside deleted text lands where the deletion was. */ +export function mapCalloutPos(changes: CalloutChange[], pos: number, assoc: -1 | 1 = 1): number { + let delta = 0 + for (const c of changes) { + if (c.to < pos || (c.to === pos && (c.from < pos || assoc > 0))) { + delta += c.insert.length - (c.to - c.from) + continue + } + if (c.from < pos && pos < c.to) { + return c.from + delta + (assoc > 0 ? c.insert.length : 0) + } + break + } + return pos + delta +} + +/** Apply an edit's changes to a string (tests, and callers without an editor). */ +export function applyCalloutChanges(doc: string, changes: CalloutChange[]): string { + let out = doc + for (let i = changes.length - 1; i >= 0; i--) { + const c = changes[i] + out = out.slice(0, c.from) + c.insert + out.slice(c.to) + } + return out +} + +function edit(changes: CalloutChange[], selection: CalloutSelection): CalloutEdit { + return { + changes, + selection: { + anchor: mapCalloutPos(changes, selection.anchor), + head: mapCalloutPos(changes, selection.head) + } + } +} + +function commonIndent(lines: Line[]): string { + let indent: string | null = null + for (const line of lines) { + if (isBlank(line)) continue + const lead = line.text.match(/^[ \t]*/)?.[0] ?? '' + if (indent === null) { + indent = lead + continue + } + let k = 0 + while (k < indent.length && k < lead.length && indent[k] === lead[k]) k++ + indent = indent.slice(0, k) + } + return indent ?? '' +} + +/** + * Wrap the lines from `target.from` to `target.to` (a `block` target) in a + * `> [!type]` callout. Each line gets `> ` (a blank one `>`) after the + * indentation the lines share, so a callout made inside a list item stays in + * it. A target that is one plain blockquote already has its `>`: it only gets + * the title line above it. + * + * Markdown reads a quote line right under text as more of that text's quote, + * and text right under a quote as more of the quote. So a callout made from + * part of a paragraph, or from a paragraph that touches a quote, gets a blank + * line where it would otherwise run into its neighbor: without one, the title + * under a quote is no callout at all, and the line below joins the callout. + */ +export function turnIntoCallout( + doc: string, + target: { from: number; to: number }, + type: string, + selection: CalloutSelection +): CalloutEdit { + const { lines, eol: docEol } = parse(doc) + const a = lineIndexAt(lines, target.from) + const b = lineIndexAt(lines, target.to) + const range = lines.slice(a, b + 1) + const indent = commonIndent(range) + const eol = range[0].eol || docEol + const header = `> [!${type}]` + const changes: CalloutChange[] = [] + if (range.every((line) => line.depth > 0)) { + const at = range[0].from + indent.length + changes.push({ from: at, to: at, insert: header + eol + indent }) + return edit(changes, selection) + } + const above = a > 0 ? lines[a - 1] : null + const below = b + 1 < lines.length ? lines[b + 1] : null + const blankAbove = !!above && !above.frontmatter && !isBlank(above) && above.depth > 0 + // A quote line joins whatever quote is above it. Plain text joins only a + // paragraph, and only when it does not start a block of its own. + const last = range[range.length - 1] + const lastIsText = last.fence < 0 && !ATX_HEADING_RE.test(last.text) + const blankBelow = + !!below && + !isBlank(below) && + (below.depth > 0 || + (lastIsText && + below.fence < 0 && + !ATX_HEADING_RE.test(below.text) && + !LIST_ITEM_RE.test(below.text) && + !THEMATIC_BREAK_RE.test(below.text))) + range.forEach((line, k) => { + if (isBlank(line)) { + changes.push({ from: line.from, to: line.to, insert: indent + '>' }) + return + } + const at = line.from + indent.length + const lead = k === 0 ? header + eol + indent : '' + if (k === 0 && blankAbove) { + if (indent) changes.push({ from: line.from, to: line.from, insert: eol }) + changes.push({ from: at, to: at, insert: (indent ? '' : eol) + lead + '> ' }) + return + } + changes.push({ from: at, to: at, insert: lead + '> ' }) + }) + // At the start of the next line, so a caret at the end of the last wrapped + // line stays there instead of following the new line break. + if (below && blankBelow) changes.push({ from: below.from, to: below.from, insert: eol }) + return edit(changes, selection) +} + +/** The CALLOUT_TYPES entry a keyword names, by type or alias, any case. */ +export function calloutTypeDef(keyword: string): CalloutTypeDef | null { + const k = keyword.toLowerCase() + return CALLOUT_TYPES.find((def) => def.type === k || def.aliases.includes(k)) ?? null +} + +/** + * Rewrite only the `[!type]` keyword, keeping the fold marker and the title. + * Null when the callout already is that type (an alias counts: `[!error]` is + * already a danger callout, and stays written as `error`). + */ +export function changeCalloutType( + callout: CalloutBlock, + type: string, + selection: CalloutSelection +): CalloutEdit | null { + const current = calloutTypeDef(callout.type) + if (current ? current.type === type : callout.type.toLowerCase() === type) return null + return edit([{ from: callout.typeFrom, to: callout.typeTo, insert: type }], selection) +} + +/** + * Take a callout back to plain text: its title line goes (a custom title stays + * as a plain first line) and every line loses one level of `> `, the + * callout's own, so a quote or callout nested inside it keeps its markers. + */ +export function removeCallout( + doc: string, + callout: CalloutBlock, + selection: CalloutSelection +): CalloutEdit { + const { lines } = parse(doc) + const level = callout.depth - 1 + const changes: CalloutChange[] = [] + const head = lines[callout.head] + if (callout.title) { + changes.push({ from: head.markers[level], to: callout.titleFrom, insert: '' }) + } else if (callout.head + 1 < lines.length) { + changes.push({ from: head.from, to: lines[callout.head + 1].from, insert: '' }) + } else if (callout.head > 0) { + changes.push({ from: lines[callout.head - 1].to, to: head.to, insert: '' }) + } else { + changes.push({ from: head.from, to: head.to, insert: '' }) + } + for (let n = callout.head + 1; n <= callout.end; n++) { + const line = lines[n] + const marker = line.markers[level] + let to = marker + 1 + const next = line.text[to - line.from] + if (next === ' ' || next === '\t') to++ + changes.push({ from: marker, to, insert: '' }) + } + return edit(changes, selection) +} + +/** One row of the callout type menu. */ +export interface CalloutTypeChoice { + type: string + label: string + icon: string + /** The callout's current type, an alias resolved to its entry. */ + checked: boolean + /** The aliases, so the menu's filter finds `caution` or `tldr`. */ + keywords: string[] +} + +/** Danger is the type an Error callout is: `error` is its alias and renders + * red with it. The issue asked for Error by name, so the menu says so. */ +const PICKER_LABELS: Record = { danger: 'Danger (Error)' } + +/** Every callout type in CALLOUT_TYPES order, the current one checked. */ +export function calloutTypeChoices(current: string | null): CalloutTypeChoice[] { + const currentDef = current ? calloutTypeDef(current) : null + return CALLOUT_TYPES.map((def) => ({ + type: def.type, + label: PICKER_LABELS[def.type] ?? def.label, + icon: def.icon, + checked: currentDef?.type === def.type, + keywords: def.aliases + })) +} + +/** What the editor menu offers for a target: the actions in order, or a + * disabled Turn into with the reason it cannot apply. */ +export interface CalloutMenuEntry { + action: 'turn-into' | 'change-type' | 'remove' + label: string + disabled?: boolean + reason?: string +} + +export const CALLOUT_REFUSAL_REASON: Record = { + code: 'in a code block', + frontmatter: 'in properties', + blank: 'on a blank line' +} + +export function calloutMenuEntries(target: CalloutTarget): CalloutMenuEntry[] { + if (target.kind === 'callout') { + return [ + { action: 'change-type', label: 'Change callout type…' }, + { action: 'remove', label: 'Remove callout' } + ] + } + if (target.kind === 'block') return [{ action: 'turn-into', label: 'Turn into callout…' }] + // A blank line has nothing to turn into anything, and it is where a paste + // usually goes: no row at all beats a dead one there. + if (target.reason === 'blank') return [] + return [ + { + action: 'turn-into', + label: 'Turn into callout…', + disabled: true, + reason: CALLOUT_REFUSAL_REASON[target.reason] + } + ] +} diff --git a/packages/app-core/src/lib/cm-code-block-flair.ts b/packages/app-core/src/lib/cm-code-block-flair.ts index 925f68a4c..33a148afb 100644 --- a/packages/app-core/src/lib/cm-code-block-flair.ts +++ b/packages/app-core/src/lib/cm-code-block-flair.ts @@ -1,11 +1,15 @@ /** - * Code-block "flair" for the WYSIWYG (Edit) editor: a small label pinned to - * the top-right of each fenced code block showing its language (or "text" - * when none is given). Clicking the label copies the block's contents — it - * doubles as the copy button, so there's no separate copy/fold chrome. + * Code-block "flair" for the WYSIWYG (Edit) editor, pinned to the top-right + * of each fenced code block (#1014): * - * WYSIWYG-only: this lives in `wysiwygExtensions()` and never loads in the - * Split (source) editor. + * - a language button reading "typescript ▾" (or "text ▾" when the fence + * names none) that opens the code block language picker. An unlabeled block + * whose language was guessed reads "python · detected", muted, with a + * dashed border: the guess colours the block but is not in the file. + * - a copy button beside it, which copies the block's contents. + * + * Pressing either leaves the caret where it was. WYSIWYG-only: this lives in + * `wysiwygExtensions()` and never loads in the Split (source) editor. */ import { syntaxTree } from '@codemirror/language' import { RangeSetBuilder } from '@codemirror/state' @@ -17,12 +21,51 @@ import { type ViewUpdate, WidgetType } from '@codemirror/view' +import { codeLanguageGuessesChanged, detectedCodeLanguage } from './cm-code-language-detect' +import { codeFenceFromNode } from './code-language-menu' +import { requestCodeLanguagePicker } from './code-language-request' + +const SVG_NS = 'http://www.w3.org/2000/svg' + +function icon(paths: string[], size: number): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, 'svg') + svg.setAttribute('width', String(size)) + svg.setAttribute('height', String(size)) + svg.setAttribute('viewBox', '0 0 24 24') + svg.setAttribute('fill', 'none') + svg.setAttribute('stroke', 'currentColor') + svg.setAttribute('stroke-width', '2') + svg.setAttribute('stroke-linecap', 'round') + svg.setAttribute('stroke-linejoin', 'round') + svg.setAttribute('aria-hidden', 'true') + for (const d of paths) { + const path = document.createElementNS(SVG_NS, 'path') + path.setAttribute('d', d) + svg.append(path) + } + return svg +} -const FENCE_RE = /^\s*(?:`{3,}|~{3,})\s*([^\s`]*)/ +const CHEVRON = ['m6 9 6 6 6-6'] +const COPY = ['M8 8h11a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1z', 'M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3'] +const CHECK = ['M20 6 9 17l-5-5'] + +/** Keep a press on the flair from moving the caret or starting a selection: + * picking a language or copying should not disturb where the user was. */ +function holdCaret(button: HTMLElement): void { + button.addEventListener('mousedown', (event) => { + event.preventDefault() + event.stopPropagation() + }) +} class CodeFlairWidget extends WidgetType { constructor( + /** The language as the label shows it: the fence's, or the guess. */ private readonly language: string, + private readonly detected: boolean, + /** Where the block's opening fence starts. */ + private readonly fenceFrom: number, /** Doc offsets of the block content to copy (fences excluded). */ private readonly contentFrom: number, private readonly contentTo: number @@ -33,27 +76,54 @@ class CodeFlairWidget extends WidgetType { eq(other: CodeFlairWidget): boolean { return ( other.language === this.language && + other.detected === this.detected && + other.fenceFrom === this.fenceFrom && other.contentFrom === this.contentFrom && other.contentTo === this.contentTo ) } toDOM(view: EditorView): HTMLElement { - const button = document.createElement('button') - button.type = 'button' - button.className = 'cm-code-flair' - button.textContent = this.language - button.title = 'Copy code' - button.setAttribute('aria-label', `Copy ${this.language} code block`) - button.setAttribute('contenteditable', 'false') - - // Don't let the editor move the caret / start a selection when the label - // is pressed — copying shouldn't disturb where the user was typing. - button.addEventListener('mousedown', (event) => { + const bar = document.createElement('span') + bar.className = 'cm-code-flair-bar' + bar.setAttribute('contenteditable', 'false') + bar.dataset.codeFenceFrom = String(this.fenceFrom) + + const label = document.createElement('button') + label.type = 'button' + label.className = this.detected ? 'cm-code-flair is-detected' : 'cm-code-flair' + label.setAttribute('aria-haspopup', 'menu') + const name = document.createElement('span') + name.textContent = this.detected ? `${this.language} · detected` : this.language + label.append(name, icon(CHEVRON, 11)) + label.title = this.detected + ? 'Detected language, not saved in the note. Choose a language' + : 'Choose a language' + label.setAttribute( + 'aria-label', + this.detected + ? `Code block language: ${this.language}, detected. Choose a language` + : `Code block language: ${this.language}. Choose a language` + ) + holdCaret(label) + label.addEventListener('click', (event) => { event.preventDefault() event.stopPropagation() + requestCodeLanguagePicker({ + view, + fenceFrom: this.fenceFrom, + anchor: label.getBoundingClientRect() + }) }) - button.addEventListener('click', (event) => { + + const copy = document.createElement('button') + copy.type = 'button' + copy.className = 'cm-code-copy' + copy.title = 'Copy code' + copy.setAttribute('aria-label', 'Copy code') + copy.append(icon(COPY, 13)) + holdCaret(copy) + copy.addEventListener('click', (event) => { event.preventDefault() event.stopPropagation() const text = @@ -62,19 +132,25 @@ class CodeFlairWidget extends WidgetType { : '' void navigator.clipboard?.writeText(text).then( () => { - button.classList.add('is-copied') - button.textContent = 'Copied' + copy.classList.add('is-copied') + copy.replaceChildren(icon(CHECK, 13)) + copy.title = 'Copied' + copy.setAttribute('aria-label', 'Copied') window.setTimeout(() => { - button.classList.remove('is-copied') - button.textContent = this.language + copy.classList.remove('is-copied') + copy.replaceChildren(icon(COPY, 13)) + copy.title = 'Copy code' + copy.setAttribute('aria-label', 'Copy code') }, 1100) }, () => { - /* clipboard denied — leave the label as-is */ + /* clipboard denied: leave the button as it is */ } ) }) - return button + + bar.append(label, copy) + return bar } ignoreEvent(): boolean { @@ -94,12 +170,15 @@ function buildDecorations(view: EditorView): DecorationSet { to, enter: (node) => { if (node.name !== 'FencedCode') return - const beginLine = state.doc.lineAt(node.from) + const fence = codeFenceFromNode(state, node.node) + if (!fence) return false + const beginLine = state.doc.lineAt(fence.markEnd) if (seen.has(beginLine.from)) return false seen.add(beginLine.from) - const langMatch = beginLine.text.match(FENCE_RE) - const language = (langMatch?.[1] || 'text').toLowerCase() + const written = fence.language?.toLowerCase() ?? null + const guess = written === null ? detectedCodeLanguage(state, fence.from) : null + const language = written ?? guess ?? 'text' const lastLine = state.doc.lineAt(Math.max(node.from, node.to - 1)) // Content sits between the opening and closing fence lines. When the @@ -117,7 +196,7 @@ function buildDecorations(view: EditorView): DecorationSet { at: beginLine.to, deco: Decoration.widget({ side: 1, - widget: new CodeFlairWidget(language, contentFrom, contentTo) + widget: new CodeFlairWidget(language, guess !== null, fence.from, contentFrom, contentTo) }) }) return false @@ -143,6 +222,7 @@ export const codeBlockFlairPlugin = ViewPlugin.fromClass( if ( update.docChanged || update.viewportChanged || + codeLanguageGuessesChanged(update) || // A parse cut short by its time budget finishes in a later update // that changes nothing else. syntaxTree(update.startState) !== syntaxTree(update.state) diff --git a/packages/app-core/src/lib/cm-code-language-detect.test.ts b/packages/app-core/src/lib/cm-code-language-detect.test.ts new file mode 100644 index 000000000..98d096961 --- /dev/null +++ b/packages/app-core/src/lib/cm-code-language-detect.test.ts @@ -0,0 +1,194 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing, syntaxHighlighting } from '@codemirror/language' +import { EditorState, type Extension } from '@codemirror/state' +import { EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view' +import { classHighlighter } from '@lezer/highlight' +import { afterEach, describe, expect, it } from 'vitest' +import { codeBlockFlairPlugin } from './cm-code-block-flair' +import { resolveCodeLanguage } from './cm-code-languages' +import { + codeLanguageDetection, + codeLanguageGuessesChanged, + detectedCodeLanguage +} from './cm-code-language-detect' +import { codeFenceAt, setCodeFenceLanguage } from './code-language-menu' + +const PYTHON = `import os +from pathlib import Path + + +def find_notes(root: Path) -> list[Path]: + """Return every markdown file under root.""" + notes = [] + for dirpath, _dirs, files in os.walk(root): + for name in files: + if name.endswith(".md"): + notes.append(Path(dirpath) / name) + return sorted(notes)` + +const BASH = `#!/usr/bin/env bash +set -euo pipefail +for f in "$HOME"/notes/*.md; do + echo "$f" +done` + +const views: EditorView[] = [] + +function editor(doc: string, detect = true, more: Extension[] = []): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const extensions: Extension[] = [ + markdown({ base: markdownLanguage, codeLanguages: resolveCodeLanguage }), + syntaxHighlighting(classHighlighter), + codeBlockFlairPlugin, + detect ? codeLanguageDetection() : [], + more + ] + const view = new EditorView({ parent, state: EditorState.create({ doc, extensions }) }) + forceParsing(view, doc.length, 5000) + views.push(view) + return view +} + +afterEach(() => { + for (const view of views.splice(0)) { + view.dom.parentElement?.remove() + view.destroy() + } +}) + +const labels = (view: EditorView): string[] => + Array.from(view.dom.querySelectorAll('.cm-code-flair')).map((el) => el.textContent ?? '') + +async function until(check: () => boolean, ms = 4000): Promise { + const started = Date.now() + while (!check()) { + if (Date.now() - started > ms) throw new Error('timed out') + await new Promise((resolve) => setTimeout(resolve, 20)) + } +} + +/** Each token of a block's text with the classes it is drawn with. */ +function tokenClasses(view: EditorView, text: string): string[] { + const out: string[] = [] + for (const line of Array.from(view.dom.querySelectorAll('.cm-line'))) { + for (const span of Array.from(line.querySelectorAll('span[class*="tok-"]'))) { + const own = span.className + .split(/\s+/) + .filter((name) => name.startsWith('tok-') && name !== 'tok-monospace') + .sort() + .join(' ') + if (own && text.includes(span.textContent ?? '\0')) out.push(`${span.textContent}:${own}`) + } + } + return out +} + +describe('code block language detection in the editor', () => { + it('labels an unlabeled block "detected" and colours it, without touching the file', async () => { + const doc = `Notes\n\n\`\`\`\n${PYTHON}\n\`\`\`\n\n\`\`\`js\nconst a = 1\n\`\`\`\n\n\`\`\`\nhello\n\`\`\`` + const view = editor(doc) + await until(() => labels(view).includes('python · detected')) + expect(labels(view)).toEqual(['python · detected', 'js', 'text']) + expect(view.state.doc.toString()).toBe(doc) + const fence = codeFenceAt(view.state, doc.indexOf('import os'))! + expect(detectedCodeLanguage(view.state, fence.from)).toBe('python') + expect(view.dom.querySelector('.cm-code-flair.is-detected')).not.toBeNull() + expect(tokenClasses(view, PYTHON)).toContain('def:tok-keyword') + }) + + it('draws a guessed block exactly as the same block labeled', async () => { + const guessed = editor(`\`\`\`\n${PYTHON}\n\`\`\``) + const labeled = editor(`\`\`\`python\n${PYTHON}\n\`\`\``) + await until(() => labels(guessed).includes('python · detected')) + await until(() => tokenClasses(guessed, PYTHON).length > 0) + expect(tokenClasses(guessed, PYTHON)).toEqual(tokenClasses(labeled, PYTHON)) + }) + + it('loads a guessed grammar language-data keeps lazily, then colours with it', async () => { + const view = editor(`\`\`\`\n${BASH}\n\`\`\``) + await until(() => labels(view).includes('bash · detected')) + await until(() => tokenClasses(view, BASH).length > 0) + expect(tokenClasses(view, BASH).some((token) => token.startsWith('echo:'))).toBe(true) + }) + + it('keeps the guess while the block is typed in, until the next one', async () => { + const doc = `\`\`\`\n${PYTHON}\n\`\`\`` + const view = editor(doc) + await until(() => labels(view).includes('python · detected')) + const at = doc.indexOf('return sorted') + view.dispatch({ changes: { from: at, insert: 'x = 1\n ' }, userEvent: 'input.type' }) + expect(labels(view)).toEqual(['python · detected']) + expect(tokenClasses(view, 'x = 1').length).toBeGreaterThan(0) + }) + + it('does not count an edit that only moves a guess as a new one, so typing never re-parses', async () => { + const changed: boolean[] = [] + const probe = ViewPlugin.define(() => ({ + update: (update: ViewUpdate) => { + if (update.docChanged) changed.push(codeLanguageGuessesChanged(update)) + } + })) + const doc = `Intro\n\n\`\`\`\n${PYTHON}\n\`\`\`` + const view = editor(doc, true, [probe]) + await until(() => labels(view).includes('python · detected')) + view.dispatch({ changes: { from: 0, insert: 'A ' }, userEvent: 'input.type' }) + const inBlock = view.state.doc.toString().indexOf('return sorted') + view.dispatch({ changes: { from: inBlock, insert: '# x\n ' }, userEvent: 'input.type' }) + expect(changed).toEqual([false, false]) + expect(labels(view)).toEqual(['python · detected']) + }) + + it('drops the guess once the block names a language, which only a pick writes', async () => { + const doc = `\`\`\`\n${PYTHON}\n\`\`\`` + const view = editor(doc) + await until(() => labels(view).includes('python · detected')) + const fence = codeFenceAt(view.state, doc.indexOf('import'))! + setCodeFenceLanguage(view, fence.from, 'python') + expect(view.state.doc.toString().startsWith('```python\n')).toBe(true) + expect(labels(view)).toEqual(['python']) + expect(view.dom.querySelector('.cm-code-flair.is-detected')).toBeNull() + }) + + it('guesses nothing with the setting off: the block reads text and stays plain', async () => { + const view = editor(`\`\`\`\n${PYTHON}\n\`\`\``, false) + await new Promise((resolve) => setTimeout(resolve, 600)) + expect(labels(view)).toEqual(['text']) + expect(tokenClasses(view, PYTHON)).toEqual([]) + }) + + it('leaves a tiny block as text', async () => { + const view = editor('```\nx = 1\n```\n\n```\n' + PYTHON + '\n```') + await until(() => labels(view).includes('python · detected')) + expect(labels(view)[0]).toBe('text') + }) +}) + +describe('the code block label', () => { + it('is a language button with the copy button beside it', () => { + const view = editor('```ts\nconst a: number = 1\n```', false) + const bar = view.dom.querySelector('.cm-code-flair-bar')! + const language = bar.querySelector('.cm-code-flair')! + const copy = bar.querySelector('.cm-code-copy')! + expect(language.textContent).toBe('ts') + expect(language.getAttribute('aria-haspopup')).toBe('menu') + expect(copy.getAttribute('aria-label')).toBe('Copy code') + }) + + it('opens the picker for its block, through the window event', () => { + const doc = '```ts\nconst a: number = 1\n```' + const view = editor(doc, false) + let request: CustomEvent | null = null + const listen = (event: Event): void => { + request = event as CustomEvent + } + window.addEventListener('zen:open-code-language-picker', listen) + ;(view.dom.querySelector('.cm-code-flair') as HTMLButtonElement).click() + window.removeEventListener('zen:open-code-language-picker', listen) + expect(request).not.toBeNull() + expect(request!.detail.view).toBe(view) + expect(request!.detail.fenceFrom).toBe(0) + }) +}) diff --git a/packages/app-core/src/lib/cm-code-language-detect.ts b/packages/app-core/src/lib/cm-code-language-detect.ts new file mode 100644 index 000000000..c01dce16a --- /dev/null +++ b/packages/app-core/src/lib/cm-code-language-detect.ts @@ -0,0 +1,469 @@ +/** + * Edit mode's guess at the language of a code block that names none (#1014): + * the block is highlighted with the guessed grammar and its label reads + * "python · detected". The guess is never written to the file; the picker's + * "Python (detected)" row does that. + * + * Three parts: + * + * - `guessField` holds the guess per block, keyed by where the block's fence + * starts and mapped through edits, so a block keeps its guess (and its + * colours) while it is typed in, until the next guess replaces it. + * - The detector guesses off the typing path: a while after the last edit or + * scroll, in idle time, a few blocks per slice, with the guesser + * (code-language-detect.ts) loaded on first need and cached by the block's + * text. + * - The highlighter parses a guessed block with the guessed language's own + * CodeMirror parser, over exactly the ranges the markdown parser hands a + * labeled block's language, and draws `highlightTree` with the editor's own + * highlight style. A guessed block looks exactly like a labeled one. + */ +import { + highlightingFor, + Language, + LanguageDescription, + syntaxTree +} from '@codemirror/language' +import { + type ChangeDesc, + type EditorState, + type Extension, + MapMode, + RangeSetBuilder, + StateEffect, + StateField +} from '@codemirror/state' +import { + Decoration, + type DecorationSet, + type EditorView, + ViewPlugin, + type ViewUpdate +} from '@codemirror/view' +import { highlightTree } from '@lezer/highlight' +import { resolveCodeLanguage } from './cm-code-languages' +import { codeFenceFromNode, codeFenceText, type CodeFence } from './code-language-menu' + +type Detector = typeof import('./code-language-detect') + +interface Guess { + /** Where the block's opening fence starts. */ + from: number + tag: string +} + +/** New guesses, or null to drop one, by fence start. */ +const setGuesses = StateEffect.define>() +/** Draw the guessed blocks again: a grammar finished loading, or typing in + * a long block paused. */ +const redrawHighlights = StateEffect.define() + +function mapGuesses(guesses: readonly Guess[], changes: ChangeDesc): Guess[] { + const out: Guess[] = [] + for (const guess of guesses) { + const from = changes.mapPos(guess.from, 1, MapMode.TrackDel) + if (from !== null) out.push({ from, tag: guess.tag }) + } + return out +} + +interface Guesses { + readonly list: readonly Guess[] + /** Bumps when a guess is made or dropped, not when an edit only moves + * one: typing must not count as a new guess, or every keystroke in a + * guessed block would redraw (and re-parse) it. */ + readonly revision: number +} + +const guessField = StateField.define({ + create: () => ({ list: [], revision: 0 }), + update(value, tr) { + let list = tr.docChanged && value.list.length > 0 ? mapGuesses(value.list, tr.changes) : value.list + let revision = value.revision + for (const effect of tr.effects) { + if (!effect.is(setGuesses)) continue + const byFrom = new Map(list.map((guess) => [guess.from, guess.tag])) + for (const { from, tag } of effect.value) { + if (tag === null) byFrom.delete(from) + else byFrom.set(from, tag) + } + list = [...byFrom].map(([from, tag]) => ({ from, tag })) + revision++ + } + return list === value.list ? value : { list, revision } + } +}) + +const guessesOf = (state: EditorState): readonly Guess[] => state.field(guessField, false)?.list ?? [] + +/** The guess for the block whose fence starts at `fenceFrom`, or null. Only + * meaningful for a block that names no language. */ +export function detectedCodeLanguage(state: EditorState, fenceFrom: number): string | null { + return guessesOf(state).find((guess) => guess.from === fenceFrom)?.tag ?? null +} + +/** True when the transaction made or dropped a guess, for views that redraw + * on it (the code block label). An edit that only moves a guess is not one. */ +export function codeLanguageGuessesChanged(update: ViewUpdate): boolean { + return ( + (update.startState.field(guessField, false)?.revision ?? 0) !== + (update.state.field(guessField, false)?.revision ?? 0) + ) +} + +function windowsAround(view: EditorView, margin: number): Array<{ from: number; to: number }> { + const length = view.state.doc.length + return view.visibleRanges.map((range) => ({ + from: Math.max(0, range.from - margin), + to: Math.min(length, range.to + margin) + })) +} + +/** The unlabeled blocks in (or near) the viewport, in document order. */ +function unlabeledFences(view: EditorView, margin: number): CodeFence[] { + const { state } = view + const out: CodeFence[] = [] + const seen = new Set() + const tree = syntaxTree(state) + for (const window of windowsAround(view, margin)) { + tree.iterate({ + from: window.from, + to: window.to, + enter: (node) => { + if (node.name !== 'FencedCode') return + if (seen.has(node.from)) return false + seen.add(node.from) + const fence = codeFenceFromNode(state, node.node) + if (fence && fence.language === null && fence.body.length > 0) out.push(fence) + return false + } + }) + } + return out +} + +/* ------------------------------------------------------------------------ */ +/* The detector */ +/* ------------------------------------------------------------------------ */ + +/** Quiet time after an edit or a scroll before guessing again. */ +const SETTLE_MS = 400 +/** The most one idle slice spends guessing, before yielding. */ +const SLICE_MS = 12 +/** How far past the viewport to guess, so a scroll lands on colour. */ +const DETECT_MARGIN = 4_000 + +let detectorModule: Promise | null = null + +function loadDetector(): Promise { + // The editor does not import highlight.js itself: the first unlabeled block + // asks for the guesser, which the reading view's pipeline shares. + detectorModule ??= import('./code-language-detect') + return detectorModule +} + +type IdleHandle = { cancel: () => void } + +function whenIdle(run: () => void): IdleHandle { + const w = window as Window & { + requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number + cancelIdleCallback?: (id: number) => void + } + if (w.requestIdleCallback) { + const id = w.requestIdleCallback(run, { timeout: 1_000 }) + return { cancel: () => w.cancelIdleCallback?.(id) } + } + const id = window.setTimeout(run, 16) + return { cancel: () => window.clearTimeout(id) } +} + +class DetectorPlugin { + private timer: number | null = null + private idle: IdleHandle | null = null + private destroyed = false + + constructor(private readonly view: EditorView) { + this.schedule(0) + } + + update(update: ViewUpdate): void { + if ( + update.docChanged || + update.viewportChanged || + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { + this.schedule(SETTLE_MS) + } + } + + private schedule(delay: number): void { + if (this.timer !== null) window.clearTimeout(this.timer) + this.idle?.cancel() + this.idle = null + this.timer = window.setTimeout(() => { + this.timer = null + this.idle = whenIdle(() => { + this.idle = null + void this.run() + }) + }, delay) + } + + /** Guesses for blocks in the scanned window that are no longer an + * unlabeled block there: it gained a language, or lost its fence. A guess + * outside the window is kept, so scrolling back finds it in place. */ + private staleGuesses(fences: readonly CodeFence[]): Array<{ from: number; tag: null }> { + const windows = windowsAround(this.view, DETECT_MARGIN) + return guessesOf(this.view.state) + .filter( + (guess) => + windows.some((w) => guess.from >= w.from && guess.from <= w.to) && + !fences.some((fence) => fence.from === guess.from) + ) + .map(({ from }) => ({ from, tag: null })) + } + + private again(): void { + this.idle = whenIdle(() => { + this.idle = null + void this.run() + }) + } + + private async run(): Promise { + if (unlabeledFences(this.view, DETECT_MARGIN).length === 0) { + const stale = this.staleGuesses([]) + if (stale.length) this.dispatch(stale) + return + } + const detector = await loadDetector() + if (this.destroyed) return + const deadline = performance.now() + SLICE_MS + // The first guess compiles every grammar's regexes, about 70 ms in all; + // spend that a few grammars per idle slice instead of in one long task. + let warming = true + while (warming && performance.now() < deadline) warming = detector.warmCodeLanguageDetector() + if (warming) { + this.again() + return + } + const { state } = this.view + const fences = unlabeledFences(this.view, DETECT_MARGIN) + const known = guessesOf(state) + const updates: Array<{ from: number; tag: string | null }> = this.staleGuesses(fences) + let unfinished = false + for (const fence of fences) { + const text = codeFenceText(state, fence) + let result = detector.cachedCodeLanguage(text) + if (result === undefined) { + if (performance.now() > deadline) { + unfinished = true + continue + } + result = detector.detectCodeLanguage(text) + } + const tag = result?.tag ?? null + const before = known.find((guess) => guess.from === fence.from)?.tag ?? null + if (tag !== before) updates.push({ from: fence.from, tag }) + } + if (updates.length) this.dispatch(updates) + if (unfinished) this.again() + } + + private dispatch(updates: ReadonlyArray<{ from: number; tag: string | null }>): void { + if (this.destroyed) return + this.view.dispatch({ effects: setGuesses.of(updates) }) + } + + destroy(): void { + this.destroyed = true + if (this.timer !== null) window.clearTimeout(this.timer) + this.idle?.cancel() + } +} + +/* ------------------------------------------------------------------------ */ +/* The highlighter */ +/* ------------------------------------------------------------------------ */ + +/** A block this long or shorter is re-highlighted on every keystroke; a + * longer one keeps its colours mapped through the edit and is redrawn once + * typing pauses, so typing in a long block costs nothing extra. */ +const SYNC_HIGHLIGHT_CHARS = 6_000 +const REDRAW_MS = 250 +/** Colour this far past the viewport. */ +const HIGHLIGHT_MARGIN = 2_000 + +interface Token { + from: number + to: number + cls: string +} + +const TOKEN_CACHE_LIMIT = 48 +const tokenCache = new Map() +const markCache = new Map() + +function markFor(cls: string): Decoration { + let mark = markCache.get(cls) + if (!mark) { + mark = Decoration.mark({ class: cls }) + markCache.set(cls, mark) + } + return mark +} + +/** The guessed grammar, loading it first when language-data has it lazily. */ +function grammarFor(tag: string, view: EditorView): Language | null { + const resolved = resolveCodeLanguage(tag) + if (!resolved) return null + if (resolved instanceof Language) return resolved + if (resolved instanceof LanguageDescription) { + if (resolved.support) return resolved.support.language + void resolved.load().then( + () => view.dispatch({ effects: redrawHighlights.of(null) }), + () => { + /* no grammar after all: the block stays plain */ + } + ) + } + return null +} + +/** Tokens of a guessed block, relative to its first piece, cached by the + * language, the text and the layout of its pieces. */ +function tokensFor(state: EditorState, fence: CodeFence, tag: string, language: Language): Token[] { + const base = fence.body[0].from + const end = fence.body[fence.body.length - 1].to + const source = state.doc.sliceString(base, end) + const ranges = fence.body.map((piece) => ({ from: piece.from - base, to: piece.to - base })) + const key = `${tag}\0${ranges.map((r) => `${r.from}-${r.to}`).join(',')}\0${source}` + const cached = tokenCache.get(key) + if (cached) { + tokenCache.delete(key) + tokenCache.set(key, cached) + return cached + } + const tree = language.parser.parse(source, [], ranges) + const tokens: Token[] = [] + const highlighter = { + // Scoped to the nested language, the way a labeled block's tokens are. + style: (tags: Parameters[1]) => highlightingFor(state, tags, tree.type) + } + highlightTree(tree, highlighter, (from, to, cls) => { + if (to > from) tokens.push({ from, to, cls }) + }) + tokenCache.set(key, tokens) + while (tokenCache.size > TOKEN_CACHE_LIMIT) { + const oldest = tokenCache.keys().next().value + if (oldest === undefined) break + tokenCache.delete(oldest) + } + return tokens +} + +function buildHighlights(view: EditorView): DecorationSet { + const { state } = view + const guesses = guessesOf(state) + if (guesses.length === 0) return Decoration.none + const builder = new RangeSetBuilder() + const windows = windowsAround(view, HIGHLIGHT_MARGIN) + for (const fence of unlabeledFences(view, HIGHLIGHT_MARGIN)) { + const tag = guesses.find((guess) => guess.from === fence.from)?.tag + if (!tag) continue + const language = grammarFor(tag, view) + if (!language) continue + const base = fence.body[0].from + for (const token of tokensFor(state, fence, tag, language)) { + const from = base + token.from + const to = base + token.to + if (!windows.some((w) => to > w.from && from < w.to)) continue + builder.add(from, to, markFor(token.cls)) + } + } + return builder.finish() +} + +/** The size of the largest guessed block the edit touched (the parse a + * keystroke would pay for), or -1 when it touched none. */ +function touchedGuessedBlock(view: EditorView, update: ViewUpdate): number { + let largest = -1 + const guesses = guessesOf(view.state) + if (guesses.length === 0) return -1 + for (const fence of unlabeledFences(view, 0)) { + if (!guesses.some((guess) => guess.from === fence.from)) continue + let touched = false + update.changes.iterChangedRanges((_fromA, _toA, fromB, toB) => { + if (toB >= fence.from && fromB <= fence.to) touched = true + }) + if (touched) largest = Math.max(largest, fence.to - fence.from) + } + return largest +} + +class HighlightPlugin { + decorations: DecorationSet + private timer: number | null = null + + constructor(private readonly view: EditorView) { + this.decorations = buildHighlights(view) + } + + update(update: ViewUpdate): void { + const redraw = + codeLanguageGuessesChanged(update) || + update.transactions.some((tr) => tr.effects.some((effect) => effect.is(redrawHighlights))) + if (redraw) { + this.decorations = buildHighlights(update.view) + return + } + if (update.docChanged) { + const touched = touchedGuessedBlock(update.view, update) + if (touched < 0) { + // Typing elsewhere: the colours only move. + this.decorations = this.decorations.map(update.changes) + } else if (touched > SYNC_HIGHLIGHT_CHARS) { + this.decorations = this.decorations.map(update.changes) + this.scheduleRedraw() + } else { + this.decorations = buildHighlights(update.view) + } + return + } + // A scroll brings new blocks into view, and a parse cut short by its time + // budget finishes in a later update that can bring a block into the + // tree. Every keystroke also moves the viewport and reparses the note, + // though: while a long block's redraw is pending, redrawing here would be + // the very parse that redraw is there to defer. + if ( + (update.viewportChanged || syntaxTree(update.startState) !== syntaxTree(update.state)) && + this.timer === null + ) { + this.decorations = buildHighlights(update.view) + } + } + + private scheduleRedraw(): void { + if (this.timer !== null) window.clearTimeout(this.timer) + this.timer = window.setTimeout(() => { + this.timer = null + this.view.dispatch({ effects: redrawHighlights.of(null) }) + }, REDRAW_MS) + } + + destroy(): void { + if (this.timer !== null) window.clearTimeout(this.timer) + } +} + +const detectorPlugin = ViewPlugin.fromClass(DetectorPlugin) +const highlightPlugin = ViewPlugin.fromClass(HighlightPlugin, { + decorations: (plugin) => plugin.decorations +}) + +/** Guess and highlight the language of every unlabeled code block. Edit mode + * only, beside the code block label that says so; off with the "Detect code + * block languages" setting. */ +export function codeLanguageDetection(): Extension { + return [guessField, detectorPlugin, highlightPlugin] +} diff --git a/packages/app-core/src/lib/cm-code-languages.ts b/packages/app-core/src/lib/cm-code-languages.ts index 379001961..9a0c9cc6c 100644 --- a/packages/app-core/src/lib/cm-code-languages.ts +++ b/packages/app-core/src/lib/cm-code-languages.ts @@ -32,6 +32,7 @@ import { xml } from '@codemirror/lang-xml' import { yaml } from '@codemirror/lang-yaml' import { markdown } from '@codemirror/lang-markdown' import { php } from '@codemirror/lang-php' +import { isPlainTextFenceTag } from './code-fence-language' /** Map from fence-tag alias (lowercased, no dashes) to an eagerly loaded grammar. */ type Entry = { aliases: string[]; support: LanguageSupport } @@ -68,6 +69,12 @@ for (const entry of EAGER) { for (const a of entry.aliases) aliasMap.set(a, entry.support) } +/** The eager grammars' fence tags, first one the grammar's own name. The + * language picker folds these into its rows, so `golang` finds Go. */ +export const EAGER_CODE_LANGUAGE_ALIASES: ReadonlyArray = EAGER.map( + (entry) => entry.aliases +) + function normalize(info: string): string { return info.trim().toLowerCase().replace(/[^a-z0-9+#]/g, '') } @@ -83,6 +90,9 @@ export function resolveCodeLanguage( ): Language | LanguageDescription | null { const key = normalize(info) if (!key) return null + // `text` and friends mean "no highlighting". Left to the fuzzy fallback + // below, `text` contains `tex` and came back as LaTeX. + if (isPlainTextFenceTag(info.trim().split(/\s/)[0] ?? '')) return null const eager = aliasMap.get(key) if (eager) return eager.language // Fall back to language-data's lazy list for less common fence tags diff --git a/packages/app-core/src/lib/code-fence-language.test.ts b/packages/app-core/src/lib/code-fence-language.test.ts new file mode 100644 index 000000000..0e99e366e --- /dev/null +++ b/packages/app-core/src/lib/code-fence-language.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { + fenceLanguageEdit, + fenceLanguageToken, + isPlainTextFenceTag, + type FenceLanguageEdit +} from './code-fence-language' + +/** Offset just past the fence characters, the way the editor's CodeMark + * node reports it, for lines that are a fence after optional indentation + * or quote markers. */ +function markEnd(line: string): number { + const match = /^(?:[ \t]*>)*[ \t]*(`{3,}|~{3,})/.exec(line) + if (!match) throw new Error(`not a fence: ${line}`) + return match[0].length +} + +function apply(line: string, edit: FenceLanguageEdit | null): string { + if (!edit) return line + return line.slice(0, edit.from) + edit.insert + line.slice(edit.to) +} + +function setLanguage(line: string, tag: string | null): string { + return apply(line, fenceLanguageEdit(line, markEnd(line), tag)) +} + +describe('fenceLanguageToken', () => { + it('reads the first word of the info string', () => { + expect(fenceLanguageToken('```ts', 3)).toBe('ts') + expect(fenceLanguageToken('``` python title="a.py"', 3)).toBe('python') + expect(fenceLanguageToken('~~~~ rust', 4)).toBe('rust') + expect(fenceLanguageToken('```python{1,3}', 3)).toBe('python') + }) + + it('finds none on a bare fence or before an attribute block', () => { + expect(fenceLanguageToken('```', 3)).toBeNull() + expect(fenceLanguageToken('``` ', 3)).toBeNull() + expect(fenceLanguageToken('```{.wide}', 3)).toBeNull() + }) +}) + +describe('fenceLanguageEdit', () => { + it('adds a language to a bare backtick or tilde fence', () => { + expect(setLanguage('```', 'typescript')).toBe('```typescript') + expect(setLanguage('~~~', 'python')).toBe('~~~python') + }) + + it('keeps a long fence at its length', () => { + expect(setLanguage('`````', 'go')).toBe('`````go') + expect(setLanguage('~~~~~~ js', 'rust')).toBe('~~~~~~ rust') + }) + + it('replaces only the language, keeping attributes and a title after it', () => { + expect(setLanguage('```py title="a.py"', 'python')).toBe('```python title="a.py"') + expect(setLanguage('```js {1,3}', 'typescript')).toBe('```typescript {1,3}') + expect(setLanguage('~~~ ruby linenos', 'python')).toBe('~~~ python linenos') + }) + + it('keeps the space between the fence and the language', () => { + expect(setLanguage('``` js', 'ts')).toBe('``` ts') + }) + + it('writes before an attribute block, with a space', () => { + expect(setLanguage('```{.wide}', 'python')).toBe('```python {.wide}') + expect(setLanguage('``` {.wide}', 'python')).toBe('```python {.wide}') + }) + + it('leaves a CR at the end of a CRLF line alone', () => { + expect(setLanguage('```\r', 'python')).toBe('```python\r') + expect(setLanguage('```js\r', 'ts')).toBe('```ts\r') + expect(setLanguage('```js\r', null)).toBe('```\r') + }) + + it('works inside a quote or a list item', () => { + expect(setLanguage('> ```', 'sql')).toBe('> ```sql') + expect(setLanguage('> ```js', 'ts')).toBe('> ```ts') + expect(setLanguage(' ```', 'bash')).toBe(' ```bash') + }) + + it('is nothing to do when the language is already the pick', () => { + expect(fenceLanguageEdit('```python', 3, 'python')).toBeNull() + expect(fenceLanguageEdit('```', 3, null)).toBeNull() + }) + + it('takes the language away, with the gap before it', () => { + expect(setLanguage('```python', null)).toBe('```') + expect(setLanguage('``` python ', null)).toBe('``` ') + expect(setLanguage('~~~~ go', null)).toBe('~~~~') + }) + + it('writes text instead when attributes follow, so none is promoted', () => { + expect(setLanguage('```python title="a.py"', null)).toBe('```text title="a.py"') + expect(fenceLanguageEdit('```text title="a.py"', 3, null)).toBeNull() + }) + + it('reports offsets in the line, for the caller to place in the document', () => { + expect(fenceLanguageEdit('> ```js', 5, 'ts')).toEqual({ from: 5, to: 7, insert: 'ts' }) + }) +}) + +describe('isPlainTextFenceTag', () => { + it('knows the spellings of plain text', () => { + for (const tag of ['text', 'TXT', 'plaintext', 'plain', 'nohighlight']) { + expect(isPlainTextFenceTag(tag)).toBe(true) + } + expect(isPlainTextFenceTag('tex')).toBe(false) + expect(isPlainTextFenceTag('python')).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/code-fence-language.ts b/packages/app-core/src/lib/code-fence-language.ts new file mode 100644 index 000000000..300052031 --- /dev/null +++ b/packages/app-core/src/lib/code-fence-language.ts @@ -0,0 +1,87 @@ +/** + * The language word of a code fence's opening line, and the one edit that + * changes it (#1014). Pure string work on a single line, so the picker, the + * label and the tests agree on what "the language" is. + * + * The language is the first word of the info string, the way CommonMark, + * GitHub and highlight.js read it: in ```` ```ts title="a.ts" ```` it is `ts`, + * and `title="a.ts"` is left to whatever reads it. A word starting with `{` + * is a Pandoc-style attribute block, not a language. + */ + +export interface FenceLanguageEdit { + /** Offsets in the line. */ + from: number + to: number + insert: string +} + +/** The fence tags that mean "no highlighting". A fence labeled one of these + * has made its choice, so it is never guessed at either. */ +export const PLAIN_TEXT_FENCE_TAGS: ReadonlySet = new Set([ + 'text', + 'txt', + 'plain', + 'plaintext', + 'nohighlight', + 'no-highlight' +]) + +export function isPlainTextFenceTag(tag: string): boolean { + return PLAIN_TEXT_FENCE_TAGS.has(tag.trim().toLowerCase()) +} + +interface LanguageSpan { + /** Offset of the language word in the line (or where one would go). */ + from: number + to: number + word: string +} + +function languageSpan(line: string, markEnd: number): LanguageSpan { + const rest = line.slice(markEnd) + const lead = /^[ \t]*/.exec(rest)?.[0] ?? '' + const word = /^[^\s`{]*/.exec(rest.slice(lead.length))?.[0] ?? '' + const from = markEnd + lead.length + return { from, to: from + word.length, word } +} + +/** The language word of an opening fence line, as written, or null. `markEnd` + * is the offset just past the fence's backticks or tildes. */ +export function fenceLanguageToken(line: string, markEnd: number): string | null { + return languageSpan(line, markEnd).word || null +} + +/** + * The edit that makes `tag` the fence's language, or null when it already is. + * Only the language word changes: the fence characters, their count, the + * whitespace before the word and anything after it (attributes, a title, a + * stray `\r`) stay as they are. `tag === null` takes the language away; when + * attributes follow it, the word becomes `text` instead, since deleting it + * would promote the first attribute to the language. + */ +export function fenceLanguageEdit( + line: string, + markEnd: number, + tag: string | null +): FenceLanguageEdit | null { + const span = languageSpan(line, markEnd) + const after = line.slice(span.to) + if (tag === null) { + if (!span.word) return null + if (after.trim() !== '') { + return isPlainTextFenceTag(span.word) ? null : { from: span.from, to: span.to, insert: 'text' } + } + // Take the gap between the fence and the word with it, so ```` ``` py ```` + // becomes a bare ```` ``` ````, not one with a trailing space. + return { from: markEnd, to: span.to, insert: '' } + } + if (span.word === tag) return null + if (span.word) return { from: span.from, to: span.to, insert: tag } + // No language yet. Write it against the fence, and keep a space before an + // attribute block that sat there: ```` ```{.wide} ```` becomes + // ```` ```python {.wide} ````. + const next = line.charAt(markEnd) + const needsGap = next !== '' && !/\s/.test(next) + return { from: markEnd, to: markEnd, insert: needsGap ? `${tag} ` : tag } +} diff --git a/packages/app-core/src/lib/code-language-catalog.test.ts b/packages/app-core/src/lib/code-language-catalog.test.ts new file mode 100644 index 000000000..491586193 --- /dev/null +++ b/packages/app-core/src/lib/code-language-catalog.test.ts @@ -0,0 +1,70 @@ +import { LanguageDescription } from '@codemirror/language' +import { describe, expect, it } from 'vitest' +import { resolveCodeLanguage } from './cm-code-languages' +import { codeLanguageOptions, findCodeLanguageOption } from './code-language-catalog' + +describe('codeLanguageOptions', () => { + const options = codeLanguageOptions() + + it('lists the eager grammars and the language-data list, by name', () => { + const names = options.map((option) => option.name) + for (const name of ['TypeScript', 'Python', 'Rust', 'Go', 'Shell', 'Ruby', 'Kotlin', 'Swift', 'C#', 'TOML']) { + expect(names).toContain(name) + } + const sorted = [...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + expect(names).toEqual(sorted) + }) + + it('writes the tags people use, not the display names', () => { + const tagOf = (name: string): string | undefined => + options.find((option) => option.name === name)?.tag + expect(tagOf('C++')).toBe('cpp') + expect(tagOf('C#')).toBe('csharp') + expect(tagOf('Shell')).toBe('bash') + expect(tagOf('TypeScript')).toBe('typescript') + expect(tagOf('Objective-C')).toBe('objc') + expect(tagOf('Properties files')).toBe('ini') + }) + + it('offers only tags the editor reads back as a language', () => { + for (const option of options) { + expect(resolveCodeLanguage(option.tag), option.name).not.toBeNull() + } + }) + + it('reads every language-data pick back as the language it names', () => { + for (const option of options) { + const resolved = resolveCodeLanguage(option.tag) + if (resolved instanceof LanguageDescription) expect(resolved.name, option.tag).toBe(option.name) + } + }) + + it('leaves out languages no fence tag can reach', () => { + const names = options.map((option) => option.name) + expect(names).not.toContain('VB.NET') + expect(names).not.toContain('MS SQL') + }) + + it('gives each tag one row', () => { + const tags = options.map((option) => option.tag) + expect(new Set(tags).size).toBe(tags.length) + }) + + it('finds a row by its tag or any alias, the eager ones too', () => { + expect(findCodeLanguageOption('golang')?.name).toBe('Go') + expect(findCodeLanguageOption('py')?.name).toBe('Python') + expect(findCodeLanguageOption('ts')?.name).toBe('TypeScript') + expect(findCodeLanguageOption('sh')?.name).toBe('Shell') + expect(findCodeLanguageOption('YML')?.name).toBe('YAML') + expect(findCodeLanguageOption('mermaid')).toBeNull() + }) +}) + +describe('resolveCodeLanguage and plain text', () => { + it('reads text and its spellings as no language, not LaTeX', () => { + expect(resolveCodeLanguage('text')).toBeNull() + expect(resolveCodeLanguage('plaintext')).toBeNull() + expect(resolveCodeLanguage('text title="x"')).toBeNull() + expect(resolveCodeLanguage('tex')).not.toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/code-language-catalog.ts b/packages/app-core/src/lib/code-language-catalog.ts new file mode 100644 index 000000000..878b24cd2 --- /dev/null +++ b/packages/app-core/src/lib/code-language-catalog.ts @@ -0,0 +1,116 @@ +/** + * Every language the editor can highlight a fence in, as the code block + * language picker lists it (#1014): the eager grammars, the + * `@codemirror/language-data` list and the user's own TextMate languages. + * + * Each row carries the one tag a pick writes after the fence. That tag is + * chosen so the editor reads it back as the same language: language-data's + * names are not always fence tags ("C++", "Properties files"), and its fuzzy + * matcher reads some spellings as another language entirely, so a language + * with no tag that survives the round trip is left out rather than offered + * as a pick that would not highlight. + */ +import { LanguageDescription } from '@codemirror/language' +import { languages as languageData } from '@codemirror/language-data' +import { isReservedCodeFenceTag } from '@shared/custom-code-languages' +import { EAGER_CODE_LANGUAGE_ALIASES, resolveCodeLanguage } from './cm-code-languages' +import { customCodeLanguageRegistry } from './custom-code-languages' + +export interface CodeLanguageOption { + /** What a pick writes after the fence. */ + tag: string + /** The name the picker shows. */ + name: string + /** The other fence tags that mean this language, for the find field. */ + aliases: readonly string[] + /** A TextMate language the user installed. */ + custom: boolean +} + +/** The tag people write for a language, where it is not language-data's name. + * The name would read back too (`c++`, `c#`, `shell`), but `cpp`, `csharp` + * and `fsharp` are what GitHub and highlight.js expect, and highlight.js + * reads `shell` as a terminal session, so the reading view would disagree + * with the editor about a `Shell` pick. */ +const PREFERRED_TAG: Readonly> = { + 'C++': 'cpp', + 'C#': 'csharp', + 'F#': 'fsharp', + Shell: 'bash' +} + +const FENCE_TAG_RE = /^[a-z0-9][a-z0-9_+#.-]*$/ + +function readsBackAs(tag: string, description: LanguageDescription): boolean { + const resolved = resolveCodeLanguage(tag) + if (!resolved) return false + if (resolved instanceof LanguageDescription) return resolved === description + // An eager grammar owns the tag: `c` highlights with the C++ grammar, `jsx` + // with JavaScript's. That is the language the editor shows, so it counts. + return EAGER_CODE_LANGUAGE_ALIASES.some((aliases) => aliases.includes(tag)) +} + +function builtinOptions(): CodeLanguageOption[] { + const out: CodeLanguageOption[] = [] + for (const description of languageData) { + const candidates = [ + PREFERRED_TAG[description.name], + description.name.toLowerCase(), + ...description.alias + ].filter((tag): tag is string => !!tag && FENCE_TAG_RE.test(tag)) + const tag = candidates.find((candidate) => readsBackAs(candidate, description)) + if (!tag) continue + const aliases = new Set( + description.alias.filter((alias) => alias !== tag && FENCE_TAG_RE.test(alias)) + ) + out.push({ tag, name: description.name, aliases: [...aliases], custom: false }) + } + // The eager grammars answer to a few tags language-data does not list + // (`golang`, `py`, `yml`). Fold each into the row that already owns the + // grammar's own name, so the find field finds Go by `golang`. + const taken = new Set(out.map((option) => option.tag)) + for (const aliases of EAGER_CODE_LANGUAGE_ALIASES) { + const owner = out.find((option) => option.tag === aliases[0] || option.aliases.includes(aliases[0])) + if (!owner) continue + const merged = new Set(owner.aliases) + for (const alias of aliases) if (alias !== owner.tag && !taken.has(alias)) merged.add(alias) + owner.aliases = [...merged] + } + return out +} + +let builtinCache: CodeLanguageOption[] | null = null + +function customOptions(): CodeLanguageOption[] { + const out: CodeLanguageOption[] = [] + for (const language of customCodeLanguageRegistry.languages()) { + // A built-in grammar wins a reserved tag, so the custom row writes the + // first tag that is really its own. + const own = language.aliases.filter((alias) => !isReservedCodeFenceTag(alias)) + if (own.length === 0) continue + out.push({ tag: own[0], name: language.name, aliases: own.slice(1), custom: true }) + } + return out +} + +/** Every language a fence can be set to, by name. */ +export function codeLanguageOptions(): CodeLanguageOption[] { + builtinCache ??= builtinOptions() + return [...builtinCache, ...customOptions()].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + ) +} + +/** The row a fence tag belongs to (by its tag or any alias), or null. */ +export function findCodeLanguageOption( + tag: string, + options: readonly CodeLanguageOption[] = codeLanguageOptions() +): CodeLanguageOption | null { + const key = tag.trim().toLowerCase() + if (!key) return null + return ( + options.find((option) => option.tag === key) ?? + options.find((option) => option.aliases.includes(key)) ?? + null + ) +} diff --git a/packages/app-core/src/lib/code-language-detect.samples.ts b/packages/app-core/src/lib/code-language-detect.samples.ts new file mode 100644 index 000000000..70ab30195 --- /dev/null +++ b/packages/app-core/src/lib/code-language-detect.samples.ts @@ -0,0 +1,847 @@ +/** + * Real snippets for the code block language guess (#1014): the kind of thing + * people paste into a note, gathered to tune code-language-detect.ts and to + * keep it honest. Test data only; nothing in the app imports this. + */ + +/** Blocks the guess must name exactly: [tag, label, code]. */ +export const DETECTED: ReadonlyArray = [ + ['python', 'python', `import os +from pathlib import Path + + +def find_notes(root: Path) -> list[Path]: + """Return every markdown file under root.""" + notes = [] + for dirpath, _dirs, files in os.walk(root): + for name in files: + if name.endswith(".md"): + notes.append(Path(dirpath) / name) + return sorted(notes) + + +if __name__ == "__main__": + print(len(find_notes(Path.home() / "notes")))`], + ['typescript', 'typescript', `interface Note { + path: string + title: string + tags: readonly string[] +} + +export function byTag(notes: Note[], tag: string): Note[] { + return notes.filter((note): note is Note => note.tags.includes(tag)) +} + +const counts = new Map() +for (const note of notes) { + counts.set(note.title, (counts.get(note.title) ?? 0) + 1) +}`], + ['javascript', 'javascript', `const express = require('express') +const app = express() + +app.get('/notes/:id', async (req, res) => { + const note = await db.notes.findOne({ id: req.params.id }) + if (!note) { + return res.status(404).json({ error: 'not found' }) + } + res.json(note) +}) + +app.listen(3000, () => console.log('listening on 3000'))`], + ['rust', 'rust', `use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct Note { + pub title: String, + pub tags: Vec, +} + +impl Note { + pub fn new(title: &str) -> Self { + Self { title: title.to_string(), tags: Vec::new() } + } +} + +fn count_tags(notes: &[Note]) -> HashMap<&str, usize> { + let mut counts = HashMap::new(); + for note in notes { + for tag in ¬e.tags { + *counts.entry(tag.as_str()).or_insert(0) += 1; + } + } + counts +}`], + ['go', 'go', `package main + +import ( + "fmt" + "os" + "path/filepath" +) + +func main() { + count := 0 + err := filepath.Walk(os.Args[1], func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if filepath.Ext(path) == ".md" { + count++ + } + return nil + }) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Printf("%d notes\\n", count) +}`], + ['json', 'json', `{ + "name": "zennotes", + "version": "2.79.0", + "private": true, + "workspaces": ["apps/*", "packages/*"], + "scripts": { + "build": "turbo run build", + "test": "turbo run test:run" + }, + "engines": { "node": ">=20" } +}`], + ['java', 'java', `package com.example.notes; + +import java.util.ArrayList; +import java.util.List; + +public class NoteIndex { + private final List titles = new ArrayList<>(); + + public void add(String title) { + if (title == null || title.isBlank()) { + throw new IllegalArgumentException("title is required"); + } + titles.add(title); + } + + public static void main(String[] args) { + NoteIndex index = new NoteIndex(); + index.add("Groceries"); + System.out.println(index.titles.size()); + } +}`], + ['cpp', 'cpp', `#include +#include +#include + +class Counter { +public: + explicit Counter(int start) : value_(start) {} + void increment() { ++value_; } + int value() const { return value_; } +private: + int value_; +}; + +int main() { + std::vector names{"a", "b", "c"}; + Counter counter(0); + for (const auto& name : names) { + counter.increment(); + std::cout << name << std::endl; + } + return 0; +}`], + ['c', 'c', `#include +#include + +int main(int argc, char **argv) { + if (argc < 2) { + fprintf(stderr, "usage: %s FILE\\n", argv[0]); + return 1; + } + FILE *fp = fopen(argv[1], "r"); + if (fp == NULL) { + perror("fopen"); + return 1; + } + int lines = 0; + int ch; + while ((ch = fgetc(fp)) != EOF) { + if (ch == '\\n') lines++; + } + fclose(fp); + printf("%d\\n", lines); + return 0; +}`], + ['html', 'html', ` + + + + Notes + + + +
+

My notes

+
    +
    + + +`], + ['css', 'css', `.note-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 16px; + border-radius: 8px; + background: #f7f7f5; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); +} + +.note-card:hover { + background-color: #efefec; +} + +@media (max-width: 600px) { + .note-card { padding: 8px; } +}`], + ['sql', 'sql', `SELECT n.title, COUNT(t.id) AS tag_count +FROM notes AS n +LEFT JOIN note_tags AS t ON t.note_id = n.id +WHERE n.archived = FALSE + AND n.updated_at > '2026-01-01' +GROUP BY n.title +HAVING COUNT(t.id) > 2 +ORDER BY tag_count DESC +LIMIT 20;`], + ['yaml', 'yaml', `name: CI +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm test`], + ['bash', 'bash', `#!/usr/bin/env bash +set -euo pipefail + +VAULT="\${1:-$HOME/notes}" +if [[ ! -d "$VAULT" ]]; then + echo "no vault at $VAULT" >&2 + exit 1 +fi + +for f in "$VAULT"/*.md; do + lines=$(wc -l < "$f") + echo "$(basename "$f"): $lines" +done | sort -t: -k2 -n`], + ['ruby', 'ruby', `require 'json' + +class Note + attr_reader :title, :tags + + def initialize(title, tags = []) + @title = title + @tags = tags + end + + def tagged?(tag) + tags.include?(tag) + end +end + +notes = JSON.parse(File.read('notes.json')).map do |h| + Note.new(h['title'], h['tags']) +end +puts notes.select { |n| n.tagged?('work') }.map(&:title)`], + ['php', 'php', `where('user_id', $request->user()->id) + ->latest() + ->paginate(20); + + return view('notes.index', ['notes' => $notes]); + } +}`], + ['kotlin', 'kotlin', `data class Note(val title: String, val tags: List = emptyList()) + +fun main() { + val notes = listOf( + Note("Groceries", listOf("home")), + Note("Sprint plan", listOf("work", "plan")), + ) + val byTag = notes.flatMap { note -> note.tags.map { it to note } } + .groupBy({ it.first }, { it.second }) + byTag.forEach { (tag, list) -> println("$tag: \${list.size}") } +}`], + ['swift', 'swift', `import Foundation + +struct Note: Identifiable, Codable { + let id: UUID + var title: String + var tags: [String] +} + +final class NoteStore: ObservableObject { + @Published private(set) var notes: [Note] = [] + + func add(_ title: String) { + notes.append(Note(id: UUID(), title: title, tags: [])) + } + + func notes(tagged tag: String) -> [Note] { + notes.filter { $0.tags.contains(tag) } + } +}`], + ['csharp', 'csharp', `using System; +using System.Collections.Generic; +using System.Linq; + +namespace Notes +{ + public class NoteIndex + { + private readonly List _titles = new List(); + + public void Add(string title) + { + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("title is required", nameof(title)); + _titles.Add(title); + } + + public IEnumerable StartingWith(string prefix) => + _titles.Where(t => t.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } +}`], + ['diff', 'diff', `diff --git a/src/notes.ts b/src/notes.ts +index 3b18e51..a9c2f4d 100644 +--- a/src/notes.ts ++++ b/src/notes.ts +@@ -10,7 +10,7 @@ export function title(note: Note): string { +- return note.title ++ return note.title.trim() + } +`], + ['python', 'python-short', `def greet(name): + if not name: + return "Hello, stranger" + return f"Hello, {name}!" + +print(greet("Ada"))`], + ['python', 'python-class', `class Stack: + def __init__(self): + self.items = [] + + def push(self, item): + self.items.append(item) + + def pop(self): + return self.items.pop()`], + ['javascript', 'js-short', `function debounce(fn, ms) { + let timer + return (...args) => { + clearTimeout(timer) + timer = setTimeout(() => fn(...args), ms) + } +}`], + ['javascript', 'js-react', `import { useState } from 'react' + +export default function Counter() { + const [count, setCount] = useState(0) + return +}`], + ['javascript', 'js-fetch', `const res = await fetch('/api/notes') +const notes = await res.json() +notes.forEach((note) => console.log(note.title))`], + ['typescript', 'ts-generic', `export async function retry(fn: () => Promise, times = 3): Promise { + let last: unknown + for (let i = 0; i < times; i++) { + try { + return await fn() + } catch (error) { + last = error + } + } + throw last +}`], + ['rust', 'rust-short', `fn main() { + let words = vec!["apple", "bob", "kayak"]; + for w in &words { + let rev: String = w.chars().rev().collect(); + println!("{} {}", w, rev == *w); + } +}`], + ['go', 'go-short', `func Sum(xs []int) int { + total := 0 + for _, x := range xs { + total += x + } + return total +}`], + ['json', 'json-array', `[ + { "id": 1, "title": "Groceries", "done": false }, + { "id": 2, "title": "Call mom", "done": true } +]`], + ['c', 'c-short', `#include + +int main(void) { + printf("hello, world\\n"); + return 0; +}`], + ['cpp', 'cpp-short', `#include + +int main() { + std::cout << "hello" << std::endl; +}`], + ['html', 'html-snippet', `
    +

    Title

    +

    Some bold text and a link.

    +
    `], + ['xml', 'xml-config', ` + + 4.0.0 + com.example + notes +`], + ['css', 'css-short', `body { + margin: 0; + font-family: system-ui, sans-serif; + color: #222; +}`], + ['ruby', 'ruby-short', `def fizzbuzz(n) + (1..n).each do |i| + puts i % 15 == 0 ? "FizzBuzz" : i % 3 == 0 ? "Fizz" : i % 5 == 0 ? "Buzz" : i + end +end`], + ['php', 'php-short', ` $n['done'] === false); +foreach ($notes as $note) { + echo $note['title'] . PHP_EOL; +}`], + ['kotlin', 'kotlin-short', `fun greet(name: String?): String { + val who = name ?: "stranger" + return "Hello, $who" +}`], + ['csharp', 'csharp-short', `public class Person +{ + public string Name { get; set; } + public int Age { get; set; } +}`], + ['toml', 'toml', `[package] +name = "zn" +version = "0.12.2" +edition = "2021" + +[dependencies] +serde = { version = "1", features = ["derive"] }`], + ['diff', 'diff-short', `--- a/notes.md ++++ b/notes.md +@@ -1,3 +1,3 @@ +-# Old title ++# New title + Body text`], + ['python', 'python-async', `import asyncio + +async def fetch(session, url): + async with session.get(url) as resp: + return await resp.text() + +async def main(): + tasks = [fetch(s, u) for u in urls] + return await asyncio.gather(*tasks)`], + ['javascript', 'js-class', `class Queue { + constructor() { + this.items = [] + } + enqueue(item) { + this.items.push(item) + } + dequeue() { + return this.items.shift() + } +} + +module.exports = Queue`], + ['go', 'go-struct', `type Note struct { + Title string \`json:"title"\` + Tags []string \`json:"tags"\` +} + +func (n *Note) HasTag(tag string) bool { + for _, t := range n.Tags { + if t == tag { + return true + } + } + return false +}`], + ['ruby', 'ruby-rails', `class NotesController < ApplicationController + before_action :set_note, only: [:show, :edit] + + def index + @notes = Note.order(created_at: :desc) + end + + private + + def set_note + @note = Note.find(params[:id]) + end +end`], + ['bash', 'bash-script', `#!/bin/bash +for i in {1..5}; do + echo "run $i" + sleep 1 +done`], + ['yaml', 'yaml-k8s', `apiVersion: apps/v1 +kind: Deployment +metadata: + name: zennotes +spec: + replicas: 2 + template: + spec: + containers: + - name: server + image: adibhanna/zennotes:latest`], + ['css', 'css-grid', `.layout { + display: grid; + grid-template-columns: 240px 1fr; + gap: 16px; +} +.layout > aside { + border-right: 1px solid #ddd; +}`], + ['json', 'json-lines', `{"level":"info","msg":"start"} +{"level":"warn","msg":"slow"}`] +] + +/** Short real snippets. Too little for a confident guess, mostly: each may + * stay text, but must never be taken for another language. */ +export const SHORT: ReadonlyArray = [ + ['python', 'python-pandas', `import pandas as pd + +df = pd.read_csv("sales.csv") +monthly = df.groupby("month")["total"].sum() +print(monthly.head())`], + ['typescript', 'ts-short', `type Point = { x: number; y: number } + +function distance(a: Point, b: Point): number { + return Math.hypot(a.x - b.x, a.y - b.y) +}`], + ['go', 'go-http', `http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") +}) +log.Fatal(http.ListenAndServe(":8080", nil))`], + ['json', 'json-small', `{"theme": "dark", "fontSize": 14}`], + ['java', 'java-short', `public static int fib(int n) { + if (n < 2) return n; + return fib(n - 1) + fib(n - 2); +}`], + ['sql', 'sql-short', `CREATE TABLE notes ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +);`], + ['sql', 'sql-update', `UPDATE notes SET archived = 1 WHERE updated_at < '2025-01-01';`], + ['yaml', 'yaml-compose', `services: + db: + image: postgres:16 + environment: + POSTGRES_PASSWORD: example + ports: + - "5432:5432"`], + ['yaml', 'yaml-front', `title: Weekly review +tags: + - review + - weekly +date: 2026-10-29`], + ['bash', 'bash-short', `cd ~/notes +git add -A +git commit -m "sync" +git push origin main`], + ['bash', 'bash-loop', `for f in *.png; do + convert "$f" -resize 50% "small-$f" +done`], + ['bash', 'shell-prompt', `$ brew install zennotes +$ zn --version +zn 0.12.2`], + ['swift', 'swift-short', `let names = ["Ada", "Grace"] +for name in names { + print("Hello, \\(name)") +}`], + ['scss', 'scss', `$accent: #c33; + +.button { + color: $accent; + &:hover { + color: darken($accent, 10%); + } +}`], + ['python', 'python-decor', `@app.route("/notes/") +def show(note_id): + note = Note.query.get_or_404(note_id) + return render_template("note.html", note=note)`], + ['java', 'java-stream', `List titles = notes.stream() + .filter(n -> !n.isArchived()) + .map(Note::getTitle) + .sorted() + .collect(Collectors.toList());`], + ['sql', 'sql-join', `SELECT u.name, COUNT(*) AS notes +FROM users u +JOIN notes n ON n.user_id = u.id +GROUP BY u.name +ORDER BY notes DESC;`], + ['rust', 'rust-match', `match command { + Command::Add(title) => store.add(title)?, + Command::Remove(id) => store.remove(id)?, + Command::List => { + for note in store.list() { + println!("{}", note.title); + } + } +}`] +] + +/** Text that is not code: never a guess, at this length or twelve times it. */ +export const NOT_CODE: ReadonlyArray = [ + ['prose', `This is just a paragraph of prose that someone put in a code block +because they wanted it to keep its line breaks. It talks about notes, and +about the weather, and nothing in it is code.`], + ['path', `/Users/adib/Documents/notes/inbox/2026-10-29.md`], + ['url', `https://zennotes.org/docs/code-blocks`], + ['csv', `name,age,city +Ada,36,London +Grace,85,Arlington +Linus,54,Portland`], + ['log', `2026-10-29 10:42:01 INFO starting sync +2026-10-29 10:42:02 INFO pulled 12 changes +2026-10-29 10:42:02 WARN conflict in inbox/todo.md +2026-10-29 10:42:03 INFO done in 1.8s`], + ['ascii table', `+--------+-------+ +| name | count | ++--------+-------+ +| notes | 120 | +| tags | 34 | ++--------+-------+`], + ['tree', `zennotes/ +├── apps/ +│ ├── desktop/ +│ └── web/ +└── packages/ + └── app-core/`], + ['numbers', `1 2 3 4 5 +6 7 8 9 10`], + ['shopping', `- eggs +- milk +- bread`], + ['poem', `Roses are red, +violets are blue, +sugar is sweet, +and so are you.`], + ['addr', `221B Baker Street +London NW1 6XE`], + ['kv', `Name: Ada Lovelace +Born: 1815 +Known for: Analytical Engine notes`], + ['recipe', `2 cups flour +1 tsp salt +3/4 cup water +Mix, knead for 10 minutes, rest 1 hour.`], + ['chat', `Me: are we still on for 7? +Sam: yes! see you there +Me: great`], + ['math', `a^2 + b^2 = c^2 +3^2 + 4^2 = 5^2`], + ['error', `TypeError: Cannot read properties of undefined (reading 'title') + at renderNote (app.js:42:17) + at main (app.js:10:3)`], + ['keys', `Ctrl+Shift+P +Cmd+K Cmd+S`], + ['quote', `"The best way to predict the future is to invent it." +- Alan Kay`], + ['hex', `de ad be ef 00 11 22 33 +44 55 66 77 88 99 aa bb`], + ['todo', `TODO +[x] write spec +[ ] ship it`], + ['lorem', `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.`], + ['email', `From: ada@example.com +To: grace@example.com +Subject: Notes + +Hi Grace, here are the notes from today.`] +] + +/** Too small to tell: never a guess. */ +export const TINY: ReadonlyArray = [ + ['tiny word', `hello`], + ['tiny cmd', `npm install`], + ['tiny assign', `x = 1`], + ['one line call', `print("hi")`], + ['one-line JSON', `{"theme": "dark", "fontSize": 14}`], + ['one-line SQL', `UPDATE notes SET archived = 1 WHERE updated_at < '2025-01-01';`] +] + +/** Code in languages outside the subset, and output that only looks like + * code: never a guess. */ +export const OTHER_LANGUAGES: ReadonlyArray = [ + ['haskell', `module Main where + +quicksort :: Ord a => [a] -> [a] +quicksort [] = [] +quicksort (p:xs) = quicksort lesser ++ [p] ++ quicksort greater + where + lesser = filter (< p) xs + greater = filter (>= p) xs`], + ['elixir', `defmodule Greeter do + def hello(name) do + "Hello, #{name}" + end +end`], + ['makefile', `build: + go build -o bin/zn ./cmd/zn + +test: + go test ./...`], + ['nginx', `server { + listen 80; + server_name notes.example.com; + location / { + proxy_pass http://127.0.0.1:7878; + } +}`], + ['lua', `local function greet(name) + if name == nil then + return "hello" + end + return "hello " .. name +end`], + ['markdown', `# Title + +Some *emphasis* and **bold** text. + +- one +- two`], + ['scala', `object Main extends App { + case class Note(title: String, tags: List[String]) + val notes = List(Note("a", List("x")), Note("b", Nil)) + notes.filter(_.tags.nonEmpty).foreach(n => println(n.title)) +}`], + ['perl', `my @notes = glob("*.md"); +foreach my $file (@notes) { + open(my $fh, '<', $file) or die "Cannot open $file: $!"; + my $count = () = <$fh>; + print "$file: $count\\n"; +}`], + ['r', `library(dplyr) +notes <- read.csv("notes.csv") +notes %>% + group_by(tag) %>% + summarise(n = n()) %>% + arrange(desc(n))`], + ['clojure', `(defn count-tags [notes] + (->> notes + (mapcat :tags) + (frequencies) + (sort-by val >)))`], + ['latex', `\\begin{equation} + E = mc^2 +\\end{equation} +\\begin{itemize} + \\item First + \\item Second +\\end{itemize}`], + ['py-traceback', `Traceback (most recent call last): + File "main.py", line 10, in + main() + File "main.py", line 6, in main + print(notes[3]) +IndexError: list index out of range`], + ['java-trace', `Exception in thread "main" java.lang.NullPointerException + at com.example.Notes.load(Notes.java:42) + at com.example.Main.main(Main.java:10)`], + ['sql-prose', `Please select the items from the list below and update the table +when you are done, then drop me a note with the values you picked.`], + ['shell-session', `$ npm install +added 214 packages in 3s +$ npm test +> zennotes@2.79.0 test +> vitest run`], + ['git-log', `commit 8eac8604a1b2 +Author: Adib Hanna +Date: Thu Oct 29 11:27:02 2026 -0500 + + Test(cloud): the pull-cost check bounds a large vault's change`], + ['http', `GET /api/notes HTTP/1.1 +Host: localhost:7878 +Authorization: Bearer abc123 +Accept: application/json`], + ['env', `DATABASE_URL=postgres://localhost/notes +REDIS_URL=redis://localhost:6379 +DEBUG=true`], + ['markdown-table', `| Name | Count | +|------|-------| +| a | 1 | +| b | 2 |`], + ['pseudo', `function findMax(list) + max <- list[0] + for each item in list + if item > max then max <- item + return max`], + ['regex-list', `^\\d{3}-\\d{4}$ +^[A-Z][a-z]+$ +\\b(foo|bar)\\b`], + ['outline', `1. Introduction + 1.1 Background + 1.2 Goals +2. Design + 2.1 Detection`], + ['meeting', `Attendees: Ada, Grace, Linus +Agenda: + - review the release + - plan next sprint +Decisions: ship on Friday`], + ['ini-nosection', `name = zen +version = 2 +debug = true`], + ['csv-semi', `id;title;done +1;Groceries;false +2;Call mom;true`], + ['graphql', `query Notes($tag: String!) { + notes(tag: $tag) { + id + title + } +}`], + ['proto', `syntax = "proto3"; +message Note { + string title = 1; + repeated string tags = 2; +}`], + ['dockerfile', `FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +CMD ["node", "server.js"]`] +] diff --git a/packages/app-core/src/lib/code-language-detect.test.ts b/packages/app-core/src/lib/code-language-detect.test.ts new file mode 100644 index 000000000..13a8946b0 --- /dev/null +++ b/packages/app-core/src/lib/code-language-detect.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { + cachedCodeLanguage, + detectCodeLanguage, + DETECTABLE_LANGUAGES, + MAX_DETECT_CHARS, + MAX_SAMPLE_CHARS, + MIN_RELEVANCE, + warmCodeLanguageDetector +} from './code-language-detect' +import { DETECTED, NOT_CODE, OTHER_LANGUAGES, SHORT, TINY } from './code-language-detect.samples' +import { resolveCodeLanguage } from './cm-code-languages' + +const tagOf = (code: string): string | null => detectCodeLanguage(code)?.tag ?? null +const twelveTimes = (code: string): string => Array.from({ length: 12 }, () => code).join('\n') + +describe('detectCodeLanguage on real snippets', () => { + it.each(DETECTED)('names %s (%s)', (tag, _label, code) => { + expect(tagOf(code)).toBe(tag) + }) + + it('covers the languages people paste most', () => { + const named = new Set(DETECTED.map(([tag]) => tag)) + for (const tag of [ + 'python', 'javascript', 'typescript', 'bash', 'json', 'yaml', 'sql', 'html', 'css', + 'go', 'rust', 'java', 'c', 'cpp', 'csharp', 'php', 'ruby', 'kotlin', 'swift', 'diff', 'toml' + ]) { + expect(named, tag).toContain(tag) + } + }) + + it.each(SHORT)('leaves a short %s snippet (%s) as text or names it right', (tag, _label, code) => { + expect([null, tag]).toContain(tagOf(code)) + }) + + it('still names most short snippets that clear the bar', () => { + const short = DETECTED.filter(([, label]) => label.endsWith('-short')) + expect(short.length).toBeGreaterThanOrEqual(10) + }) +}) + +describe('what stays text', () => { + it.each(NOT_CODE)('%s is not code', (_label, text) => { + expect(tagOf(text)).toBeNull() + }) + + it.each(NOT_CODE)('%s is not code twelve times over either', (_label, text) => { + // Relevance only adds up: a long enough paragraph clears any threshold + // as CSS. The hallmarks are what keep it text. + expect(tagOf(twelveTimes(text))).toBeNull() + }) + + it.each(TINY)('%s is too small to tell', (_label, text) => { + expect(tagOf(text)).toBeNull() + }) + + it.each(OTHER_LANGUAGES)('%s is not taken for a language in the subset', (_label, text) => { + expect(tagOf(text)).toBeNull() + }) + + it('leaves code valid in two families to text, rather than pick one', () => { + // Every line is Java and every line is C#; highlight.js scores C# one + // point ahead, which is noise, not evidence. + const javaOrCSharp = `public class Point { + private int x; + private int y; + + public int getX() { + return x; + } +}` + expect(tagOf(javaOrCSharp)).toBeNull() + }) +}) + +describe('the guess and its rules', () => { + it('trusts a shebang and a JSON document over the scores', () => { + expect(tagOf('#!/usr/bin/env python3\nimport sys\nprint(sys.argv)')).toBe('python') + expect(tagOf('#!/bin/zsh\nsetopt extendedglob\nls **/*.md')).toBe('bash') + // highlight.js scores this the same in JSON, Rust and Go. + expect(tagOf('{\n "name": "zennotes",\n "private": true\n}')).toBe('json') + }) + + it('tells TypeScript from JavaScript by its types', () => { + const js = `function debounce(fn, ms) { + let timer + return (...args) => { + clearTimeout(timer) + timer = setTimeout(() => fn(...args), ms) + } +} +export const save = debounce(() => console.log('saved'), 200)` + const ts = `function debounce(fn: (...args: T) => void, ms: number) { + let timer: ReturnType | undefined + return (...args: T): void => { + clearTimeout(timer) + timer = setTimeout(() => fn(...args), ms) + } +} +export const save = debounce(() => console.log('saved'), 200)` + expect(tagOf(js)).toBe('javascript') + expect(tagOf(ts)).toBe('typescript') + }) + + it('maps highlight.js names to the tags the editor reads', () => { + const html = DETECTED.find(([, label]) => label === 'html')![2] + expect(tagOf(html)).toBe('html') + const toml = DETECTED.find(([, label]) => label === 'toml')![2] + expect(tagOf(toml)).toBe('toml') + }) + + it('only ever names a tag the editor can highlight', () => { + const tags = new Set(DETECTED.map(([tag]) => tag)) + for (const tag of tags) expect(resolveCodeLanguage(tag), tag).not.toBeNull() + }) + + it('reports the relevance it decided on', () => { + const rust = DETECTED.find(([, label]) => label === 'rust')![2] + expect(detectCodeLanguage(rust)?.relevance).toBeGreaterThanOrEqual(MIN_RELEVANCE) + }) +}) + +describe('size and cost', () => { + it('scores only the start of a long block, so a big file costs what a page does', () => { + const python = DETECTED.find(([, label]) => label === 'python')![2] + let long = '' + while (long.length < MAX_SAMPLE_CHARS * 8) long += `${python}\n\n` + const started = performance.now() + expect(tagOf(long)).toBe('python') + expect(performance.now() - started).toBeLessThan(500) + }) + + it('never guesses at a block over the cap', () => { + const python = DETECTED.find(([, label]) => label === 'python')![2] + let huge = '' + while (huge.length <= MAX_DETECT_CHARS) huge += `${python}\n\n` + expect(detectCodeLanguage(huge)).toBeNull() + }) + + it('answers from its cache once a text has been seen', () => { + const text = 'def cached():\n return "a fresh text the cache has not seen"\n\nprint(cached())' + expect(cachedCodeLanguage(text)).toBeUndefined() + const first = detectCodeLanguage(text) + expect(cachedCodeLanguage(text)).toEqual(first) + }) + + it('compiles its grammars one at a time ahead of the first guess', () => { + let steps = 0 + while (warmCodeLanguageDetector()) steps++ + expect(steps).toBeLessThan(DETECTABLE_LANGUAGES.length) + expect(warmCodeLanguageDetector()).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/code-language-detect.ts b/packages/app-core/src/lib/code-language-detect.ts new file mode 100644 index 000000000..8e8add647 --- /dev/null +++ b/packages/app-core/src/lib/code-language-detect.ts @@ -0,0 +1,321 @@ +/** + * Guess the language of a code block that names none (#1014), for the editor's + * "python · detected" label and highlighting, and for the reading view, so the + * two surfaces agree on every block. + * + * highlight.js scores the text in each language of a small subset, the ones + * people paste into notes and that the editor can also highlight, and the + * best score wins only when it is a confident one: + * + * - The block has at least MIN_LINES lines and MIN_CHARS characters. A one-liner + * is too little to tell `x = 1` in Python from the same line in Ruby or INI. + * - The winner's relevance reaches MIN_RELEVANCE. Below it, prose and logs win + * on accidents: a paragraph scores 6 as CSS because "a", "code" and "line" + * are HTML tag names a CSS selector may use. + * - The text shows a hallmark of the winner (`fn` or `::` for Rust, `end` for + * Ruby, a `{ prop: value; }` rule for CSS). Relevance only adds up, so a + * long enough paragraph clears any threshold as CSS; a hallmark is a fact + * about the text that a paragraph does not have. A winner without one + * yields to the next language that clears the bar. + * - No language of another family (JavaScript and TypeScript are one, C and + * C++ another) ties it. A tie between Java and C# is not a guess. + * + * The numbers were tuned on real snippets in code-language-detect.test.ts. + * Only the first MAX_SAMPLE_CHARS are scored, and a block over + * MAX_DETECT_CHARS is never guessed at, so a pasted log costs nothing. + */ +import HighlightJS from 'highlight.js/lib/core' +import bash from 'highlight.js/lib/languages/bash' +import c from 'highlight.js/lib/languages/c' +import cpp from 'highlight.js/lib/languages/cpp' +import csharp from 'highlight.js/lib/languages/csharp' +import css from 'highlight.js/lib/languages/css' +import diff from 'highlight.js/lib/languages/diff' +import go from 'highlight.js/lib/languages/go' +import ini from 'highlight.js/lib/languages/ini' +import java from 'highlight.js/lib/languages/java' +import javascript from 'highlight.js/lib/languages/javascript' +import json from 'highlight.js/lib/languages/json' +import kotlin from 'highlight.js/lib/languages/kotlin' +import php from 'highlight.js/lib/languages/php' +import python from 'highlight.js/lib/languages/python' +import ruby from 'highlight.js/lib/languages/ruby' +import rust from 'highlight.js/lib/languages/rust' +import scss from 'highlight.js/lib/languages/scss' +import sql from 'highlight.js/lib/languages/sql' +import swift from 'highlight.js/lib/languages/swift' +import typescript from 'highlight.js/lib/languages/typescript' +import xml from 'highlight.js/lib/languages/xml' +import yaml from 'highlight.js/lib/languages/yaml' +import type { LanguageFn } from 'highlight.js' + +export const MIN_LINES = 2 +export const MIN_CHARS = 24 +export const MIN_RELEVANCE = 8 +export const MIN_MARGIN = 2 +export const MAX_SAMPLE_CHARS = 2_000 +export const MAX_DETECT_CHARS = 50_000 + +export interface DetectedCodeLanguage { + /** The fence tag the guess would write, as the editor and the reading view + * both read it (`html`, not highlight.js' `xml`). */ + tag: string + /** highlight.js' relevance for it, on the sample. */ + relevance: number +} + +/** + * The subset, in the order that breaks a tie inside a family. Every name is + * one lowlight's `common` set registers too, so the reading view can + * highlight what the editor guessed, and every tag it maps to is one the + * editor highlights (cm-code-languages.ts). + */ +const SUBSET: ReadonlyArray = [ + ['javascript', javascript], + ['typescript', typescript], + ['python', python], + ['bash', bash], + ['json', json], + ['yaml', yaml], + ['sql', sql], + ['xml', xml], + ['css', css], + ['scss', scss], + ['go', go], + ['rust', rust], + ['java', java], + ['c', c], + ['cpp', cpp], + ['csharp', csharp], + ['php', php], + ['ruby', ruby], + ['kotlin', kotlin], + ['swift', swift], + ['diff', diff], + ['ini', ini] +] + +export const DETECTABLE_LANGUAGES: readonly string[] = SUBSET.map(([name]) => name) + +const FAMILY: Readonly> = { + javascript: 'js', + typescript: 'js', + json: 'js', + c: 'c', + cpp: 'c', + css: 'css', + scss: 'css' +} + +const familyOf = (name: string): string => FAMILY[name] ?? name + +const KEY_LINE = /^(?:- )?["']?[A-Za-z_][\w .-]*["']?[ \t]*:(?:[ \t]|$)/m +const NESTED_KEY_LINE = /^[ \t]+(?:- )?["']?[A-Za-z_][\w.-]*["']?[ \t]*:(?:[ \t]|$)/m + +/** What the text has to show before a language may win. Necessary, never + * sufficient: relevance still has to clear the bar. */ +const HALLMARK: Readonly boolean>> = { + // Markup opens with a tag and says `class="…"`; Lua shares `function` and + // `return` and closes blocks with `end`; Scala shares `=>` and declares + // with `val` and `def`; pseudocode assigns with `<-`. + javascript: (t) => + !/^\s*/.test(t) && + !/^[ \t]*end\b|\bthen[ \t]*$|\blocal\s+\w+|\b(?:val|def)\s+\w+|\bcase\s+class\b|\s<-\s/m.test(t), + typescript: (t) => HALLMARK.javascript(t), + // Python never closes a block with `end`, and seldom ends two lines with + // a semicolon, which is how Perl and C read under its keywords. + python: (t) => + /^[ \t]*(?:def|class|import|from|if|elif|for|while|with|try|except|return|print|async|@\w)\b/m.test(t) && + !/^[ \t]*end\b/m.test(t) && + (t.match(/;[ \t]*$/gm)?.length ?? 0) < 2, + // A line that runs a command. A `$name` alone is PHP, Perl or GraphQL too. + bash: (t) => + /^#!|^[ \t]*(?:echo|cd|export|source|sudo|if \[|for \w+ in|while|case|function \w+|\w+\(\) *\{|set -|local|alias|git|npm|npx|brew|apt|apt-get|curl|wget|grep|sed|awk|ls|mkdir|rm|cp|mv|cat|chmod|chown|docker|make)\b/m.test(t), + json: (t) => /^\s*[[{]/.test(t) && /"[^"\n]*"\s*:/.test(t), + // A colon-keyed line alone is a chat log or a form; YAML also nests, or + // lists, or starts a document. + yaml: (t) => KEY_LINE.test(t) && (NESTED_KEY_LINE.test(t) || /^[ \t]*- \S|^---[ \t]*$/m.test(t)), + // A statement opens a line: "please select the items from the list" is + // a sentence with SQL's words in it. + sql: (t) => + /^[ \t]*(?:select\b[\s\S]*\bfrom|insert\s+into|update\s+\S+\s+set|delete\s+from|create\s+(?:or\s+replace\s+)?(?:temporary\s+)?(?:table|index|unique\s+index|view|database|schema|function|trigger)|alter\s+table|drop\s+(?:table|index|view|database)|with\s+\w+\s+as\s*\()\b/im.test(t), + xml: (t) => /<[A-Za-z!?][^>]*>/.test(t) && /<\/[A-Za-z][\w:.-]*\s*>|\/>|<\?xml| /\{/.test(t) && /(?:^|[{;])[ \t]*[\w-]+[ \t]*:[ \t]*[^;{}\n]+(?:;|$)/m.test(t), + scss: (t) => HALLMARK.css(t), + go: (t) => /\bfunc\b|:=|^package\s+\w+\s*$/m.test(t), + rust: (t) => /\bfn\s+\w+|\blet\s+(?:mut\s+)?\w+|\bimpl\b|\bpub\s+(?:fn|struct|enum|mod)\b|\w+!\(|:: + /\b(?:class|interface|enum|public|private|protected|static|void|import|package|extends|implements|final)\b/.test(t) && + /;[ \t]*$/m.test(t), + c: (t) => + /#include\b|\b(?:int|void|char|float|double|struct|typedef|unsigned|long|size_t)\b/.test(t) && /;[ \t]*$/m.test(t), + cpp: (t) => HALLMARK.c(t), + csharp: (t) => + /\b(?:using|namespace|class|interface|public|private|protected|void|var)\b/.test(t) && + /;[ \t]*$|\{\s*get;/m.test(t), + // Perl's variables carry a sigil too, declared with `my`. + php: (t) => + /<\?php|\$[A-Za-z_]\w*/.test(t) && /;[ \t]*$/m.test(t) && !/\bmy\s+[$@%]/.test(t), + // Elixir closes `do` blocks with `end` too; `defmodule` and `|>` are its own. + ruby: (t) => + /^[ \t]*(?:def|class|module|require|puts|attr_\w+|if|unless|do)\b|\bdo\s*(?:\|[^|]*\|)?\s*$/m.test(t) && + /^[ \t]*end\b/m.test(t) && + !/\bdefmodule\b|\|>|\bdo:/.test(t), + kotlin: (t) => /\bfun\s+\w+|\bval\s+\w+|\bdata\s+class\b/.test(t), + swift: (t) => /\bfunc\s+\w+|\bguard\s+let\b|\blet\s+\w+\s*[:=]|\bvar\s+\w+\s*[:=]|\bimport\s+(?:Foundation|SwiftUI|UIKit)\b/.test(t), + diff: (t) => /^(?:@@ |--- |\+\+\+ |diff --git )/m.test(t), + ini: (t) => /^[ \t]*\[[^\]\n]+\][ \t]*$/m.test(t) && /^[ \t]*[\w.-]+[ \t]*=/m.test(t) +} + +/** TypeScript-only syntax: annotations, interfaces, type aliases, generics. A + * JavaScript snippet scores as high in TypeScript, so without one of these + * the family's pick is JavaScript. */ +const TYPESCRIPT_SYNTAX = + /:\s*(?:string|number|boolean|void|unknown|any|never|bigint|object)\b|\binterface\s+[A-Z]\w*|\btype\s+[A-Z]\w*(?:<[^>]*>)?\s*=|\benum\s+[A-Z]\w*|\bas\s+const\b|\w<[A-Z]\w*(?:,\s*[A-Z]\w*)*>\(|\b(?:readonly|private|public|protected)\s+\w+\s*[:;=(]/ + +const CPP_SYNTAX = + /std::|\bnamespace\b|\btemplate\s*<|\bclass\s+\w+|\b(?:public|private|protected):|#include\s*<[a-z_]+>|\bnullptr\b|\bcout\b|\bauto\b|\w+::\w+/ + +const SCSS_SYNTAX = /\$[\w-]+\s*:|&[:.\w-]|@(?:mixin|include|use|extend|each|if)\b|\/\/ / + +const HTML_SYNTAX = + /<(?:!doctype\s+html|html|head|body|div|span|p|a|ul|ol|li|table|tr|td|section|main|nav|header|footer|article|script|style|img|h[1-6]|button|form|input|label|br)\b/i + +const TOML_SYNTAX = /^[ \t]*[\w.-]+[ \t]*=[ \t]*(?:"|'|\[|\{|true\b|false\b)/m + +function tagFor(name: string, text: string): string { + if (name === 'javascript' || name === 'typescript') { + return TYPESCRIPT_SYNTAX.test(text) ? 'typescript' : 'javascript' + } + if (name === 'c' || name === 'cpp') return CPP_SYNTAX.test(text) ? 'cpp' : 'c' + if (name === 'css' || name === 'scss') return SCSS_SYNTAX.test(text) ? 'scss' : 'css' + if (name === 'xml') return HTML_SYNTAX.test(text) ? 'html' : 'xml' + if (name === 'ini') return TOML_SYNTAX.test(text) ? 'toml' : 'ini' + return name +} + +/** What a first line alone settles: a shebang names its interpreter. */ +function shebangLanguage(text: string): string | null { + const match = /^#!\S*(?:\s+\S+)*/.exec(text) + if (!match) return null + const line = match[0] + if (/\b(?:bash|sh|zsh|ksh|dash)\b/.test(line)) return 'bash' + if (/\bpython[\d.]*\b/.test(line)) return 'python' + if (/\b(?:node|deno|bun)\b/.test(line)) return 'javascript' + if (/\bruby\b/.test(line)) return 'ruby' + if (/\bphp\b/.test(line)) return 'php' + return null +} + +function isJsonDocument(text: string): boolean { + if (!/^[[{]/.test(text)) return false + try { + const value: unknown = JSON.parse(text) + return typeof value === 'object' && value !== null + } catch { + return false + } +} + +let engine: ReturnType | null = null +const warmed = new Set() + +function hljs(): ReturnType { + if (engine) return engine + // An instance of our own: lowlight registers into its own as well, and + // neither should see the other's configuration. + engine = HighlightJS.newInstance() + for (const [name, language] of SUBSET) engine.registerLanguage(name, language) + return engine +} + +function relevance(name: string, sample: string): number { + const result = hljs().highlight(sample, { language: name, ignoreIllegals: false }) + return result.illegal ? -1 : result.relevance +} + +/** The first MAX_SAMPLE_CHARS of the text, cut back to a whole line. */ +function sampleOf(text: string): string { + if (text.length <= MAX_SAMPLE_CHARS) return text + const cut = text.lastIndexOf('\n', MAX_SAMPLE_CHARS) + return text.slice(0, cut > 0 ? cut : MAX_SAMPLE_CHARS) +} + +function countLines(text: string): number { + let lines = 0 + for (const line of text.split('\n')) if (line.trim() !== '') lines++ + return lines +} + +function guess(raw: string): DetectedCodeLanguage | null { + const text = raw.trim() + if (text.length < MIN_CHARS || text.length > MAX_DETECT_CHARS) return null + if (countLines(text) < MIN_LINES) return null + + const named = shebangLanguage(text) ?? (/^<\?php\b/.test(text) ? 'php' : null) + if (named) return { tag: named, relevance: Number.POSITIVE_INFINITY } + if (isJsonDocument(text)) return { tag: 'json', relevance: Number.POSITIVE_INFINITY } + + const sample = sampleOf(text) + const scored = DETECTABLE_LANGUAGES.map((name) => ({ name, relevance: relevance(name, sample) })) + // Stable: the subset's order breaks a tie, JavaScript before TypeScript. + .sort((a, b) => b.relevance - a.relevance) + const candidates = scored.filter( + (entry) => entry.relevance >= MIN_RELEVANCE && HALLMARK[entry.name](sample) + ) + const best = candidates[0] + if (!best) return null + const rival = candidates.find((entry) => familyOf(entry.name) !== familyOf(best.name)) + if (rival && best.relevance - rival.relevance < MIN_MARGIN) return null + return { tag: tagFor(best.name, sample), relevance: best.relevance } +} + +const CACHE_LIMIT = 256 +const cache = new Map() + +/** + * The confident guess for a code block's text, or null. Cached by the text, + * least recently used first out, so a note that is re-rendered or scrolled + * pays for each block once. + */ +export function detectCodeLanguage(text: string): DetectedCodeLanguage | null { + if (text.length > MAX_DETECT_CHARS) return null + if (cache.has(text)) { + const hit = cache.get(text) ?? null + cache.delete(text) + cache.set(text, hit) + return hit + } + const result = guess(text) + cache.set(text, result) + while (cache.size > CACHE_LIMIT) { + const oldest = cache.keys().next().value + if (oldest === undefined) break + cache.delete(oldest) + } + return result +} + +/** The answer already worked out for this text, without working it out: + * undefined when it has not been. */ +export function cachedCodeLanguage(text: string): DetectedCodeLanguage | null | undefined { + return cache.has(text) ? (cache.get(text) ?? null) : undefined +} + +/** + * Compile one more of the subset's grammars, ahead of the first guess. The + * first `highlight` call in a language compiles its regexes, about 70 ms for + * the whole subset at once; the editor spends that in idle slices instead. + * False once every grammar is compiled. + */ +export function warmCodeLanguageDetector(): boolean { + const instance = hljs() + const next = DETECTABLE_LANGUAGES.find((name) => !warmed.has(name)) + if (!next) return false + warmed.add(next) + instance.highlight('', { language: next, ignoreIllegals: true }) + return warmed.size < DETECTABLE_LANGUAGES.length +} diff --git a/packages/app-core/src/lib/code-language-menu.test.ts b/packages/app-core/src/lib/code-language-menu.test.ts new file mode 100644 index 000000000..9decdd124 --- /dev/null +++ b/packages/app-core/src/lib/code-language-menu.test.ts @@ -0,0 +1,199 @@ +// @vitest-environment jsdom + +import { history, undo } from '@codemirror/commands' +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { EditorSelection, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, describe, expect, it } from 'vitest' +import { contextMenuMatchRank } from '../components/ContextMenu' +import { resolveCodeLanguage } from './cm-code-languages' +import { + codeFenceAt, + codeFenceText, + codeLanguagePickerItems, + setCodeFenceLanguage +} from './code-language-menu' + +const views: EditorView[] = [] + +function editor(doc: string, caret = 0): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc, + selection: EditorSelection.cursor(caret), + extensions: [history(), markdown({ base: markdownLanguage, codeLanguages: resolveCodeLanguage })] + }) + }) + forceParsing(view, doc.length, 5000) + views.push(view) + return view +} + +afterEach(() => { + for (const view of views.splice(0)) { + view.dom.parentElement?.remove() + view.destroy() + } +}) + +function setAt(view: EditorView, pos: number, tag: string | null): boolean { + const fence = codeFenceAt(view.state, pos) + if (!fence) throw new Error('no fence') + return setCodeFenceLanguage(view, fence.from, tag) +} + +describe('setCodeFenceLanguage', () => { + it('writes the language after a bare fence and nothing else', () => { + const doc = 'Intro\n\n```\nprint("hi")\n```\n\nEnd.' + const view = editor(doc) + expect(setAt(view, doc.indexOf('print'), 'python')).toBe(true) + expect(view.state.doc.toString()).toBe('Intro\n\n```python\nprint("hi")\n```\n\nEnd.') + }) + + it('replaces a tilde fence language and keeps the fence length and attributes', () => { + const doc = '~~~~~ js title="a.js"\nlet a = 1\n~~~~~' + const view = editor(doc) + setAt(view, doc.indexOf('let'), 'typescript') + expect(view.state.doc.toString()).toBe('~~~~~ typescript title="a.js"\nlet a = 1\n~~~~~') + }) + + it('works on a fence in a quote, and on the last line of the note', () => { + const quoted = '> ```\n> SELECT 1;\n> ```' + const view = editor(quoted) + setAt(view, quoted.indexOf('SELECT'), 'sql') + expect(view.state.doc.toString()).toBe('> ```sql\n> SELECT 1;\n> ```') + + const last = 'Text\n\n```' + const open = editor(last) + setAt(open, last.length, 'go') + expect(open.state.doc.toString()).toBe('Text\n\n```go') + }) + + it('takes the language away with Text (plain)', () => { + const doc = '```python\nx = 1\n```' + const view = editor(doc) + setAt(view, doc.indexOf('x ='), null) + expect(view.state.doc.toString()).toBe('```\nx = 1\n```') + }) + + it('is one Undo step that restores the fence exactly', () => { + const doc = 'a\n\n```js\nconst a = 1\n```' + const view = editor(doc, doc.indexOf('const')) + // Typing just before, so the pick could fold into it if history let it. + view.dispatch({ changes: { from: 0, insert: 'b' }, userEvent: 'input.type' }) + setAt(view, view.state.doc.toString().indexOf('const'), 'typescript') + expect(view.state.doc.toString()).toBe('ba\n\n```typescript\nconst a = 1\n```') + undo(view) + expect(view.state.doc.toString()).toBe('ba\n\n```js\nconst a = 1\n```') + }) + + it('leaves the caret on the character it was on', () => { + const doc = '```\nconst value = 1\n```' + const caret = doc.indexOf('value') + const view = editor(doc, caret) + setAt(view, caret, 'javascript') + const head = view.state.selection.main.head + expect(view.state.doc.sliceString(head, head + 5)).toBe('value') + }) + + it('does nothing when the block already has the pick, or is gone', () => { + const doc = '```go\nfunc main() {}\n```' + const view = editor(doc) + expect(setAt(view, 6, 'go')).toBe(false) + expect(setCodeFenceLanguage(view, 3, 'rust')).toBe(false) + expect(view.state.doc.toString()).toBe(doc) + }) +}) + +describe('codeFenceAt', () => { + const doc = 'Para\n\n```py\nx = 1\ny = 2\n```\n\nAfter' + + it('finds the block from its fences and its body, and nothing outside', () => { + const view = editor(doc) + const { state } = view + for (const pos of [doc.indexOf('```'), doc.indexOf('y = 2'), doc.lastIndexOf('```') + 3]) { + const fence = codeFenceAt(state, pos) + expect(fence?.language).toBe('py') + expect(fence && codeFenceText(state, fence)).toBe('x = 1\ny = 2') + } + expect(codeFenceAt(state, doc.indexOf('Para'))).toBeNull() + expect(codeFenceAt(state, doc.indexOf('After'))).toBeNull() + }) + + it('reads a quoted block without its quote markers', () => { + const quoted = '> ```\n> a()\n> b()\n> ```' + const view = editor(quoted) + const fence = codeFenceAt(view.state, quoted.indexOf('b()')) + expect(fence?.language).toBeNull() + expect(fence && codeFenceText(view.state, fence)).toBe('a()\nb()') + }) +}) + +describe('codeLanguagePickerItems', () => { + it('checks the current language, by any of its tags', () => { + const rows = codeLanguagePickerItems('py', null) + expect(rows.filter((row) => row.checked).map((row) => row.label)).toEqual(['Python']) + expect(rows[0].label).toBe('Text (plain)') + }) + + it('leads an unlabeled block with its guess, then Text (plain) checked', () => { + const rows = codeLanguagePickerItems(null, 'python') + expect(rows[0]).toMatchObject({ label: 'Python (detected)', tag: 'python', checked: false, role: 'detected' }) + expect(rows[1]).toMatchObject({ label: 'Text (plain)', tag: null, checked: true }) + }) + + it('offers no guess for a block that names its language', () => { + expect(codeLanguagePickerItems('rust', 'python').some((row) => row.role === 'detected')).toBe(false) + }) + + it('keeps a language the list does not know, checked, so the current one shows', () => { + const rows = codeLanguagePickerItems('mermaid', null) + expect(rows[0]).toMatchObject({ label: 'mermaid', checked: true, role: 'current' }) + expect(rows.find((row) => row.role === 'text')?.checked).toBe(false) + }) + + it('counts text and its spellings as Text (plain)', () => { + const rows = codeLanguagePickerItems('txt', null) + expect(rows.find((row) => row.role === 'text')?.checked).toBe(true) + }) + + it('shows the tag a pick writes when it is not the name', () => { + const rows = codeLanguagePickerItems(null, null) + expect(rows.find((row) => row.label === 'C++')?.hint).toBe('cpp') + expect(rows.find((row) => row.label === 'Python')?.hint).toBeUndefined() + }) +}) + +describe('the find field over the picker', () => { + const rows = codeLanguagePickerItems(null, 'python') + const filter = (query: string): string[] => + rows + .map((row, index) => ({ row, index, rank: contextMenuMatchRank(row, query) })) + .filter((entry) => entry.rank !== null) + .sort((a, b) => a.rank! - b.rank! || a.index - b.index) + .map((entry) => entry.row.label) + + it('finds a language by an alias', () => { + expect(filter('golang')[0]).toBe('Go') + expect(filter('yml')[0]).toBe('YAML') + expect(filter('sh')[0]).toBe('Shell') + }) + + it('puts an exact name before names that merely contain it', () => { + expect(filter('c')[0]).toBe('C') + expect(filter('ts')[0]).toBe('TypeScript') + expect(filter('java')[0]).toBe('Java') + }) + + it('keeps the detected row reachable by its language', () => { + expect(filter('pyth').slice(0, 2)).toEqual(['Python (detected)', 'Python']) + }) + + it('finds Text (plain) by its words', () => { + expect(filter('plain')).toContain('Text (plain)') + }) +}) diff --git a/packages/app-core/src/lib/code-language-menu.ts b/packages/app-core/src/lib/code-language-menu.ts new file mode 100644 index 000000000..aa58d4205 --- /dev/null +++ b/packages/app-core/src/lib/code-language-menu.ts @@ -0,0 +1,167 @@ +/** + * The code block language picker (#1014): the label on a block, the "Set Code + * Block Language…" command and `Space l l` all land here, so a pick is the + * same one undoable change whichever way it came. + * + * The picker lives in the active EditorPane (it is that pane's state), so the + * label, the palette and the leader key ask for it with a window event + * (code-language-request.ts), the way the callout type menu is opened. + */ +import { isolateHistory } from '@codemirror/commands' +import { ensureSyntaxTree, syntaxTree } from '@codemirror/language' +import type { EditorState } from '@codemirror/state' +import type { EditorView } from '@codemirror/view' +import type { SyntaxNode } from '@lezer/common' +import { fenceLanguageEdit, fenceLanguageToken, isPlainTextFenceTag } from './code-fence-language' +import { + codeLanguageOptions, + findCodeLanguageOption, + type CodeLanguageOption +} from './code-language-catalog' + +export interface CodeFence { + /** Start of the opening fence characters (the FencedCode node). */ + from: number + to: number + /** Document offset just past the opening fence characters. */ + markEnd: number + /** The language word as written, or null. */ + language: string | null + /** The block's text, piece by piece, as the markdown parser hands it to a + * nested language: one piece per line inside a quote or a list item. */ + body: ReadonlyArray<{ from: number; to: number }> +} + +export function codeFenceFromNode(state: EditorState, node: SyntaxNode): CodeFence | null { + if (node.name !== 'FencedCode') return null + const mark = node.firstChild + if (!mark || mark.name !== 'CodeMark') return null + const line = state.doc.lineAt(mark.from) + const body: Array<{ from: number; to: number }> = [] + for (let child = mark.nextSibling; child; child = child.nextSibling) { + if (child.name === 'CodeText') body.push({ from: child.from, to: child.to }) + } + return { + from: node.from, + to: node.to, + markEnd: mark.to, + language: fenceLanguageToken(line.text, mark.to - line.from), + body + } +} + +function fencedCodeAround(state: EditorState, pos: number): SyntaxNode | null { + const tree = ensureSyntaxTree(state, pos, 200) ?? syntaxTree(state) + for (const side of [1, -1] as const) { + for (let node: SyntaxNode | null = tree.resolveInner(pos, side); node; node = node.parent) { + if (node.name === 'FencedCode') return node + } + } + return null +} + +/** The fenced block the position is in, its fence lines included, or null. */ +export function codeFenceAt(state: EditorState, pos: number): CodeFence | null { + const node = fencedCodeAround(state, pos) + if (node) return codeFenceFromNode(state, node) + // The caret at the very end of a fence line can sit just past the node. + const line = state.doc.lineAt(pos) + const inLine = fencedCodeAround(state, line.from + (line.text.length - line.text.trimStart().length)) + return inLine ? codeFenceFromNode(state, inLine) : null +} + +/** The block whose opening fence starts at `from`, or null once it is gone. */ +export function codeFenceStartingAt(state: EditorState, from: number): CodeFence | null { + if (from < 0 || from > state.doc.length) return null + const fence = codeFenceAt(state, from) + return fence && fence.from === from ? fence : null +} + +/** The block's text without its fences (and without a quote's `>` markers). */ +export function codeFenceText(state: EditorState, fence: CodeFence): string { + return fence.body.map((piece) => state.doc.sliceString(piece.from, piece.to)).join('') +} + +/** + * Make `tag` the language of the block whose fence starts at `fenceFrom`, or + * take its language away with null. Rewrites only the fence's info string, as + * one Undo step, and leaves the selection where it was. False when there is + * no such block any more, or nothing to change. + */ +export function setCodeFenceLanguage(view: EditorView, fenceFrom: number, tag: string | null): boolean { + const { state } = view + const fence = codeFenceStartingAt(state, fenceFrom) + if (!fence) return false + const line = state.doc.lineAt(fence.markEnd) + const edit = fenceLanguageEdit(line.text, fence.markEnd - line.from, tag) + if (!edit) return false + view.dispatch({ + changes: { from: line.from + edit.from, to: line.from + edit.to, insert: edit.insert }, + // Its own history event, so one Undo takes back exactly this and typing + // right after it never folds into it. + annotations: isolateHistory.of('full'), + userEvent: 'input.code-language' + }) + return true +} + +export interface CodeLanguagePick { + label: string + /** The tag a pick writes, when it is not the label lowercased. */ + hint?: string + /** What the find field also matches: the tag and its aliases. */ + keywords: string[] + checked: boolean + /** The tag the pick writes; null takes the language away. */ + tag: string | null + role: 'detected' | 'current' | 'text' | 'language' +} + +/** + * The picker's rows, in order: the guess for an unlabeled block ("Python + * (detected)"), a row for a language the list does not know (so the current + * one is always checked), "Text (plain)", then every language by name. + */ +export function codeLanguagePickerItems( + current: string | null, + detected: string | null, + options: readonly CodeLanguageOption[] = codeLanguageOptions() +): CodeLanguagePick[] { + const rows: CodeLanguagePick[] = [] + const currentKey = current?.trim().toLowerCase() || null + const plain = currentKey === null || isPlainTextFenceTag(currentKey) + const currentOption = currentKey && !plain ? findCodeLanguageOption(currentKey, options) : null + + if (currentKey === null && detected) { + const option = findCodeLanguageOption(detected, options) + rows.push({ + label: `${option?.name ?? detected} (detected)`, + hint: detected, + keywords: [detected, ...(option?.aliases ?? []), 'detected', 'guess'], + checked: false, + tag: detected, + role: 'detected' + }) + } + if (currentKey && !plain && !currentOption) { + rows.push({ label: current!.trim(), keywords: [], checked: true, tag: current!.trim(), role: 'current' }) + } + rows.push({ + label: 'Text (plain)', + keywords: ['text', 'plain', 'plaintext', 'txt', 'none', 'no language'], + checked: plain, + tag: null, + role: 'text' + }) + for (const option of options) { + rows.push({ + label: option.name, + hint: option.tag === option.name.toLowerCase() ? undefined : option.tag, + keywords: [option.tag, ...option.aliases], + checked: option === currentOption, + tag: option.tag, + role: 'language' + }) + } + return rows +} diff --git a/packages/app-core/src/lib/code-language-request.ts b/packages/app-core/src/lib/code-language-request.ts new file mode 100644 index 000000000..ff4e5cbe7 --- /dev/null +++ b/packages/app-core/src/lib/code-language-request.ts @@ -0,0 +1,24 @@ +/** + * How the code block label, the "Set Code Block Language…" command and + * `Space l l` ask the active EditorPane for the code block language picker + * (#1014). Its own module, with nothing heavy behind it: the command list is + * on the boot path, and the picker's language list (language-data) is not. + */ +import type { EditorView } from '@codemirror/view' + +export const ZEN_OPEN_CODE_LANGUAGE_PICKER_EVENT = 'zen:open-code-language-picker' + +export interface CodeLanguagePickerRequest { + /** The editor whose block it is. Omitted: the active editor's caret. */ + view?: EditorView + /** Where the block's opening fence starts. */ + fenceFrom?: number + /** The label that was clicked, to open the picker under it. */ + anchor?: DOMRect +} + +/** Open the code block language picker: for a block's label, or, with no + * detail, at the caret of the active editor. */ +export function requestCodeLanguagePicker(detail?: CodeLanguagePickerRequest): void { + window.dispatchEvent(new CustomEvent(ZEN_OPEN_CODE_LANGUAGE_PICKER_EVENT, { detail })) +} diff --git a/packages/app-core/src/lib/command-keymaps.ts b/packages/app-core/src/lib/command-keymaps.ts index f25aad562..932e3a5b6 100644 --- a/packages/app-core/src/lib/command-keymaps.ts +++ b/packages/app-core/src/lib/command-keymaps.ts @@ -163,6 +163,8 @@ const LEADER_SUBGROUP_IDS: ReadonlySet = new Set([ 'vim.leaderTableToDatabase', 'vim.leaderNoteCover', 'vim.leaderNoteCoverUnsplash', + 'vim.leaderCalloutType', + 'vim.leaderCodeLanguage', 'vim.leaderSearchVaultText' ]) diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index e865ddc48..363ee98e2 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -25,6 +25,7 @@ import { buildMoveNotePrompt, moveNoteVocabulary, parseMoveNoteTarget } from './ import { focusPaneInDirection } from './pane-nav' import { focusSidebarPanel } from './sidebar-focus' import { runSidebarNotePlan, sidebarCommandPlan } from './sidebar-note-actions' +import { openContextGallery } from './folder-gallery-open' import { findLeaf } from './pane-layout' import { getTabStepTarget, stepActiveTab } from './buffer-navigation' import { requestPaneMode } from './pane-mode' @@ -43,6 +44,8 @@ import { import { harperEditorConfig, harperSupported } from './harper-runtime' import { reflowParagraph } from './cm-reflow' import { convertTableToDatabase } from './table-to-database' +import { removeCalloutAtCursor, requestCalloutTypeMenu } from './callout-menu' +import { requestCodeLanguagePicker } from './code-language-request' import { activeNoteCover, coverNotePath, @@ -54,7 +57,13 @@ import { } from './note-cover-actions' import { unsplashSearchAvailable } from './unsplash-search' import { openUnsplashKeySettings, unsplashKeySupported } from './unsplash-key' -import { csvPathFromDatabaseTab } from '@shared/databases' +import { csvPathFromDatabaseTab, type DbView } from '@shared/databases' +import { + databaseCsvPathForTab, + requestDatabaseViewCommand, + type DatabaseViewCommand +} from './database-view-commands' +import { viewHasPicks } from './database-cells' import { canRenameVault, renameVaultWithPrompt } from './rename-vault' import { promptImageWidth } from './image-resize' import { copyLinkAtCursor } from './link-copy' @@ -783,6 +792,19 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma const s = getState() return findLeaf(s.paneLayout, s.activePaneId) } + // The database the active pane shows (as a database tab or a .csv asset + // tab), with its active view, once it has loaded. + const activeDatabaseView = (): { csvPath: string; view: DbView } | null => { + const csvPath = databaseCsvPathForTab(getActiveLeaf()?.activeTab) + const doc = csvPath ? getState().databases[csvPath] : undefined + if (!csvPath || !doc) return null + const view = doc.views.find((v) => v.id === doc.activeViewId) ?? doc.views[0] + return view ? { csvPath, view } : null + } + const runDatabaseViewCommand = (command: DatabaseViewCommand): void => { + const active = activeDatabaseView() + if (active) requestDatabaseViewCommand(active.csvPath, command) + } const getActiveTabContext = () => { const leaf = getActiveLeaf() if (!leaf?.activeTab) return null @@ -1289,6 +1311,53 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma if (csvPath) await getState().createAllRecordPages(csvPath) } }, + // The open database's views (charles b.'s saved views). Each acts on the + // active pane's database and its active view, and lists only where it + // can do something there. + { + id: 'database.columns', + title: 'Choose Database Columns', + category: 'Database', + shortcut: getState().vimMode ? 'c' : undefined, + keywords: 'database base table columns fields show hide visible choose pick view', + when: () => activeDatabaseView()?.view.type === 'table', + run: () => runDatabaseViewCommand('columns') + }, + { + id: 'database.new-table-view', + title: 'New Table View', + category: 'Database', + keywords: 'database base view table add create new saved columns', + when: () => !!activeDatabaseView(), + run: () => runDatabaseViewCommand('new-table-view') + }, + { + id: 'database.duplicate-view', + title: 'Duplicate View', + category: 'Database', + keywords: 'database base view copy duplicate clone saved table board', + when: () => !!activeDatabaseView(), + run: () => runDatabaseViewCommand('duplicate-view') + }, + { + id: 'database.pick-selected', + title: 'Show Only Selected Rows', + category: 'Database', + keywords: 'database base rows records pick picked picks selected selection only view subset', + when: () => activeDatabaseView()?.view.type === 'table', + run: () => runDatabaseViewCommand('pick-selected') + }, + { + id: 'database.show-all-rows', + title: 'Show All Rows', + category: 'Database', + keywords: 'database base rows records picks picked clear reset all every view', + when: () => { + const active = activeDatabaseView() + return !!active && viewHasPicks(active.view) + }, + run: () => runDatabaseViewCommand('show-all-rows') + }, { id: 'table.to-database', title: 'Convert Table to Database…', @@ -1307,6 +1376,53 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma await convertTableToDatabase(view) } }, + // Callouts from the keyboard (#1016): the type menu the right-click + // offers, at the caret. Listed whenever a note is open, like the table + // conversion above: a caret in code says why instead of hiding the row. + { + id: 'editor.callout-type', + title: 'Set Callout Type…', + category: 'Editor', + shortcut: vimOnly( + chord('vim.leaderPrefix', 'vim.leaderNoteActions', 'vim.leaderCalloutType') + ), + keywords: + 'callout admonition alert box turn into convert change type note info tip warning danger error success quote blockquote', + when: hasEditorNote, + run: () => { + requestCalloutTypeMenu() + } + }, + { + id: 'editor.remove-callout', + title: 'Remove Callout', + category: 'Editor', + keywords: 'callout admonition alert unwrap plain text blockquote delete remove', + when: hasEditorNote, + run: () => { + const view = getState().editorViewRef + if (!view) return + removeCalloutAtCursor(view) + if (!hostOpensNotesToRead()) view.focus() + } + }, + // The code block language picker (#1014), for the block at the caret: the + // same picker the block's label opens. Listed whenever a note is open; + // outside a code block it says so. + { + id: 'editor.code-language', + title: 'Set Code Block Language…', + category: 'Editor', + shortcut: vimOnly( + chord('vim.leaderPrefix', 'vim.leaderNoteActions', 'vim.leaderCodeLanguage') + ), + keywords: + 'code block fence fenced language syntax highlight highlighting detect detected guess picker typescript python change set', + when: hasEditorNote, + run: () => { + requestCodeLanguagePicker() + } + }, { id: 'editor.copy-link', title: 'Copy Link Under Cursor', @@ -1566,6 +1682,17 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma when: () => !isUnresolvedLinksViewActive(getState()), run: () => getState().openUnresolvedLinksView() }, + { + id: 'view.folder-gallery', + title: 'Open Folder Gallery', + category: 'View', + shortcut: leaderShortcut('vim.leaderFolderGallery'), + keywords: + 'gallery folder covers cards grid thumbnails banner images pictures board notes browse', + // The folder under the sidebar cursor while the sidebar has focus, + // otherwise the open note's folder (lib/folder-gallery-open.ts). + run: () => openContextGallery() + }, { id: 'view.tags', unavailableReason: () => 'The Tags view is already open', diff --git a/packages/app-core/src/lib/custom-code-languages.ts b/packages/app-core/src/lib/custom-code-languages.ts index 14a2d77d5..e64a620dc 100644 --- a/packages/app-core/src/lib/custom-code-languages.ts +++ b/packages/app-core/src/lib/custom-code-languages.ts @@ -94,6 +94,24 @@ class CustomCodeLanguageRegistry { return this.byAlias.get(normalizeCodeFenceTag(tag)) ?? null; } + /** Every installed language that highlights right now, once each, with the + * fence tags it answers to (the code block language picker lists them). */ + languages(): Array<{ id: string; name: string; aliases: string[] }> { + const byId = new Map(); + for (const [alias, loaded] of this.byAlias) { + const { id, name, aliases } = loaded.definition; + let entry = byId.get(id); + if (!entry) { + // The definition's own order first, so its first alias is the tag a + // pick writes; then any alias the registry holds beyond those. + entry = { id, name, aliases: aliases.map(normalizeCodeFenceTag) }; + byId.set(id, entry); + } + if (!entry.aliases.includes(alias)) entry.aliases.push(alias); + } + return [...byId.values()]; + } + tokenize(tag: string, source: string): CodeHighlightToken[] { const loaded = this.resolve(tag); if (!loaded || !this.engine || !source || source.length > MAX_BLOCK_CHARS) diff --git a/packages/app-core/src/lib/database-cells.test.ts b/packages/app-core/src/lib/database-cells.test.ts index 8e26515f4..5ceb010ea 100644 --- a/packages/app-core/src/lib/database-cells.test.ts +++ b/packages/app-core/src/lib/database-cells.test.ts @@ -3,10 +3,23 @@ import type { DatabaseDoc } from '@shared/databases' import { FRONTMATTER_BLOCK_RE, parseFrontmatterFields } from '@shared/frontmatter' import { parseFrontmatter } from '@shared/template-files' import { + addRowInView, + addView, + clearViewPicks, composePageBody, + duplicateView, + hideAllColumns, + isLastVisibleColumn, moveColumn, moveColumnToField, - setFieldOptionsSource + pickedRowCount, + removeFromViewPicks, + setColumnHidden, + setFieldOptionsSource, + setViewPicks, + showAllColumns, + viewColumnFields, + visibleColumnIds } from './database-cells' // Minimal doc with four fields; the reorder helpers only read `fields` + `views`. @@ -112,3 +125,193 @@ describe('setFieldOptionsSource (#500)', () => { expect('optionsSource' in (cleared.fields.find((f) => f.id === 'f_b') ?? {})).toBe(false) }) }) + +// A database the way the app writes one: a schema-hidden id column, four +// user columns, three rows, a table view and a board view. +function makeViewsDoc(): DatabaseDoc { + return { + path: 'db.base/data.csv', + title: 'db', + version: 1, + idFieldId: 'f_id', + activeViewId: 'v1', + fields: [ + { id: 'f_id', name: 'id', type: 'text', hidden: true }, + { id: 'f_name', name: 'Name', type: 'text' }, + { id: 'f_status', name: 'Status', type: 'select', options: [{ id: 'o1', value: 'todo' }] }, + { id: 'f_n', name: 'N', type: 'number' }, + { id: 'f_due', name: 'Due', type: 'date' } + ], + views: [ + { + id: 'v1', + name: 'Table', + type: 'table', + filters: [{ fieldId: 'f_status', op: 'is', value: 'todo' }], + filterConjunction: 'or', + sorts: [{ fieldId: 'f_n', direction: 'desc' }], + columnOrder: ['f_id', 'f_due', 'f_name', 'f_status', 'f_n'], + hiddenFieldIds: ['f_id'], + columnWidths: { f_name: 240 } + }, + { + id: 'v2', + name: 'Board', + type: 'board', + filters: [], + sorts: [], + groupByFieldId: 'f_status', + boardColumnOrder: ['todo'], + cardFieldIds: ['f_n'] + } + ], + rows: [ + { id: 'r1', cells: { f_id: 'r1', f_name: 'One' } }, + { id: 'r2', cells: { f_id: 'r2', f_name: 'Two' } }, + { id: 'r3', cells: { f_id: 'r3', f_name: 'Three' } } + ] + } as unknown as DatabaseDoc +} + +const viewOf = (doc: DatabaseDoc, id: string) => doc.views.find((v) => v.id === id)! + +describe('columns a view shows', () => { + it('lists the user columns in the view order, never the hidden id', () => { + const doc = makeViewsDoc() + expect(viewColumnFields(doc, viewOf(doc, 'v1')).map((f) => f.id)).toEqual([ + 'f_due', + 'f_name', + 'f_status', + 'f_n' + ]) + }) + + it('hides and shows a column in one view only', () => { + const doc = duplicateView(makeViewsDoc(), 'v1') + const copyId = doc.activeViewId + const hidden = setColumnHidden(doc, 'v1', 'f_due', true) + expect(visibleColumnIds(hidden, viewOf(hidden, 'v1'))).toEqual(['f_name', 'f_status', 'f_n']) + expect(visibleColumnIds(hidden, viewOf(hidden, copyId))).toEqual([ + 'f_due', + 'f_name', + 'f_status', + 'f_n' + ]) + const shown = setColumnHidden(hidden, 'v1', 'f_due', false) + expect(viewOf(shown, 'v1').hiddenFieldIds).toEqual(['f_id']) + // Hiding twice or showing a visible column changes nothing. + expect(setColumnHidden(hidden, 'v1', 'f_due', true)).toBe(hidden) + expect(setColumnHidden(doc, 'v1', 'f_due', false)).toBe(doc) + }) + + it('never hides the last visible column', () => { + let doc = makeViewsDoc() + for (const id of ['f_due', 'f_name', 'f_status']) doc = setColumnHidden(doc, 'v1', id, true) + expect(visibleColumnIds(doc, viewOf(doc, 'v1'))).toEqual(['f_n']) + expect(isLastVisibleColumn(doc, viewOf(doc, 'v1'), 'f_n')).toBe(true) + expect(setColumnHidden(doc, 'v1', 'f_n', true)).toBe(doc) + }) + + it('leaves the id column and board views alone', () => { + const doc = makeViewsDoc() + expect(setColumnHidden(doc, 'v1', 'f_id', false)).toBe(doc) + expect(setColumnHidden(doc, 'v2', 'f_n', true)).toBe(doc) + }) + + it('shows all, and hides all but the title column', () => { + const doc = makeViewsDoc() + const none = hideAllColumns(doc, 'v1') + expect(visibleColumnIds(none, viewOf(none, 'v1'))).toEqual(['f_name']) + expect(viewOf(none, 'v1').hiddenFieldIds).toContain('f_id') + const all = showAllColumns(none, 'v1') + expect(visibleColumnIds(all, viewOf(all, 'v1'))).toEqual(['f_due', 'f_name', 'f_status', 'f_n']) + expect(viewOf(all, 'v1').hiddenFieldIds).toEqual(['f_id']) + }) +}) + +describe('more views', () => { + it('names a second table view apart from the first', () => { + const doc = addView(makeViewsDoc(), 'table') + const added = doc.views[doc.views.length - 1] + expect(added.name).toBe('Table 2') + expect(added.type).toBe('table') + expect(doc.activeViewId).toBe(added.id) + expect(added.hiddenFieldIds).toEqual(['f_id']) + }) + + it('duplicates every setting of a table view, right after it', () => { + const base = setViewPicks(setColumnHidden(makeViewsDoc(), 'v1', 'f_due', true), 'v1', ['r2']) + const doc = duplicateView(base, 'v1') + expect(doc.views.map((v) => v.name)).toEqual(['Table', 'Table copy', 'Board']) + const copy = doc.views[1] + expect(doc.activeViewId).toBe(copy.id) + expect(copy.id).not.toBe('v1') + const { id: _a, name: _b, ...copied } = copy + const { id: _c, name: _d, ...source } = viewOf(base, 'v1') + void [_a, _b, _c, _d] + expect(copied).toEqual(source) + // Deep: editing the copy cannot reach the source. + copy.filters[0].value = 'changed' + copy.hiddenFieldIds!.push('f_n') + expect(viewOf(doc, 'v1').filters[0].value).toBe('todo') + expect(viewOf(doc, 'v1').hiddenFieldIds).toEqual(['f_id', 'f_due']) + }) + + it('duplicates the board settings and picks of a board view', () => { + const base = setViewPicks(makeViewsDoc(), 'v2', ['r3']) + const doc = duplicateView(base, 'v2') + const copy = doc.views[2] + expect(copy).toMatchObject({ + name: 'Board copy', + type: 'board', + groupByFieldId: 'f_status', + boardColumnOrder: ['todo'], + cardFieldIds: ['f_n'], + pickedRowIds: ['r3'] + }) + expect(duplicateView(doc, 'v2').views.map((v) => v.name)).toContain('Board copy 2') + }) +}) + +describe('picked rows', () => { + it('saves picks in row order, existing rows only', () => { + const doc = setViewPicks(makeViewsDoc(), 'v1', ['r3', 'gone', 'r1', 'r3']) + expect(viewOf(doc, 'v1').pickedRowIds).toEqual(['r1', 'r3']) + expect(viewOf(doc, 'v2').pickedRowIds).toBeUndefined() + }) + + it('counts only the picks whose row still exists', () => { + const doc = makeViewsDoc() + const stale = { ...viewOf(doc, 'v1'), pickedRowIds: ['r2', 'deleted'] } + expect(pickedRowCount(doc, stale)).toBe(1) + expect(pickedRowCount(doc, viewOf(doc, 'v1'))).toBe(0) + }) + + it('removes picks, keeps an empty list after the last, and clears', () => { + const doc = setViewPicks(makeViewsDoc(), 'v1', ['r1', 'r2']) + const one = removeFromViewPicks(doc, 'v1', ['r1']) + expect(viewOf(one, 'v1').pickedRowIds).toEqual(['r2']) + const none = removeFromViewPicks(one, 'v1', ['r2']) + expect(viewOf(none, 'v1').pickedRowIds).toEqual([]) + const cleared = clearViewPicks(none, 'v1') + expect('pickedRowIds' in viewOf(cleared, 'v1')).toBe(false) + expect(clearViewPicks(cleared, 'v1')).toBe(cleared) + }) + + it('a row added in a picks view joins its picks', () => { + const doc = setViewPicks(makeViewsDoc(), 'v1', ['r2']) + const added = addRowInView(doc, 'v1') + expect(added.picksChanged).toBe(true) + expect(added.doc.rows).toHaveLength(4) + expect(viewOf(added.doc, 'v1').pickedRowIds).toEqual(['r2', added.rowId]) + expect(added.doc.rows[3].id).toBe(added.rowId) + }) + + it('a row added in a view without picks touches only the rows', () => { + const doc = makeViewsDoc() + const added = addRowInView(doc, 'v1') + expect(added.picksChanged).toBe(false) + expect(added.doc.views).toBe(doc.views) + expect(added.doc.rows).toHaveLength(4) + }) +}) diff --git a/packages/app-core/src/lib/database-cells.ts b/packages/app-core/src/lib/database-cells.ts index f4b6fd118..a07f30723 100644 --- a/packages/app-core/src/lib/database-cells.ts +++ b/packages/app-core/src/lib/database-cells.ts @@ -36,6 +36,7 @@ export { setCell } from '@shared/database-records' export type { RecordPagePlan } from '@shared/database-records' +import { addRow as addRowDoc } from '@shared/database-records' const genId = defaultGenId @@ -121,6 +122,17 @@ export function deleteField(doc: DatabaseDoc, fieldId: string): DatabaseDoc { } } +/** A table view's full column order: the stored `columnOrder` with fields + * added since appended and ids of deleted fields dropped, so it always + * names every field of `doc` exactly once. */ +export function normalizedColumnOrder(doc: DatabaseDoc, view: DbView): string[] { + const fieldIds = doc.fields.map((f) => f.id) + const stored = view.columnOrder ?? fieldIds + return [...stored, ...fieldIds.filter((id) => !stored.includes(id))].filter((id) => + fieldIds.includes(id) + ) +} + /** Reorder a table view's columns by moving `fieldId` one *visible* step left or * right (#317). Works on the view's `columnOrder`, swapping with the nearest * visible neighbor so hidden columns don't absorb the move; a no-op at an edge @@ -134,11 +146,7 @@ export function moveColumn( ): DatabaseDoc { const view = doc.views.find((v) => v.id === viewId) if (!view || view.type !== 'table') return doc - const fieldIds = doc.fields.map((f) => f.id) - const stored = view.columnOrder ?? fieldIds - const order = [...stored, ...fieldIds.filter((id) => !stored.includes(id))].filter((id) => - fieldIds.includes(id) - ) + const order = normalizedColumnOrder(doc, view) const hidden = new Set(view.hiddenFieldIds ?? []) const visible = order.filter((id) => !hidden.has(id)) const vi = visible.indexOf(fieldId) @@ -164,11 +172,7 @@ export function moveColumnToField( if (fieldId === targetFieldId) return doc const view = doc.views.find((v) => v.id === viewId) if (!view || view.type !== 'table') return doc - const fieldIds = doc.fields.map((f) => f.id) - const stored = view.columnOrder ?? fieldIds - const order = [...stored, ...fieldIds.filter((id) => !stored.includes(id))].filter((id) => - fieldIds.includes(id) - ) + const order = normalizedColumnOrder(doc, view) if (!order.includes(fieldId) || !order.includes(targetFieldId)) return doc const without = order.filter((id) => id !== fieldId) without.splice(without.indexOf(targetFieldId), 0, fieldId) @@ -220,9 +224,24 @@ export function removeView(doc: DatabaseDoc, viewId: string): DatabaseDoc { return { ...doc, views, activeViewId } } +/** `base`, or `base 2`, `base 3`… when a view already goes by that name, so + * a database with several table views can tell its tabs apart. */ +function uniqueViewName(doc: DatabaseDoc, base: string): string { + const taken = new Set(doc.views.map((v) => v.name)) + if (!taken.has(base)) return base + let n = 2 + while (taken.has(`${base} ${n}`)) n++ + return `${base} ${n}` +} + export function addView(doc: DatabaseDoc, type: 'table' | 'board'): DatabaseDoc { const id = genId() - const base = { id, name: type === 'board' ? 'Board' : 'Table', filters: [], sorts: [] } + const base = { + id, + name: uniqueViewName(doc, type === 'board' ? 'Board' : 'Table'), + filters: [], + sorts: [] + } const view: DbView = type === 'board' ? { @@ -239,3 +258,166 @@ export function addView(doc: DatabaseDoc, type: 'table' | 'board'): DatabaseDoc } return { ...doc, views: [...doc.views, view], activeViewId: id } } + +/** + * A copy of a view, right after it in the tabs, made the active view. Every + * setting travels: filters and how they combine, sorts, column order, hidden + * columns and widths, the board's grouping, column order and card fields, and + * the picked rows. The copy is deep, so editing one view's filters can never + * reach into the other's. + */ +export function duplicateView(doc: DatabaseDoc, viewId: string): DatabaseDoc { + const index = doc.views.findIndex((v) => v.id === viewId) + if (index === -1) return doc + const source = doc.views[index] + const id = genId() + const copy: DbView = { + ...(JSON.parse(JSON.stringify(source)) as DbView), + id, + name: uniqueViewName(doc, `${source.name} copy`) + } + const views = [...doc.views.slice(0, index + 1), copy, ...doc.views.slice(index + 1)] + return { ...doc, views, activeViewId: id } +} + +// --- columns a view shows (→ updateDatabaseSchema) ---------------------- + +/** The columns a table view can show or hide, in its column order: every + * field except a field hidden by the schema itself (the row id). */ +export function viewColumnFields(doc: DatabaseDoc, view: DbView): DbField[] { + const byId = new Map(doc.fields.map((f) => [f.id, f])) + return normalizedColumnOrder(doc, view) + .map((id) => byId.get(id)) + .filter((f): f is DbField => !!f && !f.hidden) +} + +/** The ids of the columns a table view draws, in order. */ +export function visibleColumnIds(doc: DatabaseDoc, view: DbView): string[] { + const hidden = new Set(view.hiddenFieldIds ?? []) + return viewColumnFields(doc, view) + .filter((f) => !hidden.has(f.id)) + .map((f) => f.id) +} + +/** True when `fieldId` is the only column the view still draws, which the + * view must keep: a table with no columns has nothing to put a cursor on. */ +export function isLastVisibleColumn(doc: DatabaseDoc, view: DbView, fieldId: string): boolean { + const visible = visibleColumnIds(doc, view) + return visible.length === 1 && visible[0] === fieldId +} + +/** Show or hide one column in one table view (its `hiddenFieldIds`), so each + * saved view keeps its own columns. Hiding the last visible column is a + * no-op; so is any change to a board view or an unknown field. */ +export function setColumnHidden( + doc: DatabaseDoc, + viewId: string, + fieldId: string, + hidden: boolean +): DatabaseDoc { + const view = doc.views.find((v) => v.id === viewId) + if (!view || view.type !== 'table') return doc + if (!viewColumnFields(doc, view).some((f) => f.id === fieldId)) return doc + const current = view.hiddenFieldIds ?? [] + if (hidden) { + if (current.includes(fieldId) || isLastVisibleColumn(doc, view, fieldId)) return doc + return updateView(doc, viewId, { hiddenFieldIds: [...current, fieldId] }) + } + if (!current.includes(fieldId)) return doc + return updateView(doc, viewId, { hiddenFieldIds: current.filter((id) => id !== fieldId) }) +} + +/** Every column of a table view visible again (the schema-hidden row id + * stays out, as it always does). */ +export function showAllColumns(doc: DatabaseDoc, viewId: string): DatabaseDoc { + const view = doc.views.find((v) => v.id === viewId) + if (!view || view.type !== 'table') return doc + const userIds = new Set(viewColumnFields(doc, view).map((f) => f.id)) + const keep = (view.hiddenFieldIds ?? []).filter((id) => !userIds.has(id)) + return updateView(doc, viewId, { hiddenFieldIds: keep }) +} + +/** Hide every column of a table view but one, the start of "pick the few I + * want": the record's title column stays (it carries the page icon), or the + * first column when the title is not one of the view's columns. */ +export function hideAllColumns(doc: DatabaseDoc, viewId: string): DatabaseDoc { + const view = doc.views.find((v) => v.id === viewId) + if (!view || view.type !== 'table') return doc + const columns = viewColumnFields(doc, view) + if (columns.length === 0) return doc + const titleId = doc.fields.find((f) => f.id !== doc.idFieldId)?.id + const keepId = columns.some((f) => f.id === titleId) ? titleId : columns[0].id + const userIds = new Set(columns.map((f) => f.id)) + const schemaHidden = (view.hiddenFieldIds ?? []).filter((id) => !userIds.has(id)) + return updateView(doc, viewId, { + hiddenFieldIds: [...schemaHidden, ...columns.filter((f) => f.id !== keepId).map((f) => f.id)] + }) +} + +// --- picked rows (→ updateDatabaseSchema) -------------------------------- + +/** True when the view shows only its picked rows. */ +export function viewHasPicks(view: DbView): boolean { + return Array.isArray(view.pickedRowIds) +} + +/** How many of the view's picks name a row that still exists. */ +export function pickedRowCount(doc: DatabaseDoc, view: DbView): number { + if (!view.pickedRowIds) return 0 + const picked = new Set(view.pickedRowIds) + return doc.rows.filter((r) => picked.has(r.id)).length +} + +/** Make `rowIds` the view's picks: only rows that exist, in the database's + * row order, each once. */ +export function setViewPicks(doc: DatabaseDoc, viewId: string, rowIds: Iterable): DatabaseDoc { + if (!doc.views.some((v) => v.id === viewId)) return doc + const wanted = new Set(rowIds) + const pickedRowIds = doc.rows.filter((r) => wanted.has(r.id)).map((r) => r.id) + return updateView(doc, viewId, { pickedRowIds }) +} + +/** Drop the view's picks, so it shows every row its filters let through. */ +export function clearViewPicks(doc: DatabaseDoc, viewId: string): DatabaseDoc { + const view = doc.views.find((v) => v.id === viewId) + if (!view || !viewHasPicks(view)) return doc + const { pickedRowIds: _drop, ...rest } = view + void _drop + return { ...doc, views: doc.views.map((v) => (v.id === viewId ? rest : v)) } +} + +/** Take rows out of the view's picks. Ids of deleted rows go at the same + * time; taking the last pick out leaves an empty list (0 picked rows), not + * every row, so the view never fills up behind the user's back. */ +export function removeFromViewPicks( + doc: DatabaseDoc, + viewId: string, + rowIds: Iterable +): DatabaseDoc { + const view = doc.views.find((v) => v.id === viewId) + if (!view || !view.pickedRowIds) return doc + const drop = new Set(rowIds) + const kept = new Set(view.pickedRowIds.filter((id) => !drop.has(id))) + return setViewPicks(doc, viewId, kept) +} + +/** + * Add an empty row while `viewId` is the view on screen. In a view with + * picks the new row joins them, or it would vanish the moment it was made; + * `picksChanged` then tells the caller the schema changed too (rows-only + * writes leave schema.json alone). + */ +export function addRowInView( + doc: DatabaseDoc, + viewId: string +): { doc: DatabaseDoc; rowId: string; picksChanged: boolean } { + const next = addRowDoc(doc) + const rowId = next.rows[next.rows.length - 1].id + const view = doc.views.find((v) => v.id === viewId) + if (!view || !view.pickedRowIds) return { doc: next, rowId, picksChanged: false } + return { + doc: updateView(next, viewId, { pickedRowIds: [...view.pickedRowIds, rowId] }), + rowId, + picksChanged: true + } +} diff --git a/packages/app-core/src/lib/database-grid-keys.test.ts b/packages/app-core/src/lib/database-grid-keys.test.ts new file mode 100644 index 000000000..39e820529 --- /dev/null +++ b/packages/app-core/src/lib/database-grid-keys.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { databaseGridKeyAction } from './database-grid-keys' + +// Every letter (and Vim symbol) the grid answers with Vim mode on. +const VIM_KEYS: Array<[string, string]> = [ + ['j', 'down'], + ['k', 'up'], + ['h', 'left'], + ['l', 'right'], + ['H', 'move-column-left'], + ['L', 'move-column-right'], + ['0', 'first-column'], + ['^', 'first-column'], + ['$', 'last-column'], + ['G', 'last-row'], + ['g', 'g'], + ['d', 'd'], + ['i', 'edit'], + ['x', 'select'], + ['o', 'open-page'], + ['O', 'create-pages'], + ['c', 'columns'], + ['m', 'menu'], + ['a', 'add-row'] +] + +// What stays with Vim mode off: the arrows, Enter, Space, Escape, the menu keys. +const UNIVERSAL_KEYS: Array<[{ key: string; shiftKey?: boolean }, string]> = [ + [{ key: 'ArrowDown' }, 'down'], + [{ key: 'ArrowUp' }, 'up'], + [{ key: 'ArrowLeft' }, 'left'], + [{ key: 'ArrowRight' }, 'right'], + [{ key: 'Enter' }, 'edit'], + [{ key: ' ' }, 'space'], + [{ key: 'Escape' }, 'escape'], + [{ key: 'ContextMenu' }, 'menu'], + [{ key: 'F10', shiftKey: true }, 'menu'] +] + +describe('databaseGridKeyAction: the grid keys and Vim mode', () => { + it('answers every grid letter with Vim mode on', () => { + for (const [key, action] of VIM_KEYS) { + expect(databaseGridKeyAction({ key, shiftKey: key !== key.toLowerCase() }, true), key).toBe(action) + } + }) + + it('answers no letter, digit or Vim symbol with Vim mode off', () => { + for (const [key] of VIM_KEYS) { + expect(databaseGridKeyAction({ key, shiftKey: key !== key.toLowerCase() }, false), key).toBeNull() + } + // Nor any other letter: typing never acts on the grid with Vim off. + for (const key of 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') { + expect(databaseGridKeyAction({ key }, false), key).toBeNull() + } + }) + + it('keeps the arrows, Enter, Space, Escape and the menu keys in both modes', () => { + for (const vim of [true, false]) { + for (const [event, action] of UNIVERSAL_KEYS) { + expect(databaseGridKeyAction(event, vim), `${event.key} vim=${vim}`).toBe(action) + } + } + }) + + it('leaves F10 without Shift, and every chord, to the app', () => { + expect(databaseGridKeyAction({ key: 'F10' }, true)).toBeNull() + for (const vim of [true, false]) { + expect(databaseGridKeyAction({ key: 'j', ctrlKey: true }, vim)).toBeNull() + expect(databaseGridKeyAction({ key: 'ArrowDown', metaKey: true }, vim)).toBeNull() + expect(databaseGridKeyAction({ key: 'Enter', altKey: true }, vim)).toBeNull() + } + }) + + it('does not take Delete or Backspace (row deletion has no confirm of its own)', () => { + for (const vim of [true, false]) { + expect(databaseGridKeyAction({ key: 'Delete' }, vim)).toBeNull() + expect(databaseGridKeyAction({ key: 'Backspace' }, vim)).toBeNull() + } + }) + + it('never resolves a key named like an object property', () => { + expect(databaseGridKeyAction({ key: 'constructor' }, true)).toBeNull() + expect(databaseGridKeyAction({ key: 'toString' }, false)).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/database-grid-keys.ts b/packages/app-core/src/lib/database-grid-keys.ts new file mode 100644 index 000000000..c2f085210 --- /dev/null +++ b/packages/app-core/src/lib/database-grid-keys.ts @@ -0,0 +1,89 @@ +/** + * Which keys the database grid (Table view) answers, and what each one does. + * + * The grid's letters are Vim motions and Vim verbs (h/j/k/l, H/L, 0 ^ $, + * gg/G, i, x, dd, a, o, O, c, m), so like every list in the app they are + * Vim mode's alone. With Vim mode off the grid keeps the keys every list + * keeps: the arrows move the cell cursor (ArrowUp past the first row + * reaches the column header, as k does), Enter edits the cell or renames + * the field on the header, Space selects the row (or toggles a checkbox + * cell), Escape clears and leaves, and Shift+F10 or the Context Menu key + * opens the row's menu (the column's on the header), as in the sidebar and + * the note list. A chord (Ctrl, Alt or Cmd held) is never the grid's. + * + * One function, so the gating can be tested apart from the component. + */ +export type DatabaseGridKeyAction = + | 'down' + | 'up' + | 'left' + | 'right' + | 'move-column-left' + | 'move-column-right' + | 'first-column' + | 'last-column' + | 'last-row' + | 'g' + | 'd' + | 'edit' + | 'space' + | 'select' + | 'open-page' + | 'create-pages' + | 'columns' + | 'menu' + | 'add-row' + | 'escape' + +export interface GridKey { + key: string + shiftKey?: boolean + metaKey?: boolean + ctrlKey?: boolean + altKey?: boolean +} + +/** Keys the grid answers in both modes. */ +const UNIVERSAL = new Map(Object.entries({ + ArrowDown: 'down', + ArrowUp: 'up', + ArrowLeft: 'left', + ArrowRight: 'right', + Enter: 'edit', + ' ': 'space', + Escape: 'escape', + ContextMenu: 'menu' +}) as Array<[string, DatabaseGridKeyAction]>) + +/** The grid's Vim keys: answered only with Vim mode on. */ +const VIM_ONLY = new Map(Object.entries({ + j: 'down', + k: 'up', + h: 'left', + l: 'right', + H: 'move-column-left', + L: 'move-column-right', + '0': 'first-column', + '^': 'first-column', + $: 'last-column', + G: 'last-row', + g: 'g', + d: 'd', + i: 'edit', + x: 'select', + o: 'open-page', + O: 'create-pages', + c: 'columns', + m: 'menu', + a: 'add-row' +}) as Array<[string, DatabaseGridKeyAction]>) + +export function databaseGridKeyAction(e: GridKey, vimMode: boolean): DatabaseGridKeyAction | null { + if (e.metaKey || e.ctrlKey || e.altKey) return null // leave global shortcuts alone + if (e.key === 'F10') return e.shiftKey ? 'menu' : null + const universal = UNIVERSAL.get(e.key) + if (universal) return universal + if (!vimMode) return null + return VIM_ONLY.get(e.key) ?? null +} + diff --git a/packages/app-core/src/lib/database-view-commands.ts b/packages/app-core/src/lib/database-view-commands.ts new file mode 100644 index 000000000..c27762203 --- /dev/null +++ b/packages/app-core/src/lib/database-view-commands.ts @@ -0,0 +1,62 @@ +/** + * Palette commands that act on the open database's views (#971 keys them). + * + * What they act on lives in component state, not the store: whether the + * Columns popover is open is the header's, and which rows are selected is + * the Table's. So a command names the database it means and announces + * itself on a window event, and the DatabaseView in the ACTIVE pane (the + * same database can be open in two) carries it out. + */ +import { useEffect, useRef } from 'react' +import { csvPathFromDatabaseTab, isDatabaseCsvPath } from '@shared/databases' +import { assetPathFromTab } from './asset-tabs' + +export type DatabaseViewCommand = + | 'columns' + | 'new-table-view' + | 'duplicate-view' + | 'pick-selected' + | 'show-all-rows' + +export const DATABASE_VIEW_COMMAND_EVENT = 'zen:database-view-command' + +interface DatabaseViewCommandDetail { + csvPath: string + command: DatabaseViewCommand +} + +/** The database a tab shows: a `zen://database/` tab, or a `.csv` opened as + * an asset tab, which renders the same grid. */ +export function databaseCsvPathForTab(tab: string | null | undefined): string | null { + const direct = csvPathFromDatabaseTab(tab) + if (direct) return direct + const asset = assetPathFromTab(tab) + return asset && isDatabaseCsvPath(asset) ? asset : null +} + +export function requestDatabaseViewCommand(csvPath: string, command: DatabaseViewCommand): void { + window.dispatchEvent( + new CustomEvent(DATABASE_VIEW_COMMAND_EVENT, { + detail: { csvPath, command } + }) + ) +} + +/** Run `handler` for commands addressed to `csvPath`, while `isActive`. */ +export function useDatabaseViewCommand( + csvPath: string | null, + isActive: boolean, + handler: (command: DatabaseViewCommand) => void +): void { + const latest = useRef(handler) + latest.current = handler + useEffect(() => { + if (!csvPath || !isActive) return + const listener = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (detail?.csvPath === csvPath) latest.current(detail.command) + } + window.addEventListener(DATABASE_VIEW_COMMAND_EVENT, listener) + return () => window.removeEventListener(DATABASE_VIEW_COMMAND_EVENT, listener) + }, [csvPath, isActive]) +} diff --git a/packages/app-core/src/lib/editor-focus.test.ts b/packages/app-core/src/lib/editor-focus.test.ts index 0aab7b6ee..a6750332f 100644 --- a/packages/app-core/src/lib/editor-focus.test.ts +++ b/packages/app-core/src/lib/editor-focus.test.ts @@ -26,9 +26,11 @@ vi.mock('@replit/codemirror-vim', () => ({ import { EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { + caretIntoNewNoteBody, focusEditorAfterReadOpen, focusEditorAfterPaletteClose, - focusEditorNormalMode + focusEditorNormalMode, + newNoteBodyCaret } from './editor-focus' import { installNoteEditorHost } from './editor-host' import { revealedSelectionRanges, sourceRevealExtension } from './cm-source-reveal' @@ -44,6 +46,7 @@ describe('focusEditorNormalMode', () => { vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { return window.setTimeout(() => callback(performance.now()), 0) }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((handle) => window.clearTimeout(handle)) }) afterEach(() => { @@ -69,6 +72,26 @@ describe('focusEditorNormalMode', () => { expect(focus).toHaveBeenCalledTimes(1) expect(document.activeElement).toBe(dom) }) + + it('hands a just-made note the keyboard on the line under its heading, however it was opened', async () => { + const view = new EditorView({ state: EditorState.create({ doc: '# Untitled\n\n' }), parent: document.body }) + storeState.current.editorViewRef = view as unknown as Record + focusEditorNormalMode() + await vi.advanceTimersByTimeAsync(0) + expect(view.state.selection.main.head).toBe(12) + view.dispatch(view.state.replaceSelection('Body')) + expect(view.state.doc.toString()).toBe('# Untitled\n\nBody') + view.destroy() + }) + + it('leaves the caret of a note with a body where it was', async () => { + const view = new EditorView({ state: EditorState.create({ doc: '# Plan\n\nfirst\n' }), parent: document.body }) + storeState.current.editorViewRef = view as unknown as Record + focusEditorNormalMode() + await vi.advanceTimersByTimeAsync(0) + expect(view.state.selection.main.head).toBe(0) + view.destroy() + }) }) describe('search hand-offs', () => { @@ -163,3 +186,29 @@ describe('search hand-offs', () => { expect(view!.hasFocus).toBe(false) }) }) + +describe('a new note drops into its body after the name field', () => { + it('moves a caret still at the start of a heading-only note to the line under it', () => { + expect(newNoteBodyCaret('# Untitled\n\n', 0, true)).toBe(12) + expect(newNoteBodyCaret('# Untitled\n', 0, true)).toBe(11) + }) + + it('leaves a note with more in it, a placed caret or a selection alone', () => { + expect(newNoteBodyCaret('# Plan\n\nfirst line\n', 0, true)).toBeNull() + expect(newNoteBodyCaret('# Untitled\n\n', 4, true)).toBeNull() + expect(newNoteBodyCaret('# Untitled\n\n', 0, false)).toBeNull() + expect(newNoteBodyCaret('# Untitled', 0, true)).toBeNull() + expect(newNoteBodyCaret('---\ntags: a\n---\n# Untitled\n', 0, true)).toBeNull() + }) + + it('keeps the caret on the empty line when the rename rewrites the heading afterwards', () => { + const view = new EditorView({ state: EditorState.create({ doc: '# Untitled\n\n' }) }) + storeState.current.editorViewRef = view as unknown as Record + caretIntoNewNoteBody() + view.dispatch({ changes: { from: 2, to: 10, insert: 'Caret check' } }) + view.dispatch(view.state.replaceSelection('Body')) + expect(view.state.doc.toString()).toBe('# Caret check\n\nBody') + view.destroy() + storeState.current.editorViewRef = null + }) +}) diff --git a/packages/app-core/src/lib/editor-focus.ts b/packages/app-core/src/lib/editor-focus.ts index 0a45f0911..617ad11d7 100644 --- a/packages/app-core/src/lib/editor-focus.ts +++ b/packages/app-core/src/lib/editor-focus.ts @@ -37,6 +37,7 @@ export function focusEditorNormalMode( return } view.focus() + caretIntoNewNoteBody(view) if (state.vimMode) { const cm = getCM(view) if (cm?.state.vim?.insertMode) { @@ -93,3 +94,32 @@ export function focusEditorAfterReadOpen( export function focusEditorAfterPaletteClose(): void { if (!hostOpensNotesToRead()) focusEditorNormalMode() } + +/** + * Where the caret goes when a note that holds only its `# Title` heading (one + * just made) is handed the keyboard: its editor starts with the caret at the + * very start, so the first keys typed went in front of the heading + * (`Body# My note`), whether the name field handed off (#214) or a New Note + * command focused the editor itself. It goes to the end, on the empty line + * under the heading. Null leaves the caret where it is: a note with more in + * it, a heading with no line under it, a selection, or a caret already placed. + */ +export function newNoteBodyCaret(text: string, head: number, empty: boolean): number | null { + if (!empty || head !== 0) return null + const headingEnd = text.indexOf('\n') + if (headingEnd === -1 || !/^#\s/.test(text.slice(0, headingEnd))) return null + if (text.slice(headingEnd).trim() !== '') return null + return text.length +} + +/** Drop the caret into a new note's body as the editor takes the keyboard + * (see newNoteBodyCaret). A rename's heading rewrite is a minimal change, so + * a caret at the end stays at the end whichever lands first. */ +export function caretIntoNewNoteBody( + view: ReturnType['editorViewRef'] = useStore.getState().editorViewRef +): void { + if (!view?.state) return + const { main } = view.state.selection + const at = newNoteBodyCaret(view.state.doc.toString(), main.head, main.empty) + if (at !== null) view.dispatch({ selection: { anchor: at } }) +} diff --git a/packages/app-core/src/lib/folder-gallery-open.test.ts b/packages/app-core/src/lib/folder-gallery-open.test.ts new file mode 100644 index 000000000..2832a8d42 --- /dev/null +++ b/packages/app-core/src/lib/folder-gallery-open.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { NoteContent, NoteMeta } from '@shared/ipc' + +function installZen(): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + getAppInfo: vi.fn().mockReturnValue({ runtime: 'desktop' }), + getCapabilities: vi.fn().mockReturnValue({}), + platformSync: () => 'darwin', + listAssets: vi.fn().mockResolvedValue([]), + hasAssetsDir: vi.fn().mockResolvedValue(false) + } + }) +} + +function note(path: string): NoteMeta { + return { + path, + title: path.slice(path.lastIndexOf('/') + 1).replace(/\.md$/, ''), + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '' + } +} + +async function load() { + vi.resetModules() + localStorage.clear() + installZen() + const { useStore } = await import('../store') + const open = await import('./folder-gallery-open') + const { galleryTabPath } = await import('./folder-gallery') + const { makeLeaf } = await import('./pane-layout') + return { useStore, ...open, galleryTabPath, makeLeaf } +} + +/** A sidebar row the way Sidebar draws one, numbered for its Vim cursor. */ +function sidebarRow(idx: number, attrs: Record): void { + const row = document.createElement('div') + row.setAttribute('data-sidebar-idx', String(idx)) + for (const [name, value] of Object.entries(attrs)) row.setAttribute(name, value) + document.body.appendChild(row) +} + +beforeEach(() => { + document.body.innerHTML = '' +}) + +describe('contextGalleryFolder', () => { + it('takes the folder row under the sidebar cursor while the sidebar has focus', async () => { + const s = await load() + sidebarRow(0, { 'data-sidebar-type': 'folder', 'data-sidebar-folder': 'inbox', 'data-sidebar-subpath': 'Recipes' }) + s.useStore.setState({ focusedPanel: 'sidebar', sidebarOpen: true, sidebarCursorIndex: 0 }) + expect(s.contextGalleryFolder()).toEqual({ folder: 'inbox', subpath: 'Recipes' }) + }) + + it('takes a note row’s own folder, and the Archive row as the archive', async () => { + const s = await load() + sidebarRow(0, { 'data-sidebar-type': 'note', 'data-sidebar-path': 'inbox/Recipes/Desserts/Cake.md' }) + sidebarRow(1, { 'data-sidebar-type': 'archive' }) + s.useStore.setState({ + notes: [note('inbox/Recipes/Desserts/Cake.md')], + focusedPanel: 'sidebar', + sidebarOpen: true, + sidebarCursorIndex: 0 + }) + expect(s.contextGalleryFolder()).toEqual({ folder: 'inbox', subpath: 'Recipes/Desserts' }) + s.useStore.setState({ sidebarCursorIndex: 1 }) + expect(s.contextGalleryFolder()).toEqual({ folder: 'archive', subpath: '' }) + }) + + it('takes the open note’s folder when the sidebar does not have focus', async () => { + const s = await load() + sidebarRow(0, { 'data-sidebar-type': 'folder', 'data-sidebar-folder': 'inbox', 'data-sidebar-subpath': 'Recipes' }) + const active: NoteContent = { ...note('inbox/Projects/Website/Plan.md'), body: '' } + s.useStore.setState({ focusedPanel: 'editor', sidebarCursorIndex: 0, activeNote: active }) + expect(s.contextGalleryFolder()).toEqual({ folder: 'inbox', subpath: 'Projects/Website' }) + }) + + it('stays on an open Gallery’s folder, and falls back to the notes area', async () => { + const s = await load() + const leaf = s.makeLeaf([s.galleryTabPath('archive', '2025')], s.galleryTabPath('archive', '2025')) + s.useStore.setState({ focusedPanel: 'editor', activeNote: null, paneLayout: leaf, activePaneId: leaf.id }) + expect(s.contextGalleryFolder()).toEqual({ folder: 'archive', subpath: '2025' }) + const empty = s.makeLeaf() + s.useStore.setState({ paneLayout: empty, activePaneId: empty.id }) + expect(s.contextGalleryFolder()).toEqual({ folder: 'inbox', subpath: '' }) + }) +}) + +describe('openGalleryFromArgument', () => { + it('opens the folder an argument names, and says so when none does', async () => { + const s = await load() + const openFolderGallery = vi.fn().mockResolvedValue(undefined) + s.useStore.setState({ + folders: [{ folder: 'inbox', subpath: 'Projects', siblingOrder: 0 }], + openFolderGallery + }) + await s.openGalleryFromArgument('projects') + expect(openFolderGallery).toHaveBeenCalledWith('inbox', 'Projects') + const { useToastStore } = await import('./toast') + await s.openGalleryFromArgument('Nowhere') + expect(openFolderGallery).toHaveBeenCalledTimes(1) + expect(useToastStore.getState().toasts.at(-1)?.message).toBe('No folder called "Nowhere" in this vault') + }) +}) diff --git a/packages/app-core/src/lib/folder-gallery-open.ts b/packages/app-core/src/lib/folder-gallery-open.ts new file mode 100644 index 000000000..67e4165ce --- /dev/null +++ b/packages/app-core/src/lib/folder-gallery-open.ts @@ -0,0 +1,75 @@ +/** + * Which folder a Gallery opener means, for the openers that are not on a + * folder already (the folder's own menu is): Open Folder Gallery in the + * palette, the Vim leader key, and a bare `:gallery`. + * + * The sidebar row under the cursor wins while the sidebar holds the keyboard + * (a folder row is that folder, a note row its note's folder), the way the + * palette's Move Note to Trash follows the sidebar (#972). Otherwise the + * open note's folder, an open Gallery's own folder, and last the top of the + * main notes area. + */ +import type { NoteFolder } from '@shared/ipc' +import { useStore } from '../store' +import { + galleryFolderFromTabPath, + galleryFolderOfNote, + resolveGalleryFolderArgument, + type GalleryFolder +} from './folder-gallery' +import { findLeaf } from './pane-layout' +import { sidebarCursorRow, sidebarHoldsFocus } from './sidebar-note-actions' +import { resolveSystemFolderLabels } from './system-folder-labels' +import { useToastStore } from './toast' + +const NOTE_FOLDERS: readonly string[] = ['inbox', 'quick', 'archive', 'trash'] + +function isNoteFolder(value: string | null): value is NoteFolder { + return value !== null && NOTE_FOLDERS.includes(value) +} + +export function contextGalleryFolder(): GalleryFolder { + const state = useStore.getState() + if (typeof document !== 'undefined' && sidebarHoldsFocus(state)) { + const row = sidebarCursorRow(state.sidebarCursorIndex) + if (row?.type === 'folder' && isNoteFolder(row.folder)) { + return { folder: row.folder, subpath: row.subpath ?? '' } + } + if (row?.type === 'archive') return { folder: 'archive', subpath: '' } + if (row?.type === 'note' && row.path) { + const note = state.notes.find((n) => n.path === row.path) + if (note) return galleryFolderOfNote(note, state.vaultSettings) + } + } + if (state.activeNote) return galleryFolderOfNote(state.activeNote, state.vaultSettings) + const open = galleryFolderFromTabPath(findLeaf(state.paneLayout, state.activePaneId)?.activeTab) + if (open) return open + return { folder: 'inbox', subpath: '' } +} + +/** Open the Gallery of the folder the context means. */ +export async function openContextGallery(): Promise { + const target = contextGalleryFolder() + await useStore.getState().openFolderGallery(target.folder, target.subpath) +} + +/** `:gallery `: the folder the argument names, or a note saying none + * does. A bare `:gallery` follows the context. */ +export async function openGalleryFromArgument(arg: string): Promise { + if (!arg.trim()) { + await openContextGallery() + return + } + const state = useStore.getState() + const target = resolveGalleryFolderArgument( + arg, + state.folders, + state.vaultSettings, + resolveSystemFolderLabels(state.systemFolderLabels) + ) + if (!target) { + useToastStore.getState().addToast(`No folder called "${arg.trim()}" in this vault`, 'info') + return + } + await state.openFolderGallery(target.folder, target.subpath) +} diff --git a/packages/app-core/src/lib/folder-gallery.test.ts b/packages/app-core/src/lib/folder-gallery.test.ts new file mode 100644 index 000000000..7c51cd7af --- /dev/null +++ b/packages/app-core/src/lib/folder-gallery.test.ts @@ -0,0 +1,526 @@ +import { describe, expect, it } from 'vitest' +import type { NoteFolder, NoteMeta, VaultSettings } from '@shared/ipc' +import type { NoteCover } from '@shared/note-cover' +import { gradientCoverDataUrl, NOTE_COVER_GRADIENTS } from '@shared/note-cover-gradients' +import { + GALLERY_CARD_TEXT_HEIGHT, + filterGalleryNotes, + formatGalleryDate, + galleryCardTimestamp, + galleryContentHeight, + galleryFolderExists, + galleryFolderFromTabPath, + galleryFolderName, + galleryFolderOfNote, + galleryGeometry, + galleryInitial, + galleryNotesIn, + galleryRowRange, + galleryScrollTopFor, + galleryTabPath, + galleryThumbnail, + isGalleryTabPath, + normalizeGallerySort, + resolveGalleryFolderArgument, + rewriteGalleryTabPath, + sortGalleryNotes, + stepGalleryCursor, + unsplashThumbnailUrl +} from './folder-gallery' + +const INBOX_MODE = { primaryNotesLocation: 'inbox', systemFolderPaths: {} } as unknown as VaultSettings +const ROOT_MODE = { primaryNotesLocation: 'root', systemFolderPaths: {} } as unknown as VaultSettings +const LABELS: Record = { + inbox: 'Inbox', + quick: 'Quick Notes', + archive: 'Archive', + trash: 'Trash' +} + +function note(path: string, extra: Partial = {}): NoteMeta { + const file = path.slice(path.lastIndexOf('/') + 1) + return { + path, + title: file.replace(/\.md$/i, ''), + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '', + ...extra + } +} + +function cover(target: NoteCover['target'], y = 0.5): NoteCover { + return { key: 'banner', target, y } +} + +describe('gallery tab paths', () => { + it('round-trip a folder, nested subpaths and odd characters included', () => { + for (const [folder, subpath] of [ + ['inbox', ''], + ['inbox', 'Projects'], + ['inbox', 'Projects/Web site'], + ['archive', '2025/Q1 #1 & more'], + ['quick', ''] + ] as Array<[NoteFolder, string]>) { + const path = galleryTabPath(folder, subpath) + expect(path.startsWith('zen://gallery/')).toBe(true) + expect(isGalleryTabPath(path)).toBe(true) + expect(galleryFolderFromTabPath(path)).toEqual({ folder, subpath }) + } + }) + + it('trims slashes so one folder has one tab', () => { + expect(galleryTabPath('inbox', '/Projects/')).toBe(galleryTabPath('inbox', 'Projects')) + }) + + it('refuses paths that are not a gallery of a known folder', () => { + expect(isGalleryTabPath('inbox/gallery.md')).toBe(false) + expect(isGalleryTabPath(null)).toBe(false) + expect(galleryFolderFromTabPath('zen://gallery/elsewhere/x')).toBeNull() + expect(galleryFolderFromTabPath('zen://unresolved-links')).toBeNull() + }) + + it('names the folder by its last segment, or its label at the top', () => { + expect(galleryFolderName({ folder: 'inbox', subpath: 'Projects/Website' }, LABELS)).toBe('Website') + expect(galleryFolderName({ folder: 'quick', subpath: '' }, LABELS)).toBe('Quick Notes') + }) +}) + +describe('rewriteGalleryTabPath', () => { + // The workspace's own rewrite for a folder rename: a `dir/` prefix swap. + const renamer = (from: string, to: string | null) => (path: string): string | null => + path.startsWith(from) ? (to === null ? null : to + path.slice(from.length)) : path + + it('follows a renamed folder and the folders under it', () => { + const rename = renamer('inbox/Projects/', 'inbox/Work/') + expect(rewriteGalleryTabPath(galleryTabPath('inbox', 'Projects'), rename, INBOX_MODE)).toBe( + galleryTabPath('inbox', 'Work') + ) + expect(rewriteGalleryTabPath(galleryTabPath('inbox', 'Projects/Web'), rename, INBOX_MODE)).toBe( + galleryTabPath('inbox', 'Work/Web') + ) + }) + + it('follows a rename in a root-mode vault, where the notes have no inbox/ prefix', () => { + const rename = renamer('Projects/', 'Work/') + expect(rewriteGalleryTabPath(galleryTabPath('inbox', 'Projects'), rename, ROOT_MODE)).toBe( + galleryTabPath('inbox', 'Work') + ) + }) + + it('follows a rename inside a remapped archive', () => { + const settings = { + primaryNotesLocation: 'inbox', + systemFolderPaths: { archive: '99 - Archive' } + } as unknown as VaultSettings + const rename = renamer('99 - Archive/2025/', '99 - Archive/Done/') + expect(rewriteGalleryTabPath(galleryTabPath('archive', '2025'), rename, settings)).toBe( + galleryTabPath('archive', 'Done') + ) + }) + + it('closes the gallery of a deleted folder and leaves others alone', () => { + const remove = renamer('inbox/Projects/', null) + expect(rewriteGalleryTabPath(galleryTabPath('inbox', 'Projects'), remove, INBOX_MODE)).toBeNull() + const other = galleryTabPath('inbox', 'Recipes') + expect(rewriteGalleryTabPath(other, remove, INBOX_MODE)).toBe(other) + const top = galleryTabPath('inbox', '') + expect(rewriteGalleryTabPath(top, remove, INBOX_MODE)).toBe(top) + }) + + it('passes any other tab through', () => { + expect(rewriteGalleryTabPath('inbox/Projects/a.md', () => null, INBOX_MODE)).toBe('inbox/Projects/a.md') + }) +}) + +describe('galleryNotesIn', () => { + const notes = [ + note('inbox/Recipes/Pasta.md'), + note('inbox/Recipes/Soup.md'), + note('inbox/Recipes/Desserts/Cake.md'), + note('inbox/Recipes/Desserts/Deep/Tart.md'), + note('inbox/Recipes/Sketch.excalidraw'), + note('inbox/Recipes/Board.excalidraw.md'), + note('inbox/Recipes/Pantry.base/pages/Flour.md'), + note('inbox/Recipesque/Other.md'), + note('inbox/Top.md'), + note('archive/Recipes/Old.md', { folder: 'archive' }) + ] + const recipes = { folder: 'inbox' as const, subpath: 'Recipes' } + + it('holds the folder’s own notes by default, without drawings', () => { + expect(galleryNotesIn(notes, recipes, false, INBOX_MODE).map((n) => n.title)).toEqual([ + 'Pasta', + 'Soup' + ]) + }) + + it('adds every note below it with subfolders on, but no database record pages', () => { + expect(galleryNotesIn(notes, recipes, true, INBOX_MODE).map((n) => n.title)).toEqual([ + 'Pasta', + 'Soup', + 'Cake', + 'Tart' + ]) + }) + + it('never mistakes a sibling folder with a longer name for a subfolder', () => { + const titles = galleryNotesIn(notes, recipes, true, INBOX_MODE).map((n) => n.title) + expect(titles).not.toContain('Other') + }) + + it('stays inside its top-level folder', () => { + expect( + galleryNotesIn(notes, { folder: 'archive', subpath: 'Recipes' }, true, INBOX_MODE).map( + (n) => n.title + ) + ).toEqual(['Old']) + }) + + it('takes the whole folder from its top', () => { + expect(galleryNotesIn(notes, { folder: 'inbox', subpath: '' }, false, INBOX_MODE).map((n) => n.title)).toEqual([ + 'Top' + ]) + expect(galleryNotesIn(notes, { folder: 'inbox', subpath: '' }, true, INBOX_MODE)).toHaveLength(6) + }) + + it('reads folders the same way in a root-mode vault', () => { + const rootNotes = [note('Recipes/Pasta.md'), note('Recipes/Desserts/Cake.md'), note('Top.md')] + expect(galleryNotesIn(rootNotes, recipes, false, ROOT_MODE).map((n) => n.title)).toEqual(['Pasta']) + expect(galleryNotesIn(rootNotes, recipes, true, ROOT_MODE).map((n) => n.title)).toEqual([ + 'Pasta', + 'Cake' + ]) + }) +}) + +describe('sortGalleryNotes', () => { + const a = note('inbox/a.md', { title: 'Note 10', createdAt: 100, updatedAt: 500 }) + const b = note('inbox/b.md', { title: 'Note 2', createdAt: 300, updatedAt: 400 }) + const c = note('inbox/c.md', { title: 'apple', createdAt: 200, updatedAt: 500 }) + + it('puts the last edited first, ties by title', () => { + expect(sortGalleryNotes([a, b, c], 'updated').map((n) => n.title)).toEqual([ + 'apple', + 'Note 10', + 'Note 2' + ]) + }) + + it('puts the newest first under Created', () => { + expect(sortGalleryNotes([a, b, c], 'created').map((n) => n.title)).toEqual([ + 'Note 2', + 'apple', + 'Note 10' + ]) + }) + + it('sorts titles naturally, ignoring case, under Title', () => { + expect(sortGalleryNotes([a, b, c], 'title').map((n) => n.title)).toEqual([ + 'apple', + 'Note 2', + 'Note 10' + ]) + }) + + it('does not touch the list it was given', () => { + const list = [a, b, c] + sortGalleryNotes(list, 'title') + expect(list).toEqual([a, b, c]) + }) + + it('reads an unknown stored sort as Updated', () => { + expect(normalizeGallerySort('created')).toBe('created') + expect(normalizeGallerySort('title')).toBe('title') + expect(normalizeGallerySort('size')).toBe('updated') + expect(normalizeGallerySort(undefined)).toBe('updated') + }) + + it('shows the date the sort is about', () => { + expect(galleryCardTimestamp(a, 'created')).toBe(100) + expect(galleryCardTimestamp(a, 'updated')).toBe(500) + expect(galleryCardTimestamp(a, 'title')).toBe(500) + }) +}) + +describe('filterGalleryNotes', () => { + const list = [note('inbox/x/Garden Plan.md'), note('inbox/x/Plant list.md'), note('inbox/x/Soup.md')] + + it('keeps titles holding every word, in any case and order', () => { + expect(filterGalleryNotes(list, 'PLAN').map((n) => n.title)).toEqual(['Garden Plan', 'Plant list']) + expect(filterGalleryNotes(list, 'plan garden').map((n) => n.title)).toEqual(['Garden Plan']) + }) + + it('keeps everything for an empty query', () => { + expect(filterGalleryNotes(list, ' ')).toHaveLength(3) + }) +}) + +describe('card text', () => { + it('takes the first letter or digit of a title', () => { + expect(galleryInitial('pasta')).toBe('P') + expect(galleryInitial('🍕 pizza night')).toBe('P') + expect(galleryInitial('[draft] essay')).toBe('D') + expect(galleryInitial('2026 plan')).toBe('2') + expect(galleryInitial('élan')).toBe('É') + expect(galleryInitial('#')).toBe('#') + expect(galleryInitial('')).toBe('?') + }) + + it('writes the year only when it is not this year', () => { + const now = new Date(2026, 9, 29).getTime() + const thisYear = new Date(2026, 2, 4).getTime() + const lastYear = new Date(2025, 2, 4).getTime() + expect(formatGalleryDate(thisYear, now)).toBe( + new Date(thisYear).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ) + expect(formatGalleryDate(lastYear, now)).toContain('2025') + }) +}) + +describe('opening a gallery', () => { + const folders = [ + { folder: 'inbox' as const, subpath: 'Projects' }, + { folder: 'inbox' as const, subpath: 'Projects/Website' }, + { folder: 'archive' as const, subpath: '2025' } + ] + + it('finds a folder by its vault path, its notes-area path or a system folder name', () => { + expect(resolveGalleryFolderArgument('inbox/Projects', folders, INBOX_MODE, LABELS)).toEqual({ + folder: 'inbox', + subpath: 'Projects' + }) + expect(resolveGalleryFolderArgument('projects/website/', folders, INBOX_MODE, LABELS)).toEqual({ + folder: 'inbox', + subpath: 'Projects/Website' + }) + expect(resolveGalleryFolderArgument('archive/2025', folders, INBOX_MODE, LABELS)).toEqual({ + folder: 'archive', + subpath: '2025' + }) + expect(resolveGalleryFolderArgument('Quick Notes', folders, INBOX_MODE, LABELS)).toEqual({ + folder: 'quick', + subpath: '' + }) + expect(resolveGalleryFolderArgument('inbox', folders, INBOX_MODE, LABELS)).toEqual({ + folder: 'inbox', + subpath: '' + }) + }) + + it('reads a root-mode vault’s folders without an inbox/ prefix', () => { + expect(resolveGalleryFolderArgument('Projects', folders, ROOT_MODE, LABELS)).toEqual({ + folder: 'inbox', + subpath: 'Projects' + }) + }) + + it('answers null for a folder the vault does not have', () => { + expect(resolveGalleryFolderArgument('Nowhere', folders, INBOX_MODE, LABELS)).toBeNull() + expect(resolveGalleryFolderArgument(' ', folders, INBOX_MODE, LABELS)).toBeNull() + }) + + it('takes a note’s own folder', () => { + expect(galleryFolderOfNote(note('inbox/Projects/Website/a.md'), INBOX_MODE)).toEqual({ + folder: 'inbox', + subpath: 'Projects/Website' + }) + expect(galleryFolderOfNote(note('a.md'), ROOT_MODE)).toEqual({ folder: 'inbox', subpath: '' }) + }) + + it('knows when its folder is gone', () => { + expect(galleryFolderExists({ folder: 'inbox', subpath: 'Projects' }, folders)).toBe(true) + expect(galleryFolderExists({ folder: 'inbox', subpath: 'Gone' }, folders)).toBe(false) + expect(galleryFolderExists({ folder: 'trash', subpath: '' }, folders)).toBe(true) + }) +}) + +describe('galleryGeometry', () => { + it('shows two columns at phone width, never one', () => { + for (const width of [320, 375, 430]) { + const g = galleryGeometry(width) + expect(g.columns).toBe(2) + expect(g.cardWidth * 2 + g.gap + g.padding * 2).toBeCloseTo(width) + } + }) + + it('adds columns as the pane widens, up to eight', () => { + expect(galleryGeometry(700).columns).toBe(3) + expect(galleryGeometry(900).columns).toBe(4) + expect(galleryGeometry(1280).columns).toBe(5) + expect(galleryGeometry(5000).columns).toBe(8) + }) + + it('gives every card the cover plus the text block, and a row the gap under it', () => { + const g = galleryGeometry(1000) + expect(g.cardHeight).toBe(g.coverHeight + GALLERY_CARD_TEXT_HEIGHT) + expect(g.rowHeight).toBe(g.cardHeight + g.gap) + expect(g.coverHeight).toBeLessThan(g.cardWidth) + }) + + it('measures the content without a gap after the last row', () => { + const g = galleryGeometry(1000) + expect(galleryContentHeight(0, g)).toBe(0) + expect(galleryContentHeight(1, g)).toBe(g.padding * 2 + g.cardHeight) + expect(galleryContentHeight(g.columns + 1, g)).toBe(g.padding * 2 + g.rowHeight + g.cardHeight) + }) +}) + +describe('galleryRowRange', () => { + const g = galleryGeometry(1000) + + it('mounts only the rows on screen, plus the overscan', () => { + const count = 1000 + const view = galleryRowRange(count, g, 0, g.rowHeight * 3, 0) + expect(view.start).toBe(0) + expect(view.end).toBeLessThanOrEqual(4) + const scrolled = galleryRowRange(count, g, g.padding + g.rowHeight * 50, g.rowHeight * 3, 2) + expect(scrolled.start).toBe(48) + expect(scrolled.end).toBeLessThanOrEqual(56) + }) + + it('stops at the last row and handles an empty gallery', () => { + const rows = Math.ceil(10 / g.columns) + expect(galleryRowRange(10, g, 100000, 800, 3).end).toBe(rows) + expect(galleryRowRange(0, g, 0, 800, 3)).toEqual({ start: 0, end: 0 }) + }) +}) + +describe('galleryScrollTopFor', () => { + const g = galleryGeometry(1000) + const viewport = g.rowHeight * 2 + + it('leaves the scroll alone for a card in view', () => { + expect(galleryScrollTopFor(0, g, 0, viewport)).toBe(0) + }) + + it('scrolls down just enough to show a card below', () => { + const index = g.columns * 5 + const top = galleryScrollTopFor(index, g, 0, viewport) + const cardBottom = g.padding + 5 * g.rowHeight + g.cardHeight + expect(top).toBe(cardBottom + g.padding - viewport) + }) + + it('scrolls up to a card above', () => { + expect(galleryScrollTopFor(g.columns * 2, g, g.rowHeight * 10, viewport)).toBe(2 * g.rowHeight) + }) +}) + +describe('stepGalleryCursor', () => { + // 3 columns, 8 cards: + // 0 1 2 + // 3 4 5 + // 6 7 + it('walks left and right in reading order, across rows', () => { + expect(stepGalleryCursor(2, 8, 3, 'right')).toBe(3) + expect(stepGalleryCursor(3, 8, 3, 'left')).toBe(2) + expect(stepGalleryCursor(0, 8, 3, 'left')).toBe(0) + expect(stepGalleryCursor(7, 8, 3, 'right')).toBe(7) + }) + + it('keeps the column up and down', () => { + expect(stepGalleryCursor(1, 8, 3, 'down')).toBe(4) + expect(stepGalleryCursor(4, 8, 3, 'up')).toBe(1) + expect(stepGalleryCursor(1, 8, 3, 'up')).toBe(1) + }) + + it('lands on the last card going down past the short last row', () => { + expect(stepGalleryCursor(5, 8, 3, 'down')).toBe(7) + expect(stepGalleryCursor(7, 8, 3, 'down')).toBe(7) + expect(stepGalleryCursor(6, 8, 3, 'down')).toBe(6) + }) + + it('jumps to the ends', () => { + expect(stepGalleryCursor(4, 8, 3, 'first')).toBe(0) + expect(stepGalleryCursor(4, 8, 3, 'last')).toBe(7) + }) + + it('clamps a stale index and has nowhere to go in an empty gallery', () => { + expect(stepGalleryCursor(40, 8, 3, 'left')).toBe(6) + expect(stepGalleryCursor(0, 0, 3, 'down')).toBe(-1) + }) +}) + +describe('galleryThumbnail', () => { + const served = (href: string): string | null => (href.startsWith('assets/') ? `zen-asset://vault/${href}` : null) + + it('draws the placeholder for a note without a cover', () => { + expect(galleryThumbnail(null, served)).toEqual({ kind: 'placeholder' }) + }) + + it('draws a vault image from where the host serves it, at its focal point', () => { + expect(galleryThumbnail(cover({ kind: 'vault', href: 'assets/lisbon.jpg' }, 0.3), served)).toEqual({ + kind: 'image', + url: 'zen-asset://vault/assets/lisbon.jpg', + y: 0.3, + source: 'vault' + }) + }) + + it('draws a built-in gradient from the vault, or from its own SVG when the vault cannot serve it', () => { + const gradient = NOTE_COVER_GRADIENTS[0] + const file = `cover-gradient-${gradient.id}.svg` + expect(galleryThumbnail(cover({ kind: 'vault', href: `assets/${file}` }), served)).toMatchObject({ + kind: 'image', + url: `zen-asset://vault/assets/${file}`, + source: 'gradient' + }) + expect(galleryThumbnail(cover({ kind: 'vault', href: `elsewhere/${file}` }), served)).toEqual({ + kind: 'image', + url: gradientCoverDataUrl(gradient), + y: 0.5, + source: 'gradient' + }) + }) + + it('falls back to the placeholder for a vault image this device cannot find', () => { + expect(galleryThumbnail(cover({ kind: 'vault', href: 'gone.png' }), served)).toEqual({ + kind: 'placeholder' + }) + }) + + it('asks Unsplash for a thumbnail-sized photo and keeps any other link as it is', () => { + const photo = 'https://images.unsplash.com/photo-1?ixid=AbC%3D&w=2400&q=80&auto=format' + expect(galleryThumbnail(cover({ kind: 'url', url: photo }), served)).toEqual({ + kind: 'image', + url: 'https://images.unsplash.com/photo-1?ixid=AbC%3D&w=800&q=80&auto=format', + y: 0.5, + source: 'unsplash' + }) + const web = 'https://example.com/cover.jpg?w=2400' + expect(galleryThumbnail(cover({ kind: 'url', url: web }), served)).toMatchObject({ + url: web, + source: 'web' + }) + }) +}) + +describe('unsplashThumbnailUrl', () => { + it('swaps the width and keeps the rest byte for byte', () => { + expect(unsplashThumbnailUrl('https://images.unsplash.com/p?ixid=a%2Bb&w=2400&q=80', 640)).toBe( + 'https://images.unsplash.com/p?ixid=a%2Bb&w=640&q=80' + ) + }) + + it('adds a width to a raw URL', () => { + expect(unsplashThumbnailUrl('https://images.unsplash.com/p?ixid=x', 640)).toBe( + 'https://images.unsplash.com/p?ixid=x&w=640' + ) + expect(unsplashThumbnailUrl('https://images.unsplash.com/p', 640)).toBe( + 'https://images.unsplash.com/p?w=640' + ) + }) + + it('leaves other hosts alone', () => { + expect(unsplashThumbnailUrl('https://unsplash.com.evil.test/p?w=2400', 640)).toBe( + 'https://unsplash.com.evil.test/p?w=2400' + ) + }) +}) diff --git a/packages/app-core/src/lib/folder-gallery.ts b/packages/app-core/src/lib/folder-gallery.ts new file mode 100644 index 000000000..9771732b5 --- /dev/null +++ b/packages/app-core/src/lib/folder-gallery.ts @@ -0,0 +1,476 @@ +/** + * The Gallery: one folder's notes as a grid of cards, each led by the note's + * cover. Asked for by charles b. once covers shipped (2.78.0): "a gallery + * view of all notes in a folder by cover thumbnail". + * + * Everything here is pure, so the view is a thin shell over it: which notes a + * gallery holds, their order, the title filter, the grid's geometry and + * cursor moves, and which picture a card draws for a cover. The covers + * themselves come from reading the notes on screen (gallery-covers.ts); the + * vault index carries no cover, on purpose, so the desktop scan, the Go + * server and the phones' vault code stay as they are. + * + * A gallery is a tab like the Unresolved Links view, keyed by its folder: + * `zen://gallery//`, so two folders open as two + * tabs and a workspace restore brings each one back. + */ +import type { FolderEntry, NoteFolder, NoteMeta, VaultSettings } from '@shared/ipc' +import type { NoteCover } from '@shared/note-cover' +import { isFormDirName } from '@shared/databases' +import { isExcalidrawPath, isObsidianExcalidrawPath } from '@shared/excalidraw' +import { builtinGradientForAsset, gradientCoverDataUrl } from '@shared/note-cover-gradients' +import { isUnsplashImageUrl } from '@shared/unsplash' +import { naturalCompare } from './natural-sort' +import { + folderForVaultRelativePath, + noteFolderSubpath, + notePathWithinFolder, + vaultRelativeFolderPath +} from './vault-layout' + +/* ------------------------------------------------------------------------ */ +/* The tab */ +/* ------------------------------------------------------------------------ */ + +export interface GalleryFolder { + folder: NoteFolder + /** POSIX path inside the top-level folder, "" for the folder itself. */ + subpath: string +} + +const GALLERY_TAB_PREFIX = 'zen://gallery/' +const NOTE_FOLDERS: readonly NoteFolder[] = ['inbox', 'quick', 'archive', 'trash'] + +function cleanSubpath(subpath: string): string { + return (subpath ?? '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '') +} + +export function galleryTabPath(folder: NoteFolder, subpath: string): string { + return `${GALLERY_TAB_PREFIX}${folder}/${encodeURIComponent(cleanSubpath(subpath))}` +} + +export function isGalleryTabPath(path: string | null | undefined): boolean { + return typeof path === 'string' && path.startsWith(GALLERY_TAB_PREFIX) +} + +export function galleryFolderFromTabPath(path: string | null | undefined): GalleryFolder | null { + if (!path || !isGalleryTabPath(path)) return null + const rest = path.slice(GALLERY_TAB_PREFIX.length) + const slash = rest.indexOf('/') + const folder = (slash < 0 ? rest : rest.slice(0, slash)) as NoteFolder + if (!NOTE_FOLDERS.includes(folder)) return null + const encoded = slash < 0 ? '' : rest.slice(slash + 1) + let subpath = encoded + try { + subpath = decodeURIComponent(encoded) + } catch { + // Keep the text as written. + } + return { folder, subpath: cleanSubpath(subpath) } +} + +/** The folder's own name: its last segment, or the system folder's label at + * the top of one. */ +export function galleryFolderName( + target: GalleryFolder, + labels: Record +): string { + if (!target.subpath) return labels[target.folder] + return target.subpath.slice(target.subpath.lastIndexOf('/') + 1) +} + +/** + * Where a gallery tab goes when its folder is renamed, moved or deleted: + * `rewriteFile` is the workspace's own rewrite for paths under the changed + * folder (a vault-relative `dir/` in, the new one or null out). A top-level + * folder never moves, and a gallery of a folder that is gone closes. + */ +export function rewriteGalleryTabPath( + path: string, + rewriteFile: (path: string) => string | null, + settings: VaultSettings | null | undefined +): string | null { + const target = galleryFolderFromTabPath(path) + if (!target || !target.subpath) return path + const dir = `${vaultRelativeFolderPath(target.folder, target.subpath, settings)}/` + const mapped = rewriteFile(dir) + if (mapped === null) return null + if (mapped === dir) return path + const nextDir = mapped.replace(/\/+$/, '') + const folder = folderForVaultRelativePath(nextDir, settings) ?? target.folder + // The path inside the folder, read through a stand-in file name so the + // folder's own directory (remapped or not, root-mode or not) comes off the + // same way it does for a note. + const within = notePathWithinFolder(`${nextDir}/_`, folder, settings) + return galleryTabPath(folder, within.slice(0, -2)) +} + +/* ------------------------------------------------------------------------ */ +/* Which notes, in which order */ +/* ------------------------------------------------------------------------ */ + +export type GallerySort = 'updated' | 'created' | 'title' + +export const GALLERY_SORTS: readonly GallerySort[] = ['updated', 'created', 'title'] + +export const GALLERY_SORT_LABELS: Record = { + updated: 'Updated', + created: 'Created', + title: 'Title' +} + +export function normalizeGallerySort(value: unknown): GallerySort { + return value === 'created' || value === 'title' ? value : 'updated' +} + +/** A drawing is a canvas, not a note with a cover. */ +function isDrawing(path: string): boolean { + return isExcalidrawPath(path) || isObsidianExcalidrawPath(path) +} + +/** + * The notes a gallery of `target` holds: the folder's own notes, and with + * `includeSubfolders` every note below it too. A database's record pages + * (`.base/` under the folder) are rows of that database, not the + * folder's notes, so they stay out either way. + */ +export function galleryNotesIn>( + notes: readonly T[], + target: GalleryFolder, + includeSubfolders: boolean, + settings: VaultSettings | null | undefined +): T[] { + const base = cleanSubpath(target.subpath) + const out: T[] = [] + for (const note of notes) { + if (note.folder !== target.folder || isDrawing(note.path)) continue + const sub = noteFolderSubpath(note, settings) + if (sub === base) { + out.push(note) + continue + } + if (!includeSubfolders) continue + if (base && !sub.startsWith(`${base}/`)) continue + const below = base ? sub.slice(base.length + 1) : sub + if (below.split('/').some((segment) => isFormDirName(segment))) continue + out.push(note) + } + return out +} + +/** Newest first for the two dates, A to Z for the title; ties fall back to + * the title, then the path, so the order never shuffles between renders. */ +export function sortGalleryNotes>( + notes: readonly T[], + sort: GallerySort +): T[] { + const byTitle = (a: T, b: T): number => + naturalCompare(a.title, b.title) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0) + return [...notes].sort((a, b) => { + if (sort === 'updated') return b.updatedAt - a.updatedAt || byTitle(a, b) + if (sort === 'created') return b.createdAt - a.createdAt || byTitle(a, b) + return byTitle(a, b) + }) +} + +/** Keeps the notes whose title holds every word of the query, in any case. */ +export function filterGalleryNotes>( + notes: readonly T[], + query: string +): T[] { + const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean) + if (words.length === 0) return [...notes] + return notes.filter((note) => { + const title = note.title.toLowerCase() + return words.every((word) => title.includes(word)) + }) +} + +/** The date a card shows: when it was made under the Created sort, when it + * last changed otherwise. */ +export function galleryCardTimestamp( + note: Pick, + sort: GallerySort +): number { + return sort === 'created' ? note.createdAt : note.updatedAt +} + +/** "Oct 28", with the year only when it is not this year's. */ +export function formatGalleryDate(ms: number, now: number = Date.now()): string { + const date = new Date(ms) + const sameYear = date.getFullYear() === new Date(now).getFullYear() + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: sameYear ? undefined : 'numeric' + }) +} + +/** The letter a card without a cover shows: the title's first letter or + * digit (an emoji or a bracket in front is skipped), upper-cased. */ +export function galleryInitial(title: string): string { + const letter = title.match(/[\p{L}\p{N}]/u)?.[0] ?? Array.from(title.trim())[0] ?? '?' + return letter.toLocaleUpperCase() +} + +/* ------------------------------------------------------------------------ */ +/* Opening one */ +/* ------------------------------------------------------------------------ */ + +/** The folder a note sits in, as a gallery names it. */ +export function galleryFolderOfNote( + note: Pick, + settings: VaultSettings | null | undefined +): GalleryFolder { + return { folder: note.folder, subpath: noteFolderSubpath(note, settings) } +} + +/** + * The folder `:gallery ` names. In order: a vault-relative directory + * (`inbox/Projects`, `archive/2025`, or `Projects` in a root-mode vault), a + * folder inside the main notes area as `:newtask` takes it (`Projects`), or a + * system folder by its key or its label (`archive`, `Quick Notes`). Case does + * not matter. Null when nothing in the vault answers. + */ +export function resolveGalleryFolderArgument( + arg: string, + folders: readonly Pick[], + settings: VaultSettings | null | undefined, + labels: Record +): GalleryFolder | null { + const wanted = arg + .trim() + .replace(/\\/g, '/') + .replace(/^\.?\/+|\/+$/g, '') + .toLowerCase() + if (!wanted) return null + const candidates: GalleryFolder[] = NOTE_FOLDERS.map((folder) => ({ folder, subpath: '' })) + for (const entry of folders) { + const subpath = cleanSubpath(entry.subpath) + if (subpath) candidates.push({ folder: entry.folder, subpath }) + } + const byDirectory = candidates.find( + (c) => vaultRelativeFolderPath(c.folder, c.subpath, settings).toLowerCase() === wanted + ) + if (byDirectory) return byDirectory + const inNotes = candidates.find((c) => c.folder === 'inbox' && c.subpath.toLowerCase() === wanted) + if (inNotes) return inNotes + const system = NOTE_FOLDERS.find( + (folder) => folder === wanted || labels[folder].toLowerCase() === wanted + ) + return system ? { folder: system, subpath: '' } : null +} + +/** True while the vault still has the folder; a top-level one always does. */ +export function galleryFolderExists( + target: GalleryFolder, + folders: readonly Pick[] +): boolean { + if (!target.subpath) return true + return folders.some( + (entry) => entry.folder === target.folder && cleanSubpath(entry.subpath) === target.subpath + ) +} + +/* ------------------------------------------------------------------------ */ +/* The grid */ +/* ------------------------------------------------------------------------ */ + +/** A card is never narrower than this while a third column could fit. */ +const MIN_CARD_WIDTH = 200 +export const GALLERY_MIN_COLUMNS = 2 +export const GALLERY_MAX_COLUMNS = 8 +/** The cover's height over its width: a wide frame, like the note's own. */ +const COVER_RATIO = 0.56 +/** The title and date under the cover: two lines and their padding. */ +export const GALLERY_CARD_TEXT_HEIGHT = 56 +/** Below this width the grid tightens its margins (a phone, a narrow split). */ +const NARROW_WIDTH = 640 + +export interface GalleryGeometry { + columns: number + /** Space around the grid, inside the scroller. */ + padding: number + /** Space between cards, both ways. */ + gap: number + cardWidth: number + coverHeight: number + cardHeight: number + /** A card row and the gap under it. */ + rowHeight: number +} + +/** + * The grid for a scroller `width` pixels wide. Columns fill the width at no + * less than MIN_CARD_WIDTH each, never fewer than two (a phone shows two + * small cards a row rather than one huge one) and never more than eight. + * Every card has the same height, which is what lets the view mount only + * the rows on screen. + */ +export function galleryGeometry(width: number): GalleryGeometry { + const narrow = width < NARROW_WIDTH + const padding = narrow ? 12 : 24 + const gap = narrow ? 12 : 16 + const inner = Math.max(0, width - padding * 2) + const fit = Math.floor((inner + gap) / (MIN_CARD_WIDTH + gap)) + const columns = Math.min(GALLERY_MAX_COLUMNS, Math.max(GALLERY_MIN_COLUMNS, fit)) + const cardWidth = Math.max(0, (inner - gap * (columns - 1)) / columns) + const coverHeight = Math.round(cardWidth * COVER_RATIO) + const cardHeight = coverHeight + GALLERY_CARD_TEXT_HEIGHT + return { columns, padding, gap, cardWidth, coverHeight, cardHeight, rowHeight: cardHeight + gap } +} + +/** The grid's full height in the scroller: every row, without a gap under + * the last one, inside the padding. */ +export function galleryContentHeight(count: number, geometry: GalleryGeometry): number { + const rows = Math.ceil(Math.max(0, count) / geometry.columns) + if (rows === 0) return 0 + return geometry.padding * 2 + rows * geometry.rowHeight - geometry.gap +} + +export interface GalleryRowRange { + /** First row to mount. */ + start: number + /** One past the last row to mount. */ + end: number +} + +/** The rows a scroller shows at `scrollTop`, plus `overscan` rows each side. */ +export function galleryRowRange( + count: number, + geometry: GalleryGeometry, + scrollTop: number, + viewportHeight: number, + overscan: number +): GalleryRowRange { + const rows = Math.ceil(Math.max(0, count) / geometry.columns) + if (rows === 0) return { start: 0, end: 0 } + const top = Math.max(0, scrollTop - geometry.padding) + const first = Math.min(rows - 1, Math.floor(top / geometry.rowHeight)) + const last = Math.min( + rows - 1, + Math.floor((top + Math.max(0, viewportHeight)) / geometry.rowHeight) + ) + return { + start: Math.max(0, first - overscan), + end: Math.min(rows, last + 1 + overscan) + } +} + +/** The scroll position that shows card `index` whole, moving as little as it + * can: unchanged when the card is in view already. */ +export function galleryScrollTopFor( + index: number, + geometry: GalleryGeometry, + scrollTop: number, + viewportHeight: number +): number { + const row = Math.floor(Math.max(0, index) / geometry.columns) + const cardTop = geometry.padding + row * geometry.rowHeight + const cardBottom = cardTop + geometry.cardHeight + if (cardTop - geometry.padding < scrollTop) return Math.max(0, cardTop - geometry.padding) + if (cardBottom + geometry.padding > scrollTop + viewportHeight) { + return Math.max(0, cardBottom + geometry.padding - viewportHeight) + } + return scrollTop +} + +export type GalleryMove = 'left' | 'right' | 'up' | 'down' | 'first' | 'last' + +/** + * Where the cursor goes. Left and right walk the cards in reading order, so + * they cross rows; up and down keep the column. Down from a column the short + * last row does not reach lands on the last card, so a downward walk always + * gets to the end; any other move past an edge stays put. + */ +export function stepGalleryCursor( + index: number, + count: number, + columns: number, + move: GalleryMove +): number { + if (count <= 0) return -1 + const cols = Math.max(1, Math.floor(columns)) + const at = Math.min(count - 1, Math.max(0, index)) + switch (move) { + case 'first': + return 0 + case 'last': + return count - 1 + case 'left': + return Math.max(0, at - 1) + case 'right': + return Math.min(count - 1, at + 1) + case 'up': + return at - cols >= 0 ? at - cols : at + case 'down': { + if (at + cols < count) return at + cols + const lastRowStart = Math.floor((count - 1) / cols) * cols + return at < lastRowStart ? count - 1 : at + } + } +} + +/* ------------------------------------------------------------------------ */ +/* A card's picture */ +/* ------------------------------------------------------------------------ */ + +/** Wide enough for the widest card on a 2x screen; the full cover asks + * Unsplash for 2400 pixels. */ +export const GALLERY_UNSPLASH_WIDTH = 800 + +/** + * An Unsplash cover at thumbnail width. Unsplash serves any size of a photo + * from its imgix parameters, and its terms want the photo hotlinked, never + * copied, so a smaller `w` is the thumbnail. The rest of the URL (its + * `ixid` among them) stays byte for byte what the note holds. Any other URL + * comes back as it was. + */ +export function unsplashThumbnailUrl(url: string, width: number = GALLERY_UNSPLASH_WIDTH): string { + if (!isUnsplashImageUrl(url)) return url + const base = url.replace(/#.*$/, '') + const w = Math.max(1, Math.round(width)) + if (/[?&]w=\d+/.test(base)) return base.replace(/([?&])w=\d+/, `$1w=${w}`) + return `${base}${base.includes('?') ? '&' : '?'}w=${w}` +} + +export type GalleryThumbnailSource = 'vault' | 'gradient' | 'unsplash' | 'web' + +export type GalleryThumbnail = + | { + kind: 'image' + url: string + /** Vertical focal point, 0 (top) to 1 (bottom): the note's `banner_y`. */ + y: number + source: GalleryThumbnailSource + } + | { kind: 'placeholder' } + +/** + * What a card draws for a note's cover. A vault image is the file itself + * (served by the host, so a remote vault and the web app work the same); a + * built-in gradient whose file the vault cannot hand over yet draws from its + * own SVG; an Unsplash photo comes at thumbnail width; any other https cover + * as it is. No cover, or an image this device cannot find, is the quiet + * placeholder: a card is not where a broken cover gets fixed. + */ +export function galleryThumbnail( + cover: NoteCover | null, + resolveVault: (href: string) => string | null +): GalleryThumbnail { + if (!cover) return { kind: 'placeholder' } + if (cover.target.kind === 'url') { + const unsplash = isUnsplashImageUrl(cover.target.url) + return { + kind: 'image', + url: unsplash ? unsplashThumbnailUrl(cover.target.url) : cover.target.url, + y: cover.y, + source: unsplash ? 'unsplash' : 'web' + } + } + const gradient = builtinGradientForAsset(cover.target.href) + const served = resolveVault(cover.target.href) + if (served) return { kind: 'image', url: served, y: cover.y, source: gradient ? 'gradient' : 'vault' } + if (gradient) return { kind: 'image', url: gradientCoverDataUrl(gradient), y: cover.y, source: 'gradient' } + return { kind: 'placeholder' } +} diff --git a/packages/app-core/src/lib/gallery-covers.test.ts b/packages/app-core/src/lib/gallery-covers.test.ts new file mode 100644 index 000000000..def1ee891 --- /dev/null +++ b/packages/app-core/src/lib/gallery-covers.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest' +import { GalleryCoverCache, runLimited } from './gallery-covers' + +const WITH_COVER = '---\nbanner: "![[assets/lisbon.jpg]]"\nbanner_y: 0.25\n---\n# Lisbon\n' +const WITHOUT_COVER = '# Plain note\n' + +describe('GalleryCoverCache', () => { + it('reads a note once and keeps its cover against that version', async () => { + const cache = new GalleryCoverCache() + const read = vi.fn(async () => WITH_COVER) + const note = { path: 'inbox/a.md', updatedAt: 1 } + expect(cache.peek(note)).toBeUndefined() + const cover = await cache.load(note, read) + expect(cover).toMatchObject({ target: { kind: 'vault', href: 'assets/lisbon.jpg' }, y: 0.25 }) + expect(cache.peek(note)).toEqual(cover) + await cache.load(note, read) + expect(read).toHaveBeenCalledTimes(1) + }) + + it('knows a note without a cover as null, not as unread', async () => { + const cache = new GalleryCoverCache() + const note = { path: 'inbox/b.md', updatedAt: 1 } + await cache.load(note, async () => WITHOUT_COVER) + expect(cache.peek(note)).toBeNull() + }) + + it('reads a note again once it changes', async () => { + const cache = new GalleryCoverCache() + const read = vi + .fn<(path: string) => Promise>() + .mockResolvedValueOnce(WITHOUT_COVER) + .mockResolvedValueOnce(WITH_COVER) + await cache.load({ path: 'inbox/a.md', updatedAt: 1 }, read) + const edited = { path: 'inbox/a.md', updatedAt: 2 } + expect(cache.peek(edited)).toBeUndefined() + expect(await cache.load(edited, read)).not.toBeNull() + expect(read).toHaveBeenCalledTimes(2) + }) + + it('shares one read between cards asking at once', async () => { + const cache = new GalleryCoverCache() + let release!: (body: string) => void + const read = vi.fn(() => new Promise((resolve) => (release = resolve))) + const note = { path: 'inbox/a.md', updatedAt: 1 } + const first = cache.load(note, read) + const second = cache.load(note, read) + release(WITH_COVER) + expect(await first).toEqual(await second) + expect(read).toHaveBeenCalledTimes(1) + }) + + it('treats a note it cannot read as having no cover', async () => { + const cache = new GalleryCoverCache() + const note = { path: 'inbox/gone.md', updatedAt: 1 } + expect(await cache.load(note, async () => Promise.reject(new Error('ENOENT')))).toBeNull() + expect(cache.peek(note)).toBeNull() + }) + + it('starts empty for another workspace, and drops a read that lands after the switch', async () => { + const cache = new GalleryCoverCache() + cache.setScope('local:/vault-a') + const note = { path: 'inbox/a.md', updatedAt: 1 } + await cache.load(note, async () => WITH_COVER) + cache.setScope('remote:https://server') + expect(cache.peek(note)).toBeUndefined() + + let release!: (body: string) => void + const late = cache.load(note, () => new Promise((resolve) => (release = resolve))) + cache.setScope('local:/vault-b') + release(WITH_COVER) + await late + expect(cache.peek(note)).toBeUndefined() + }) + + it('forgets the oldest covers past its limit', async () => { + const cache = new GalleryCoverCache(2) + for (const path of ['a.md', 'b.md', 'c.md']) { + await cache.load({ path, updatedAt: 1 }, async () => WITHOUT_COVER) + } + expect(cache.size).toBe(2) + expect(cache.peek({ path: 'a.md', updatedAt: 1 })).toBeUndefined() + expect(cache.peek({ path: 'c.md', updatedAt: 1 })).toBeNull() + }) +}) + +describe('runLimited', () => { + it('never runs more than the limit at once, and runs every item', async () => { + let running = 0 + let peak = 0 + const done: number[] = [] + await runLimited([1, 2, 3, 4, 5, 6, 7], 3, async (n) => { + running += 1 + peak = Math.max(peak, running) + await new Promise((r) => setTimeout(r, 1)) + running -= 1 + done.push(n) + }) + expect(peak).toBe(3) + expect(done.sort()).toEqual([1, 2, 3, 4, 5, 6, 7]) + }) + + it('takes no new items once cancelled', async () => { + const started: number[] = [] + let cancelled = false + await runLimited( + [1, 2, 3, 4], + 1, + async (n) => { + started.push(n) + if (n === 2) cancelled = true + }, + () => cancelled + ) + expect(started).toEqual([1, 2]) + }) + + it('keeps going past an item that throws', async () => { + const done: number[] = [] + await runLimited([1, 2, 3], 1, async (n) => { + if (n === 2) throw new Error('boom') + done.push(n) + }) + expect(done).toEqual([1, 3]) + }) +}) diff --git a/packages/app-core/src/lib/gallery-covers.ts b/packages/app-core/src/lib/gallery-covers.ts new file mode 100644 index 000000000..0afb0d808 --- /dev/null +++ b/packages/app-core/src/lib/gallery-covers.ts @@ -0,0 +1,118 @@ +/** + * Covers for the Gallery's cards, read on demand. + * + * The vault index (NoteMeta) carries no cover, and giving it one would change + * the desktop scan, the Go server and the phones' vault code. So a card asks + * for its note's cover when it comes on screen: the note is read through the + * same bridge read the editor uses (an open note's buffer, when there is one, + * is fresher and costs nothing) and its frontmatter goes through the shared + * parser. A 1000-note folder reads the twenty notes on screen, not a + * thousand. + * + * Each answer is kept against the note's path and its `updatedAt`, so a note + * edited elsewhere is read again and an unchanged one never is. The cache is + * per workspace (a local vault, a remote server): switching clears it. + */ +import type { NoteMeta } from '@shared/ipc' +import { readNoteCover, type NoteCover } from '@shared/note-cover' + +type CoverNote = Pick + +interface CoverEntry { + updatedAt: number + cover: NoteCover | null +} + +/** Enough for every card of a large folder scrolled end to end; past it the + * oldest answers go first. */ +const DEFAULT_LIMIT = 5000 + +export class GalleryCoverCache { + private entries = new Map() + private pending = new Map>() + private scope = '' + + constructor(private readonly limit: number = DEFAULT_LIMIT) {} + + /** Point the cache at a workspace; a different one starts it empty. */ + setScope(scope: string): void { + if (scope === this.scope) return + this.scope = scope + this.entries.clear() + this.pending.clear() + } + + /** The note's cover when it is known for this version of the note: a cover, + * null for none, undefined while it has not been read. */ + peek(note: CoverNote): NoteCover | null | undefined { + const entry = this.entries.get(note.path) + return entry && entry.updatedAt === note.updatedAt ? entry.cover : undefined + } + + /** + * The note's cover, reading it once. Calls for the same version of a note + * share one read. A note that cannot be read has no cover until it changes: + * a card draws its placeholder rather than asking again on every scroll. + */ + load(note: CoverNote, readBody: (path: string) => Promise): Promise { + const known = this.peek(note) + if (known !== undefined) return Promise.resolve(known) + const key = `${note.path}\0${note.updatedAt}` + const inFlight = this.pending.get(key) + if (inFlight) return inFlight + const scope = this.scope + const read = readBody(note.path) + .then((body) => readNoteCover(body), () => null) + .then((cover) => { + if (this.scope === scope) { + this.pending.delete(key) + this.remember(note, cover) + } + return cover + }) + this.pending.set(key, read) + return read + } + + get size(): number { + return this.entries.size + } + + private remember(note: CoverNote, cover: NoteCover | null): void { + this.entries.delete(note.path) + this.entries.set(note.path, { updatedAt: note.updatedAt, cover }) + while (this.entries.size > this.limit) { + const oldest = this.entries.keys().next().value + if (oldest === undefined) break + this.entries.delete(oldest) + } + } +} + +/** The one cache every Gallery tab shares. */ +export const galleryCoverCache = new GalleryCoverCache() + +/** + * Run `work` over `items`, at most `limit` at a time, in order. Stops taking + * new items once `cancelled()` says so; items already started finish. + */ +export async function runLimited( + items: readonly T[], + limit: number, + work: (item: T) => Promise, + cancelled: () => boolean = () => false +): Promise { + let next = 0 + const worker = async (): Promise => { + while (next < items.length && !cancelled()) { + const item = items[next++] + try { + await work(item) + } catch { + // One failed item does not stop the rest. + } + } + } + const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker) + await Promise.all(workers) +} diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 0e5f8e5cb..48823d901 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -129,7 +129,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Turn a CSV into a database', body: - 'Run “New Database” from the command palette (or right-click a folder in the sidebar → New database) to create one, or just open an existing `.csv` file from the vault. ZenNotes stores a database as a `.base` folder: `data.csv` (the rows), `schema.json` (field types, select options, saved views), and the record-page notes rows open into. A database opens from its sidebar row like a note: click it, or press Enter or `l` on it in Vim mode (the row’s `m` menu carries Open database too); the chevron still expands the folder to browse its record pages, and the open grid closes like any tab. Edit cells inline in the Table view, group records in a Board by any select field, switch the raw-CSV toggle to see the underlying file, and press `o` on a row to open it as a full Markdown page whose frontmatter mirrors the row’s properties. The whole grid is keyboard-driven. See the Database grid shortcuts.' + 'Run “New Database” from the command palette (or right-click a folder in the sidebar → New database) to create one, or just open an existing `.csv` file from the vault. ZenNotes stores a database as a `.base` folder: `data.csv` (the rows), `schema.json` (field types, select options, saved views), and the record-page notes rows open into. A database opens from its sidebar row like a note: click it, or press Enter or `l` on it in Vim mode (the row’s `m` menu carries Open database too); the chevron still expands the folder to browse its record pages, and the open grid closes like any tab. Edit cells inline in the Table view, group records in a Board by any select field, switch the raw-CSV toggle to see the underlying file, and open a row as a full Markdown page whose frontmatter mirrors the row’s properties (its Open button, Open page in its menu, or `o` in Vim mode). The whole grid is keyboard-driven. See the Database grid shortcuts.' }, { title: 'Set the width of a table column', @@ -151,6 +151,11 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ body: 'The cover picker has a second tab, Unsplash, that searches Unsplash’s free photos with an access key of your own. The first time, the tab says how to get one: at unsplash.com/developers, open Your apps, create a New Application (a free Unsplash account is enough) and copy its Access Key; then press Enter on Open Settings and paste the key into Settings → Typography → Covers → Unsplash access key (“Set Unsplash Access Key…” in the command palette goes there too). Save checks the key with one request to Unsplash and keeps it unless Unsplash refuses it, Test asks again and says how many of the hour’s requests are left, and Clear removes it. The desktop app keeps the key in your system’s secure storage and the web client in your browser, never in `config.toml` or the vault, and sends it only to api.unsplash.com: searches go from your machine straight to Unsplash, and zennotes.org takes no part. A new Unsplash app allows 50 requests an hour until Unsplash approves it for production; when they run out the tab says so, and if Unsplash stops accepting the key it offers Change key. Press Tab in the picker to switch between Covers and Unsplash (what you typed comes along), press `Space l u` in Vim mode, run “Add Cover from Unsplash…” from the command palette, or type `:cover unsplash mountains` to open the tab on a search. Photos appear as you type; move with the arrow keys (or Ctrl+N/P, Ctrl+J/K) and press Enter to make the selected one the cover. The line under the grid credits the selected photo’s photographer, with links to their profile and to Unsplash. Unsplash’s rules keep its photos on Unsplash, so unlike a gradient or a gallery photo the picture is not copied into `assets/`: the note links to it, `banner: "https://images.unsplash.com/…"`, with the credit on the two lines under it, `banner_credit` (the photographer) and `banner_credit_url` (their Unsplash profile). In the editor the three lines fold into one, `cover · Unsplash photo by …`, until the cursor moves onto it. Hover the cover to see “Photo by … on Unsplash” with both links; a published note’s page always shows it. The picture needs a connection to load. One you have seen before usually still shows offline; otherwise the cover says it could not load the picture from images.unsplash.com. To keep a photo for offline use, download it from unsplash.com and drop it on the picker. The Covers tab never goes online. Choosing another cover or removing this one takes the credit away with it.' }, + { + title: 'See a folder as a gallery of its covers', + body: + 'The Gallery shows one folder’s notes as a grid of cards: each card leads with the note’s cover (an image in the vault, an Unsplash photo or a gradient, cropped to fill the card at the cover’s focal point), then its title and date. A note without a cover gets a quiet card with the first letter of its title. Right-click a folder in the sidebar and choose Open as Gallery, run Open Folder Gallery from the command palette, press `Space G` in Vim mode, or type `:gallery`. The palette, `Space G` and a bare `:gallery` open the folder under the sidebar cursor while the sidebar has focus, and the open note’s folder otherwise; `:gallery Projects/Website` names a folder (a path in the vault, a folder in your notes, or a system folder such as `archive`). Each folder opens as a tab of its own, named after the folder. The header shows how many notes there are, a Sort menu (Updated, the default, puts the last edited first; Created puts the newest first; Title goes A to Z), and an Include subfolders switch, off by default, that adds the notes in the folder’s subfolders. Both choices apply to every Gallery and are kept in `config.toml` as `gallery_sort` and `gallery_include_subfolders` under `[view]`. A database’s record pages and drawings are left out. In Vim mode h / j / k / l move across the grid, gg / G jump to the first and last card, Enter or `o` opens the note and / filters the cards by title; with Vim mode off the arrow keys move, Enter opens and you click into the filter to type. Escape clears the filter. Click a card to open its note; Ctrl+O comes back to the Gallery, on the card you opened. Only the cards on screen are drawn, and a note is read for its cover only when its card scrolls into view, so a folder of thousands of notes opens at once. It works the same in a vault on a self-hosted server and in the web app, and at phone width it keeps two columns.' + }, { title: 'Give a vault a name of its own', body: @@ -179,7 +184,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'Ctrl, Alt and Cmd keys in list views', body: - 'An action in a list view (Archive, Trash, Quick Notes, Tags, Tasks, Unresolved Links, Workflows, the Timeline) can take a chord such as `"nav.delete" = "Ctrl+Backspace"` or `"nav.delete" = "Mod+Backspace"` in `config.toml`, or from Settings → Keymap, and so can the sidebar’s Delete selected result. Unlike the single letters, such a chord works with Vim mode on or off. `Mod` means ⌘ on macOS and Ctrl on Windows and Linux, so one config.toml serves both. A global shortcut keeps its key everywhere, though: an action bound to Mod+P would lose to Search notes, so Settings marks such a binding “Never fires” and names the shortcut that has the key.' + 'An action in a list view (Archive, Trash, Quick Notes, Tags, Tasks, Unresolved Links, a folder’s Gallery, Workflows, the Timeline) can take a chord such as `"nav.delete" = "Ctrl+Backspace"` or `"nav.delete" = "Mod+Backspace"` in `config.toml`, or from Settings → Keymap, and so can the sidebar’s Delete selected result. Unlike the single letters, such a chord works with Vim mode on or off. `Mod` means ⌘ on macOS and Ctrl on Windows and Linux, so one config.toml serves both. A global shortcut keeps its key everywhere, though: an action bound to Mod+P would lose to Search notes, so Settings marks such a binding “Never fires” and names the shortcut that has the key.' }, { title: 'Read a note beside its source', @@ -256,6 +261,11 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ body: 'Settings → Appearance → Custom → New theme scaffolds a folder at `~/.config/zennotes/themes//` with a `manifest.json` and a `theme.css`, reveals it, and adds a card you click to apply. Edits to `theme.css` apply live. Only the active theme CSS is loaded, so write `:root { … }` for the light/shared values and `:root[data-theme-mode="dark"] { … }` for dark. You never put the theme name in a selector. Colors are the `--z-*` tokens, written as space-separated RGB (`--z-accent: 255 59 48;`): backgrounds `--z-bg` / `--z-bg-softer` / `--z-bg-1`…`--z-bg-4`, text `--z-fg-1` / `--z-fg-2` / `--z-grey-0`…`--z-grey-2`, accent `--z-accent` / `--z-accent-soft` / `--z-accent-muted`, and the syntax hues `--z-red` / `--z-green` / `--z-yellow` / `--z-blue` / `--z-purple` / `--z-aqua`. `manifest.json` carries name, author, version, description, `modes` (light | dark | both), and an optional preview swatch.' }, + { + title: 'Set a code block’s language', + body: + 'In Edit mode every fenced code block carries its language at the top right, as a button reading `typescript ▾` (or `text ▾` when the fence names none), with a copy button beside it. Press the language to open the “Code block language” picker: a find field, then every language the editor can highlight, by name, the current one checked. Type to narrow the list: a language’s tags count, so `golang`, `yml` or `sh` find Go, YAML and Shell, and an exact name comes first, so `c` then Enter is C. A pick rewrites only the opening fence’s language word, so ```` ``` ```` becomes ```` ```typescript ````, keeping the fence’s backticks or tildes, their count and anything after the word, such as a title; the cursor stays where it was, and one Undo takes it back. “Text (plain)” takes the language away. Languages you installed under Settings → Editor → Languages are listed too. A block that names no language is guessed at, when it is long enough to tell: it is highlighted with the guess and its button reads `python · detected`, muted, in a dashed outline. The guess is never written to the note; the picker’s first row, “Python (detected)”, writes it. Tiny or ambiguous blocks stay text, and the reading view colours exactly the blocks the editor guesses, in the same language. From the keyboard, run “Set Code Block Language…” from the command palette, or press `Space l l` in Vim mode, with the cursor in a block; it can take a key of its own (Alt+Enter on its row). In the picker the arrows, Ctrl+N / Ctrl+P, Enter and Escape work with Vim mode on or off; with Vim mode off the find field has the keyboard and every letter is text, and with it on the list does, where j / k move and `/` goes to the find field. Turn the guessing off with Settings → Editor → Detect code block languages.' + }, { title: 'Add syntax highlighting for another language', body: @@ -447,7 +457,12 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Callouts highlight the important bits', body: - 'Turn a blockquote into a colored callout (an Obsidian-style admonition) by starting its first line with `> [!type]`, optionally followed by a title: `> [!warning] Heads up`. Typing `[!` inside a blockquote opens an insert menu of the callout types: filter by name (aliases match too, so `warn` finds Warning and `tldr` finds Abstract), move with the arrow keys or the Vim/Emacs completion chords (Ctrl+J / Ctrl+K, Ctrl+N / Ctrl+P), and press Enter, Tab, Ctrl+Y, or click to drop in the syntax. The type sets the color: `note`, `info`, `abstract` / `summary` / `tldr` render blue; `tip` / `hint` / `important` and `success` / `check` / `done` green; `question` / `help` / `faq` and `example` purple; `warning` / `caution` / `attention` yellow; `danger` / `error`, `bug`, and `failure` / `fail` red; and `quote` / `cite` a neutral gray. Types are case-insensitive, and an unrecognized one still renders as a neutral note, so callouts pasted in from Obsidian keep working. Put `-` or `+` right after the type to make a callout foldable, as in Obsidian: `> [!example]- Screenshots` starts collapsed and `> [!example]+ Screenshots` starts expanded. Click the title row in Preview, or the chevron after the title in Edit mode, to open or close it; the fold keys (zc and zo, or Ctrl+Alt+F and Ctrl+Alt+U, Cmd+Option+F and Cmd+Option+U on macOS) work on it from its title line or any line inside it, and Fold All (zM) includes it. A collapsed callout starts closed each time the note opens and prints open in a PDF export; folding never changes the note.' + 'Turn a blockquote into a colored callout (an Obsidian-style admonition) by starting its first line with `> [!type]`, optionally followed by a title: `> [!warning] Heads up`. Typing `[!` inside a blockquote opens an insert menu of the callout types: filter by name (aliases match too, so `warn` finds Warning and `tldr` finds Abstract), move with the arrow keys or the Vim/Emacs completion chords (Ctrl+J / Ctrl+K, Ctrl+N / Ctrl+P), and press Enter, Tab, Ctrl+Y, or click to drop in the syntax. The type sets the color: `note`, `info`, `abstract` / `summary` / `tldr` render blue; `tip` / `hint` / `important` and `success` / `check` / `done` green; `question` / `help` / `faq` and `example` purple; `warning` / `caution` / `attention` yellow; `danger` / `error`, `bug`, and `failure` / `fail` red; and `quote` / `cite` a neutral gray. Types are case-insensitive, and an unrecognized one still renders as a neutral note, so callouts pasted in from Obsidian keep working. Put `-` or `+` right after the type to make a callout foldable, as in Obsidian: `> [!example]- Screenshots` starts collapsed and `> [!example]+ Screenshots` starts expanded. Click the title row in Preview, or the chevron after the title in Edit mode, to open or close it; the fold keys (zc and zo, or Ctrl+Alt+F and Ctrl+Alt+U, Cmd+Option+F and Cmd+Option+U on macOS) work on it from its title line or any line inside it, and Fold All (zM) includes it. A collapsed callout starts closed each time the note opens and prints open in a PDF export; folding never changes the note. To make one without typing the syntax, or to change or remove one, use the callout menu (next card).' + }, + { + title: 'Turn text into a callout from the menu', + body: + 'Right-click text in the editor and choose “Turn into callout…”. A second menu opens in the same spot, “Callout type”, listing every type with its icon: Note, Info, Abstract, Tip, Success, Question, Example, Warning, Danger (Error), Bug, Failure and Quote. An Error callout is a Danger one, since `error` is its alias. Move with the arrow keys and press Enter, click, or type to filter: aliases count, so `error`, `caution` or `tldr` find their type. Selected lines become the callout whole, line by line. With nothing selected, the paragraph under the pointer does, a whole list when it is in one. A plain blockquote just gains the `> [!type]` line on top. A callout made from part of a paragraph, or right beside a quote, gets a blank line so it stands on its own. Right-click inside a callout, written out or drawn as a card in Live Preview, for “Change callout type…” and “Remove callout”. Change type checks the current type, and keeps the title and any `+` or `-` fold marker. Remove drops the title line, keeps a custom title as a plain first line, and takes one level of `>` off the text, so a quote or callout nested inside stays as it was. From the keyboard, run “Set Callout Type…” from the command palette, or press `Space l a` in Vim mode, to open the same menu at the cursor. “Remove Callout” is in the palette too, and either command can take a key of its own (Alt+Enter on its row). In Vim mode, `m` or Shift+F10 on a selection opens the whole right-click menu. Each change is one step for Undo. Code blocks and the note’s properties never become callouts: there the menu shows “Turn into callout…” greyed out with the reason, and the command says why. With Vim mode off, the menus answer to the arrow keys, Enter and Escape, and letters filter.' }, { title: '@ inserts dates and links notes', @@ -502,7 +517,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Any CSV is a database', body: - 'A `.csv` file in your vault is a full Notion-style database, with zero new dependencies. The same data shows up as an editable Table (inline cell editing) and as a Board grouped by a select field; add and switch views freely. Fields are typed as text, number, checkbox, date, select, multi-select, or note link (single or multi), and support sort, filter, and a raw-CSV toggle, while every row keeps a stable id so external edits round-trip cleanly. Open any row as a real Markdown note, a “record page” in a per-database folder, whose frontmatter mirrors the row’s properties and whose body is a freeform page. A record can be linked once it has a page; to give every record one at once (a database converted from a CSV starts with none), choose Create pages for all records in the database’s ⋯ menu or the command palette, or press `O` in the grid in Vim mode. Create one with “New Database” in the command palette or by right-clicking a folder → New database.' + 'A `.csv` file in your vault is a full Notion-style database, with zero new dependencies. The same data shows up as an editable Table (inline cell editing) and as a Board grouped by a select field; add and switch views freely. Fields are typed as text, number, checkbox, date, select, multi-select, or note link (single or multi), and support sort, filter, and a raw-CSV toggle, while every row keeps a stable id so external edits round-trip cleanly. Each saved view keeps its own columns and rows, so one wide CSV can carry several narrow views. A table view’s Columns button, beside Filter, reads “Columns 5 of 44” once some are hidden and opens a checklist of the view’s columns with a find field, Show all and Hide all (press `c` in the grid in Vim mode); a column’s ⋯ menu has Hide column too, and the last visible column always stays. The + beside the view tabs makes a New table view (which opens Columns straight away), a New board view, or a duplicate of this view with its filters, sorts, columns and picked rows; a view tab’s right-click menu has Duplicate view as well. To show only certain rows, select them (the row checkboxes, Space in the grid, or x in Vim mode) and choose Show only these in this view: the view then keeps those picked rows, applies its filters and sorts to them, and says “Showing N picked rows” under the header, with Edit picks and Show all rows. A row you add in that view joins its picks, a deleted row simply drops out, and a Board view shows its picked rows the same way. The command palette has Choose Database Columns, New Table View, Duplicate View, Show Only Selected Rows and Show All Rows, and each can take a key of its own. Open any row as a real Markdown note, a “record page” in a per-database folder, whose frontmatter mirrors the row’s properties and whose body is a freeform page. A record can be linked once it has a page; to give every record one at once (a database converted from a CSV starts with none), choose Create pages for all records in the database’s ⋯ menu or the command palette, or press `O` in the grid in Vim mode. Create one with “New Database” in the command palette or by right-clicking a folder → New database.' }, { title: 'Databases discover your notes', @@ -642,6 +657,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Ctrl+V', action: 'Visual block', detail: 'Select a rectangle across rows; `I`, `A` and `c` apply the edit to every row of the block, short rows included.' }, { keys: 'Space p', action: 'Note outline', detail: 'Jump to any heading in the active note via a searchable overlay.' }, { keys: 'Space u', action: 'Unresolved links', detail: 'Open the Unresolved Links view: every link in the vault to a note that does not exist yet, with the notes and lines that link to it. Enter on a name creates the note, Enter on a line opens the note at the link, and K writes the missing note in the link popover. Also `:unresolved`.' }, + { keys: 'Space G', action: 'Folder gallery', detail: 'Open the Gallery of the folder under the sidebar cursor (while the sidebar has focus) or of the open note’s folder: its notes as cards led by their covers. h / j / k / l move, Enter opens, / filters by title. A capital G, since `Space g` is the Atlas.' }, { keys: 'Space v', action: 'Switch vault', detail: 'Open the command palette directly to the local vault switcher.' }, { keys: 'Space a', action: 'Open workflows', detail: 'Open the Workflows view, where saved pipelines over your notes are built and run. Workflows are off by default; turn them on under Settings → Workflows first.' }, { keys: 'Space r', action: 'Review Cloud conflicts', detail: 'Open whatever Cloud sync is waiting on: the queue of files two devices changed at once, one decision at a time, or the vault settings question, which lists each setting that differs with this device’s value beside the cloud’s and lets you pick a side per setting. When the open note is one of the waiting files, the queue starts on it. A waiting note also says so across its top, in Edit and Preview alike: sync is paused for it until you choose what to keep, and its Review button opens the queue on it. Choosing the other device’s version puts the file where that device left it; when this device already has a different file there, that file moves beside it under the next free name, such as Untitled 2.md. A notification with Review appears once when a new conflict arrives. When the Cloud vault this vault syncs with is deleted, on this device, another device or the website, the status bar says Cloud vault deleted and the binding opens Settings → Cloud instead, where This vault says what happened until you choose a cloud vault, start a new one, or dismiss the notice. If another device moved it into an end-to-end encrypted vault, the status bar says Cloud vault now encrypted instead, and This vault asks you to unlock this device if needed, then choose the encrypted vault. The binding and the command palette entry appear only while something is waiting, and open the same place as Review in the status bar.' }, @@ -654,6 +670,8 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Space l s', action: 'Toggle favorite', detail: 'Add or remove the active note from the Favorites section shown in the sidebar and on the home view. Folders join it from their context menu.' }, { keys: 'Space l c', action: 'Cover image', detail: 'Open the cover picker for the active note: a gradient, a gallery photo, an image in the vault, an upload, a pasted image or an https link. Also “Add Cover…” in the command palette and `:cover`.' }, { keys: 'Space l u', action: 'Cover from Unsplash', detail: 'Open the cover picker on its Unsplash tab to search free photos for the active note’s cover. Also “Add Cover from Unsplash…” in the command palette and `:cover unsplash {search}`.' }, + { keys: 'Space l a', action: 'Callout type', detail: 'Open the callout type menu at the cursor: pick a type to turn the paragraph or the selected lines into a callout, or to change the type of the callout the cursor is in. Also “Set Callout Type…” in the command palette, and “Turn into callout…” or “Change callout type…” in the right-click menu, where “Remove callout” sits too.' }, + { keys: 'Space l l', action: 'Code block language', detail: 'Open the code block language picker for the code block the cursor is in: pick a language to write after its opening fence, “Text (plain)” to take it away, or the detected guess at the top to keep it. Also “Set Code Block Language…” in the command palette, and the language button at the top right of each block in Edit mode.' }, { keys: 'Space l d', action: 'Convert table to database', detail: 'Turn the Markdown table under the cursor (or the rendered table whose cell you are in) into a `.base` database and leave a `[[link]]` to it in the note. Also “Convert Table to Database…” in the command palette, `:table_to_database`, and the bottom of the table cell menu.' }, { keys: 'Space, then pause', action: 'Show leader hints', detail: 'If enabled in Settings, open a which-key style guide for the next available leader actions. Sticky mode keeps it open until `Space` or `Esc`.' }, { keys: 'Mod+3', action: 'Toggle outline panel', detail: 'Show or hide the persistent outline in the active pane. Once focused (Ctrl+W l or Alt+L from the editor), j / k (or the arrows) walk the headings, gg / G jump to the first and last, Enter jumps the editor to the heading under the cursor, and Esc hands focus back.' }, @@ -793,6 +811,19 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: '/', action: 'Filter', detail: 'Dim every note not matching a title or tag search, live.' } ] }, + { + id: 'gallery-view', + title: 'Gallery view', + description: + 'A folder’s notes as cards, led by their covers. The view runs its own keyboard loop in the main pane, and the Vim leader works here too. The single-letter keys are Vim-mode only; with Vim mode off the arrows, Enter and Escape do the work, and typing into the filter field always works.', + items: [ + { keys: 'h / j / k / l', action: 'Move across the grid', detail: 'Left and right walk the cards in reading order, from one row to the next; up and down keep the column. Down from a column the last row does not reach lands on the last card. The arrow keys do the same with Vim mode on or off.' }, + { keys: 'g g / G', action: 'First or last card', detail: 'Jump to the first or the last card in the current order.' }, + { keys: 'Enter / o', action: 'Open the note', detail: 'Open the note under the cursor and move into the editor. Ctrl+O comes back to the Gallery, on that card. A click opens it too.' }, + { keys: '/', action: 'Filter by title', detail: 'Move into the filter field: only cards whose title holds every word you type stay. Enter or ↓ goes back to the grid with the filter kept.' }, + { keys: 'Esc', action: 'Clear the filter', detail: 'Clears an active filter, in the field or on the grid.' } + ] + }, { id: 'workflows-view', title: 'Workflows view', @@ -818,24 +849,24 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { id: 'database-grid', title: 'Database grid (Table view)', - description: 'Vim-style motions when a CSV database table has focus. The grid yields to these keys so they do not collide with global motions.', + description: 'Keys for a CSV database table while its grid has focus; the grid takes them before any global motion. The single-letter keys (h / j / k / l, H / L, 0 / ^ / $, g g / G, i, x, o, O, c, m, a, d d) and Ctrl-o / Ctrl-w belong to Vim mode, like every list in the app. With Vim mode off the arrows, Enter, Space, Esc and Shift+F10 (or the Context Menu key) stay live, and the rest is in the row and column menus, the header buttons and the command palette.', items: [ - { keys: 'h / j / k / l', action: 'Move the cell cursor', detail: 'Arrow keys also work. 0 / ^ jump to the first column, $ to the last.' }, - { keys: 'H / L', action: 'Move the current column left / right', detail: 'Reorders columns from the keyboard and the cursor follows. You can also drag a column header, or use “Move left / Move right” in the field menu (⋯).' }, - { keys: 'g g / G', action: 'Jump to first / last row', detail: 'Fast travel within the current column.' }, - { keys: 'k (into header)', action: 'Rename a field', detail: 'Press k up onto the column-header row, then Enter / i to rename the field, or m to open its column menu.' }, - { keys: 'i / Enter', action: 'Edit the cell', detail: 'On a checkbox cell this toggles it instead of opening an editor.' }, + { keys: '↑ / ↓ / ← / →', action: 'Move the cell cursor', detail: 'In either mode. ↑ from the first row reaches the column-header row, where Enter renames the field and Shift+F10 opens its column menu. In Vim mode h / j / k / l move too, k climbs onto the header the same way, and 0 / ^ jump to the first column, $ to the last.' }, + { keys: 'Enter', action: 'Edit the cell', detail: 'In either mode; i does it too in Vim mode. On a checkbox cell this toggles it instead of opening an editor, and on the column header it renames the field.' }, + { keys: 'Space', action: 'Select the row', detail: 'In either mode; x does it too in Vim mode. On a checkbox cell Space toggles the checkbox instead. Selected rows get the selection bar: Delete, or Show only these in this view, which makes the selection the view’s picked rows (also Show Only Selected Rows in the command palette). The view then says “Showing N picked rows” under the header, with Edit picks and Show all rows.' }, + { keys: 'Shift+F10', action: 'Open the row menu', detail: 'In either mode, or the Context Menu key; m does it too in Vim mode. On a cell it is the record’s right-click menu (Open page, Delete row, and the rest); with rows selected it offers Show only selected rows, and in a view showing picked rows, Remove from this view’s picks. On the column header it is the column menu, with Hide column and Choose columns….' }, + { keys: 'Esc', action: 'Clear selection / leave the grid', detail: 'In either mode. Leaves Edit picks without changing them, else clears a multi-row selection, then blurs the grid.' }, { keys: '[[ (in a text cell)', action: 'Link a note from the cell', detail: 'While editing a text cell, `[[` opens the same note search as the editor; ↑/↓ or Ctrl+N/P move, Enter inserts the `[[wikilink]]`, Escape closes the list and keeps typing.' }, { keys: '↑ / ↓ · Enter (in a picker)', action: 'Pick options and notes', detail: 'Select, Multi-select, and Note link popovers are searchable lists: type to filter, arrows or Ctrl+N/P move the highlight, Enter picks it (with nothing highlighted, Enter adds the typed text as a new option), Escape closes.' }, - { keys: 'm', action: 'Open the row menu', detail: 'On a cell, open the right-click menu for that record (Open, Delete, and the rest).' }, - { keys: 'Space / x', action: 'Select the row', detail: 'Toggle the row’s selection for bulk actions.' }, - { keys: 'o', action: 'Open the record page', detail: 'Open the row as a Markdown note in the per-database folder.' }, - { keys: 'O', action: 'Create pages for all records', detail: 'Give every record that has no page its note in the per-database folder, so each one can be linked with [[wikilinks]]. Records that already have a page keep it, a page file already in the folder under a record’s name is linked instead of copied, and rows with an empty title are left out. Asks first. Also in the database’s ⋯ menu and the command palette.' }, - { keys: 'Ctrl-o', action: 'Jump back to the grid', detail: 'From a record page, return to the database grid, whether you opened it as a .csv file or via New Database.' }, - { keys: 'Ctrl-w k / j / h / l', action: 'Move to tabs or panes', detail: 'Move between the grid, the tab strip, and split panes, exactly like from the editor.' }, - { keys: 'a', action: 'Add a row', detail: 'Append a new empty record and move the cursor to it.' }, - { keys: 'd d', action: 'Delete the row', detail: 'Remove the record at the cursor.' }, - { keys: 'Esc', action: 'Clear selection / leave the grid', detail: 'Clears a multi-row selection first, then blurs the grid.' } + { keys: 'H / L', action: 'Move the current column left / right', detail: 'Vim mode. Reorders columns from the keyboard and the cursor follows. In either mode you can also drag a column header, or use Move left / Move right in the column menu.' }, + { keys: 'g g / G', action: 'Jump to first / last row', detail: 'Vim mode. Fast travel within the current column.' }, + { keys: 'c', action: 'Choose the columns this view shows', detail: 'Vim mode. Opens the Columns popover: j / k move, Space or x shows or hides the column, / jumps to the find field, Escape closes. With Vim mode off, use the Columns button beside Filter: the arrows move, Space or Enter toggles. Each saved view keeps its own columns, and the last visible one always stays. Also Choose Database Columns in the command palette.' }, + { keys: 'o', action: 'Open the record page', detail: 'Vim mode. Open the row as a Markdown note in the per-database folder. In either mode, Open page is in the row menu and on the row itself.' }, + { keys: 'O', action: 'Create pages for all records', detail: 'Vim mode. Give every record that has no page its note in the per-database folder, so each one can be linked with [[wikilinks]]. Records that already have a page keep it, a page file already in the folder under a record’s name is linked instead of copied, and rows with an empty title are left out. Asks first. In either mode it is also in the database’s ⋯ menu and the command palette.' }, + { keys: 'a', action: 'Add a row', detail: 'Vim mode. Append a new empty record and move the cursor to it; in either mode New row under the table and + Row in the header do the same. In a view showing picked rows the new row joins the picks, so it stays in view.' }, + { keys: 'd d', action: 'Delete the row', detail: 'Vim mode. Remove the record at the cursor. In either mode Delete row is in the row menu, and Delete in the selection bar removes the selected rows.' }, + { keys: 'Ctrl-o', action: 'Jump back to the grid', detail: 'Vim mode. From a record page, return to the database grid, whether you opened it as a .csv file or via New Database.' }, + { keys: 'Ctrl-w k / j / h / l', action: 'Move to tabs or panes', detail: 'Vim mode. Move between the grid, the tab strip, and split panes, exactly like from the editor.' } ] } ] @@ -1051,6 +1082,11 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Open the link popover for the link under the cursor', detail: 'Opens the note the `[[wikilink]]` under the cursor names in a floating editor over this note, with the keyboard in it; a link to a note that does not exist yet offers Create note, and the new note opens there ready for typing. `K` on a link inside the popover moves on to that note. Escape in normal mode closes it and puts the cursor back on the link. Vim\'s own `K` looks up the word under the cursor; this one looks up the note. Rebind it as `vim.linkPopover`.' }, + { + command: ':gallery [folder]', + summary: 'Open a folder as a Gallery', + detail: 'Opens the Gallery of the open note’s folder: its notes as cards led by their covers. With a folder, opens that one instead, given as a path in the vault (`:gallery inbox/Projects`), a folder in your notes (`:gallery Projects/Website`) or a system folder (`:gallery archive`). `:gal` for short; `Space G` and Open Folder Gallery in the palette do the same.' + }, { command: ':unresolved', summary: 'Open the Unresolved Links view', @@ -1247,6 +1283,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Live preview', detail: 'Hide markdown syntax on lines you are not actively editing. Also draws math, tables, and `mermaid` and `d2` diagrams in place; each turns back into its source when the cursor enters it.' }, { label: 'Math size', detail: 'Scales inline `$…$` and block `$$…$$` math relative to the surrounding text, 50 to 200 percent, in the editor and the reading view, for both KaTeX and Typst; `math_font_scale` under `[editor]` in `config.toml`.' }, { label: 'Render tables in live preview', detail: 'Show Markdown tables as interactive WYSIWYG widgets (edit cells, drag, right-click/`m` menu). Turn it off to keep tables as plain markdown text so you can edit them with the keyboard and Vim motions like any other line. When widgets are on, Arrow keys (and h/j/k/l) navigate cells; Shift+V then Shift+J/Shift+K move whole lines in the raw source. In Vim mode the cell cursor speaks the editor\'s language: motions (w/b/e, f/t, 0/$), operators (d/c/y with motions and text objects), visual selections, and yank/paste (y/p/P) through the same registers as the rest of the note, so you can yank in a cell and paste in the body, or the other way around. Shift+V selects whole rows: y takes them as Markdown table rows (from the header, with its divider row), so p in the note pastes a working table, and ⌘C (Ctrl+C on Windows and Linux) copies a selection without changing it. The `m` menu can also copy the whole table as Markdown or duplicate it below. Typing `[[` in a cell opens the same note picker as in a paragraph, with the same keys, and `|` starts the display text.' }, + { label: 'Detect code block languages', detail: 'On by default. Guess the language of a code block that names none, when it is long enough to tell, and highlight it with the guess in Edit mode and the reading view alike; Edit mode labels the block `python · detected`. The guess is never written to the note: pick it from the block’s language button to keep it. Off, such a block is plain text in both. `detect_code_languages` under `[editor]` in config.toml.' }, { label: 'Sync title heading on rename', detail: 'On by default. A new note is created as `# `, and with this on a rename carries that heading along: rename `Untitled` to `Groceries` and line one becomes `# Groceries`, from the breadcrumb, the sidebar, or the note list alike. Only an existing top-level `#` heading is rewritten and one is never invented, so a note that opens with prose, a list, or a `##` heading is untouched; deleting the `#` line opts that note out permanently. The heading is found after any frontmatter, and the rest of the note is left byte for byte as it was.' }, { label: 'Grammar and spelling with Harper', detail: 'Off by default. Turn it on to underline grammar and spelling problems as you write, checked on this device by Harper (writewithharper.com); no text leaves the app. Hover a mark for the fixes, or in Vim mode press z= at the problem, ]s and [s to move between problems, zg to add a word to this vault\'s dictionary and zG to ignore one suggestion. Choose the English dialect below the toggle. Code, links and frontmatter are never checked, and notes past 120,000 characters are skipped.' }, { label: 'Heading level labels', detail: 'Show H1 through H6 badges before headings. Heading fold arrows stay available whether labels are on or off.' }, diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index d9af1ac0a..db02280cc 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -71,6 +71,8 @@ export type KeymapId = | "vim.leaderTableToDatabase" | "vim.leaderNoteCover" | "vim.leaderNoteCoverUnsplash" + | "vim.leaderCalloutType" + | "vim.leaderCodeLanguage" | "vim.leaderQuickCapture" | "vim.leaderHideToTray" | "vim.leaderTemplatePicker" @@ -81,6 +83,7 @@ export type KeymapId = | "vim.leaderCalendar" | "vim.leaderTimeline" | "vim.leaderUnresolvedLinks" + | "vim.leaderFolderGallery" | "vim.panePrefix" | "vim.paneFocusLeft" | "vim.paneFocusDown" @@ -765,6 +768,32 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ vimOnly: true, maxTokens: 1, }, + { + id: "vim.leaderCalloutType", + kind: "sequence", + scope: "leader", + group: "vim", + title: "Leader note action: callout type", + // `c` is the cover; `a` for admonition, and for the alerts GitHub calls them. + description: + "Open the callout type menu at the cursor: pick a type to turn the paragraph or the selected lines into a callout, or to change the type of the callout the cursor is in.", + defaultBinding: "a", + vimOnly: true, + maxTokens: 1, + }, + { + id: "vim.leaderCodeLanguage", + kind: "sequence", + scope: "leader", + group: "vim", + title: "Leader note action: code block language", + // `l` for language: the note group's own key, pressed twice. + description: + "Open the code block language picker for the code block the cursor is in: pick a language to write after its fence, or Text (plain) to take it away.", + defaultBinding: "l", + vimOnly: true, + maxTokens: 1, + }, { id: "vim.leaderQuickCapture", kind: "sequence", @@ -880,6 +909,20 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ vimOnly: true, maxTokens: 1, }, + { + id: "vim.leaderFolderGallery", + kind: "sequence", + scope: "leader", + group: "vim", + title: "Leader: folder gallery", + // `G` for Gallery: `g` was the Atlas already, and a capital is a key of + // its own after the leader (`<leader>q` and `<leader>Q` differ). + description: + "Open the Gallery of the current folder: its notes as cards led by their covers. The folder under the sidebar cursor while the sidebar has focus, otherwise the open note's folder.", + defaultBinding: "G", + vimOnly: true, + maxTokens: 1, + }, { id: "vim.panePrefix", kind: "sequence", diff --git a/packages/app-core/src/lib/list-view-keys.ts b/packages/app-core/src/lib/list-view-keys.ts index c3c9d6c3d..a8b087d87 100644 --- a/packages/app-core/src/lib/list-view-keys.ts +++ b/packages/app-core/src/lib/list-view-keys.ts @@ -1,7 +1,7 @@ /** * How the list views (Archive, Trash, Quick Notes, Tags, Tasks, Unresolved - * links, Workflows, the Timeline) read a key against their own actions' - * bindings. + * links, the Gallery, Workflows, the Timeline) read a key against their own + * actions' bindings. * * Each of them used to open with `if (e.metaKey || e.ctrlKey || e.altKey) * return`, so the app's chords went past them untouched. That also threw diff --git a/packages/app-core/src/lib/markdown-settings.ts b/packages/app-core/src/lib/markdown-settings.ts index 7cfacd337..50a06d73f 100644 --- a/packages/app-core/src/lib/markdown-settings.ts +++ b/packages/app-core/src/lib/markdown-settings.ts @@ -1,9 +1,9 @@ /** - * The two render settings the preview pipeline reads, kept in their own module + * The render settings the preview pipeline reads, kept in their own module * so setting them does not drag the renderer in. * - * `App.tsx` pushes the `mathRenderer` and `looseMathDelimiters` prefs down on - * every change. When those setters lived in `lib/markdown`, that one import made + * `App.tsx` pushes the `mathRenderer`, `looseMathDelimiters` and + * `detectCodeLanguages` prefs down on every change. When those setters lived in `lib/markdown`, that one import made * the whole remark/rehype/highlight stack a static dependency of the app entry * chunk, so every launch fetched and evaluated `vendor-markdown` before the user * had opened a note. This module is a few bytes of state; `lib/markdown` reads @@ -18,6 +18,7 @@ export type MarkdownMathRenderer = 'katex' | 'typst' let mathRenderer: MarkdownMathRenderer = 'katex' let looseMathDelimiters = false +let detectCodeLanguages = true let revision = 0 /** Point the preview pipeline at KaTeX or Typst. Default KaTeX keeps existing @@ -35,6 +36,14 @@ export function setMarkdownLooseMathDelimiters(next: boolean): void { revision += 1 } +/** Guess the language of a code block that names none (#1014). Off, an + * unlabeled block renders plain, as it does in the editor. */ +export function setMarkdownDetectCodeLanguages(next: boolean): void { + if (next === detectCodeLanguages) return + detectCodeLanguages = next + revision += 1 +} + export function markdownMathRenderer(): MarkdownMathRenderer { return mathRenderer } @@ -43,7 +52,11 @@ export function markdownLooseMathDelimiters(): boolean { return looseMathDelimiters } -/** Increments whenever either setting effectively changes. Part of the +export function markdownDetectCodeLanguages(): boolean { + return detectCodeLanguages +} + +/** Increments whenever a setting effectively changes. Part of the * `renderMarkdown` cache key. */ export function markdownSettingsRevision(): number { return revision diff --git a/packages/app-core/src/lib/markdown.test.ts b/packages/app-core/src/lib/markdown.test.ts index e30c1a823..d7ca3b329 100644 --- a/packages/app-core/src/lib/markdown.test.ts +++ b/packages/app-core/src/lib/markdown.test.ts @@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { renderMarkdown } from './markdown' -import { setMarkdownMathRenderer } from './markdown-settings' +import { setMarkdownDetectCodeLanguages, setMarkdownMathRenderer } from './markdown-settings' +import { DETECTED } from './code-language-detect.samples' describe('renderMarkdown', () => { it('keeps source newlines soft while preserving explicit hard breaks (#656)', () => { @@ -685,3 +686,40 @@ describe('a lone dash under a line of text (#898)', () => { expect(renderMarkdown('> quoted\n> -')).not.toContain('<h2') }) }) + +describe('code block language detection in the reading view (#1014)', () => { + const python = DETECTED.find(([, label]) => label === 'python')![2] + const codeTag = (html: string): string => /<code[^>]*>/.exec(html)?.[0] ?? '' + + afterEach(() => setMarkdownDetectCodeLanguages(true)) + + it('colours an unlabeled block in the language the editor guesses', () => { + setMarkdownDetectCodeLanguages(true) + const html = renderMarkdown('```\n' + python + '\n```') + expect(codeTag(html)).toContain('language-python') + expect(html).toContain('hljs-keyword') + }) + + it('leaves an unlabeled block plain with detection off, as the editor does', () => { + setMarkdownDetectCodeLanguages(false) + const html = renderMarkdown('```\n' + python + '\n```') + expect(codeTag(html)).not.toContain('language-') + expect(html).not.toContain('hljs-keyword') + }) + + it('leaves a labeled block to its label either way', () => { + for (const on of [true, false]) { + setMarkdownDetectCodeLanguages(on) + const html = renderMarkdown('```ruby\n' + python + '\n```') + expect(codeTag(html)).toContain('language-ruby') + expect(codeTag(html)).not.toContain('language-python') + } + }) + + it('does not guess at a block too small to tell, which detect: true used to colour', () => { + setMarkdownDetectCodeLanguages(true) + const html = renderMarkdown('```\nx = 1\n```') + expect(codeTag(html)).not.toContain('language-') + expect(html).not.toContain('hljs-') + }) +}) diff --git a/packages/app-core/src/lib/markdown.ts b/packages/app-core/src/lib/markdown.ts index 538495357..fa6bbe1f0 100644 --- a/packages/app-core/src/lib/markdown.ts +++ b/packages/app-core/src/lib/markdown.ts @@ -28,10 +28,12 @@ import { PREVIEW_TOKEN_CLASS } from './custom-code-languages' import { + markdownDetectCodeLanguages, markdownLooseMathDelimiters, markdownMathRenderer, markdownSettingsRevision } from './markdown-settings' +import { detectCodeLanguage } from './code-language-detect' import { numberLatexEquationEnvironments } from './latex-equation-numbering' /** @@ -787,6 +789,38 @@ function rehypeMathDiagrams() { } } +function hastText(node: HastElement['children'][number]): string { + if (node.type === 'text') return node.value + if (node.type === 'element') return node.children.map(hastText).join('') + return '' +} + +/** + * Name the language of a code block that names none (#1014), with the guess + * Edit mode makes, so the reading view colours the blocks the editor labels + * "detected", in the same language. rehype-highlight's own `detect` stays + * off: it takes highlight.js' best score however low, so a two-word block was + * coloured as whatever language scored a point. With "Detect code block + * languages" off, an unlabeled block is plain here as it is in the editor. + */ +function rehypeDetectCodeLanguages() { + return (tree: HastRoot): void => { + if (!markdownDetectCodeLanguages()) return + visit(tree, 'element', (node, _index, parent) => { + if (node.tagName !== 'code' || parent?.type !== 'element' || parent.tagName !== 'pre') return + const classNames = (node.properties?.className as string[] | undefined) ?? [] + const labeled = classNames.some( + (name) => + name.startsWith('language-') || name.startsWith('lang-') || name === 'no-highlight' || name === 'nohighlight' + ) + if (labeled) return + const guess = detectCodeLanguage(node.children.map(hastText).join('')) + if (!guess) return + node.properties = { ...node.properties, className: [...classNames, `language-${guess.tag}`] } + }) + } +} + /** Highlight unknown fenced tags through the user-installed TextMate registry. */ function rehypeCustomCodeLanguages() { return (tree: HastRoot): void => { @@ -1069,7 +1103,8 @@ function createProcessor(mathRenderer: 'katex' | 'typst') { .use(rehypeTableColWidths) .use(rehypeMermaid) .use(rehypeMathDiagrams) - .use(rehypeHighlight, { detect: true, ignoreMissing: true }) + .use(rehypeDetectCodeLanguages) + .use(rehypeHighlight, { detect: false, ignoreMissing: true }) // After rehype-highlight so a user-installed TextMate grammar wins over // highlight.js' guess for a fence tag it does not actually know. .use(rehypeCustomCodeLanguages) diff --git a/packages/app-core/src/lib/self-keyed-surfaces.ts b/packages/app-core/src/lib/self-keyed-surfaces.ts index a9d98e83d..c3264c46b 100644 --- a/packages/app-core/src/lib/self-keyed-surfaces.ts +++ b/packages/app-core/src/lib/self-keyed-surfaces.ts @@ -26,6 +26,9 @@ export const SELF_KEYED_SURFACES = [ // Runs its own vim-style motion grid. '[data-zen-db-grid]', + // The grid's Columns popover: j / k and x with Vim on, the arrows and + // Space with it off, and its Escape closes it rather than leaving the grid. + '[data-db-columns-menu]', '[data-workflow-list-pane]', '[data-workflow-canvas]', // Owns bracket region navigation before global Vim buffer prefixes. diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index 1ef255109..381b6de6a 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -57,6 +57,18 @@ export const WHATS_NEW: readonly WhatsNewEntry[] = [ { title: 'Unsplash covers with your own key', body: 'Add your Unsplash Access Key in Settings to search millions of photos for a note’s cover. Searches go straight from your computer to Unsplash, and the photo is credited to its photographer.' + }, + { + title: 'A folder as a Gallery', + body: 'Open as Gallery on a folder shows its notes as cards by their covers, a letter for a note without one. Sort them, include subfolders, filter by title. Open Folder Gallery in the palette, :gallery, or Space G in Vim mode.' + }, + { + title: 'Callouts and code languages from a menu', + body: 'Right-click lines to turn them into a callout, or a callout to change its type. A code block’s label now picks its language, and a block without one shows a detected language until you choose it.' + }, + { + title: 'Database views that show just what you need', + body: 'Each table view keeps its own columns: the Columns button hides the rest of a wide CSV. The + makes new table views and copies, and Show only these in this view keeps the rows you selected.' } ] }, diff --git a/packages/app-core/src/lib/workspace-tabs.ts b/packages/app-core/src/lib/workspace-tabs.ts index ea76b7ffb..7cbad9d76 100644 --- a/packages/app-core/src/lib/workspace-tabs.ts +++ b/packages/app-core/src/lib/workspace-tabs.ts @@ -11,6 +11,7 @@ import { isAssetsViewTabPath } from '@shared/assets-view' import { isUnresolvedLinksTabPath } from '@shared/unresolved-links' import { isAssetTabPath } from './asset-tabs' import { isDiagramTabPath } from './diagram-tabs' +import { isGalleryTabPath } from './folder-gallery' import { allLeaves, type PaneLayout } from './pane-layout' export function isWorkspaceVirtualTabPath(path: string): boolean { @@ -25,6 +26,7 @@ export function isWorkspaceVirtualTabPath(path: string): boolean { isTrashTabPath(path) || isAssetsViewTabPath(path) || isUnresolvedLinksTabPath(path) || + isGalleryTabPath(path) || isAssetTabPath(path) || isDiagramTabPath(path) || isDatabaseTabPath(path) diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 6baf74b54..881a3437e 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -6,7 +6,7 @@ import { TAGS_TAB_PATH } from '@shared/tags' import { WORKFLOWS_TAB_PATH } from '@shared/workflows-view' import { databaseTabPath, type DatabaseDoc } from '@shared/databases' import { assetTabPath } from './lib/asset-tabs' -import { findLeaf, type PaneLayout, type PaneLeaf } from './lib/pane-layout' +import { allLeaves, findLeaf, type PaneLayout, type PaneLeaf } from './lib/pane-layout' import { NO_VALUE_COLUMN_ID } from './components/TasksKanban' function makeTask(content: string, taskIndex = 0): VaultTask { @@ -1962,6 +1962,48 @@ describe('timeline panel state (#1007)', () => { }) }) +describe('folder Gallery', () => { + it('sorts by last edit and stays in its folder by default, and round-trips both choices', async () => { + installZen() + const { useStore } = await loadStore() + expect(useStore.getState().gallerySort).toBe('updated') + expect(useStore.getState().galleryIncludeSubfolders).toBe(false) + + useStore.getState().setGallerySort('title') + useStore.getState().setGalleryIncludeSubfolders(true) + const saved = JSON.parse(localStorage.getItem('zen:prefs:v2') ?? '{}') + expect(saved.gallerySort).toBe('title') + expect(saved.galleryIncludeSubfolders).toBe(true) + + vi.resetModules() + const reloaded = await import('./store') + expect(reloaded.useStore.getState().gallerySort).toBe('title') + expect(reloaded.useStore.getState().galleryIncludeSubfolders).toBe(true) + }) + + it('reads an unknown stored sort as the default', async () => { + installZen() + localStorage.setItem('zen:prefs:v2', JSON.stringify({ gallerySort: 'size' })) + vi.resetModules() + const { useStore } = await import('./store') + expect(useStore.getState().gallerySort).toBe('updated') + }) + + it('opens a folder as a tab in the active pane, one tab per folder', async () => { + installZen() + const { useStore, isGalleryViewActive } = await loadStore() + const { galleryTabPath } = await import('./lib/folder-gallery') + await useStore.getState().openFolderGallery('inbox', 'Recipes') + expect(isGalleryViewActive(useStore.getState())).toBe(true) + expect(useStore.getState().focusedPanel).toBe('editor') + await useStore.getState().openFolderGallery('inbox', 'Recipes') + await useStore.getState().openFolderGallery('archive', '') + const tabs = allLeaves(useStore.getState().paneLayout).flatMap((leaf) => leaf.tabs) + expect(tabs.filter((tab) => tab === galleryTabPath('inbox', 'Recipes'))).toHaveLength(1) + expect(tabs).toContain(galleryTabPath('archive', '')) + }) +}) + describe('kanbanCardSort (#889)', () => { it('defaults to the manual order and round-trips due-date ordering', async () => { installZen() diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 85ba1a1d9..40b16643b 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -270,6 +270,13 @@ import { normalizeTimelineRange, type TimelineRange } from './lib/timeline-rail' +import { + galleryTabPath, + isGalleryTabPath, + normalizeGallerySort, + rewriteGalleryTabPath, + type GallerySort +} from './lib/folder-gallery' import { normalizeApplicationSchemes } from '@shared/application-links' import { normalizeEditorTabSize } from './lib/editor-tab-size' import { recentNoteToggleTarget } from './lib/recent-note-toggle' @@ -614,6 +621,10 @@ interface Prefs { /** Render Markdown tables as interactive WYSIWYG widgets in live preview. * Off keeps tables as plain editable markdown — full keyboard/Vim editing. */ renderTablesInLivePreview: boolean + /** Guess the language of a code block that names none, and highlight it + * with the guess, in Edit mode and the reading view alike (#1014). The + * guess is never written to the note. */ + detectCodeLanguages: boolean /** How a completed task's text is styled (strike / gray / both / none) in the * editor and preview. Applied via `html[data-completed-task-style]`. */ completedTaskStyle: CompletedTaskStyle @@ -810,6 +821,12 @@ interface Prefs { tasksListOrder: TasksListOrder /** How far ahead the Timeline panel reaches. (#1007) */ timelineRange: TimelineRange + /** The Gallery's card order: last edited, newest, or by title. Portable + * (`gallery_sort` under [view]). */ + gallerySort: GallerySort + /** Whether a Gallery shows the notes in its folder's subfolders too. + * Portable (`gallery_include_subfolders` under [view]). */ + galleryIncludeSubfolders: boolean /** Display-only Kanban column title overrides. Keyed by `${groupBy}:${columnId}`. */ kanbanColumnTitles: Record<string, string> /** Manual Kanban column arrangement per board. Keyed by groupBy → ordered @@ -1157,6 +1174,7 @@ export const DEFAULT_PREFS: Prefs = { listIndentGuides: true, linkPopoverOnHover: true, renderTablesInLivePreview: true, + detectCodeLanguages: true, completedTaskStyle: 'none', mathRenderer: 'katex', typstTagPreambles: false, @@ -1250,6 +1268,8 @@ export const DEFAULT_PREFS: Prefs = { kanbanCardSort: 'manual', tasksListOrder: 'note', timelineRange: DEFAULT_TIMELINE_RANGE, + gallerySort: 'updated', + galleryIncludeSubfolders: false, kanbanColumnTitles: {}, kanbanColumnOrder: {}, kanbanCardOrder: {}, @@ -1350,6 +1370,10 @@ function normalizePrefs(p: Partial<Prefs>): Prefs { typeof p.renderTablesInLivePreview === 'boolean' ? p.renderTablesInLivePreview : DEFAULT_PREFS.renderTablesInLivePreview, + detectCodeLanguages: + typeof p.detectCodeLanguages === 'boolean' + ? p.detectCodeLanguages + : DEFAULT_PREFS.detectCodeLanguages, completedTaskStyle: p.completedTaskStyle === 'strikethrough' || p.completedTaskStyle === 'gray' || @@ -1629,6 +1653,11 @@ function normalizePrefs(p: Partial<Prefs>): Prefs { kanbanCardSort: p.kanbanCardSort === 'due' ? 'due' : 'manual', tasksListOrder: p.tasksListOrder === 'priority' ? 'priority' : 'note', timelineRange: normalizeTimelineRange(p.timelineRange), + gallerySort: normalizeGallerySort(p.gallerySort), + galleryIncludeSubfolders: + typeof p.galleryIncludeSubfolders === 'boolean' + ? p.galleryIncludeSubfolders + : DEFAULT_PREFS.galleryIncludeSubfolders, kanbanColumnTitles: normalizeKanbanColumnTitles(p.kanbanColumnTitles), kanbanColumnOrder: normalizeKanbanColumnOrder(p.kanbanColumnOrder), kanbanCardOrder: normalizeKanbanCardOrder(p.kanbanCardOrder), @@ -1944,12 +1973,18 @@ function isDatabaseSurfaceTabPath(path: string | null | undefined): path is stri * database surfaces — so opening a row's record page and pressing Ctrl+O jumps * back to the grid — and the Tasks and Tags views, which are destinations the * user navigates to on purpose and expects the jumplist to return to (#633). + * A folder's Gallery is one too: open a note from it, and Ctrl+O comes back. * Other virtual tabs (plain assets, help, trash…) stay excluded. */ function isJumpHistoryTabPath(path: string | null | undefined): path is string { if (!path) return false if (!isWorkspaceVirtualTabPath(path)) return true - return isDatabaseSurfaceTabPath(path) || isTasksTabPath(path) || isTagsTabPath(path) + return ( + isDatabaseSurfaceTabPath(path) || + isTasksTabPath(path) || + isTagsTabPath(path) || + isGalleryTabPath(path) + ) } function captureNoteJumpLocation(state: { @@ -2503,6 +2538,7 @@ function collectPrefs(s: { listIndentGuides: boolean linkPopoverOnHover: boolean renderTablesInLivePreview: boolean + detectCodeLanguages: boolean completedTaskStyle: CompletedTaskStyle mathRenderer: MathRenderer typstTagPreambles: boolean @@ -2591,6 +2627,8 @@ function collectPrefs(s: { kanbanCardSort: KanbanCardSort tasksListOrder: TasksListOrder timelineRange: TimelineRange + gallerySort: GallerySort + galleryIncludeSubfolders: boolean kanbanColumnTitles: Record<string, string> kanbanColumnOrder: Record<string, string[]> kanbanCardOrder: Record<string, string[]> @@ -2624,6 +2662,7 @@ function collectPrefs(s: { listIndentGuides: s.listIndentGuides, linkPopoverOnHover: s.linkPopoverOnHover, renderTablesInLivePreview: s.renderTablesInLivePreview, + detectCodeLanguages: s.detectCodeLanguages, completedTaskStyle: s.completedTaskStyle, mathRenderer: s.mathRenderer, typstTagPreambles: s.typstTagPreambles, @@ -2709,6 +2748,8 @@ function collectPrefs(s: { kanbanCardSort: s.kanbanCardSort, tasksListOrder: s.tasksListOrder, timelineRange: s.timelineRange, + gallerySort: s.gallerySort, + galleryIncludeSubfolders: s.galleryIncludeSubfolders, kanbanColumnTitles: s.kanbanColumnTitles, kanbanColumnOrder: s.kanbanColumnOrder, kanbanCardOrder: s.kanbanCardOrder, @@ -3056,6 +3097,15 @@ export function isUnresolvedLinksViewActive(state: { return leaf?.activeTab === UNRESOLVED_LINKS_TAB_PATH } +/** True when the active pane's active tab is a folder's Gallery. */ +export function isGalleryViewActive(state: { + paneLayout: PaneLayout + activePaneId: string +}): boolean { + const leaf = findLeaf(state.paneLayout, state.activePaneId) + return isGalleryTabPath(leaf?.activeTab) +} + /** True when the active pane's active tab is the built-in Assets view. */ export function isAssetsViewActive(state: { paneLayout: PaneLayout @@ -3180,6 +3230,7 @@ interface Store { listIndentGuides: boolean linkPopoverOnHover: boolean renderTablesInLivePreview: boolean + detectCodeLanguages: boolean completedTaskStyle: CompletedTaskStyle mathRenderer: MathRenderer typstTagPreambles: boolean @@ -3386,6 +3437,10 @@ interface Store { /** How far ahead the Timeline panel reaches: two weeks, a month, or every * dated task. Portable (`timeline_range` under [view]). (#1007) */ timelineRange: TimelineRange + /** The Gallery's card order, shared by every Gallery tab. Portable. */ + gallerySort: GallerySort + /** Whether a Gallery reaches into its folder's subfolders. Portable. */ + galleryIncludeSubfolders: boolean /** The Timeline panel's filter, in the Tasks filter syntax. Session state: * shared by every pane's Timeline, cleared on restart. (#1007) */ timelineFilter: string @@ -3549,6 +3604,9 @@ interface Store { openWorkflowsView: () => Promise<void> /** Open the Atlas map as a tab in the active pane. */ openAtlasView: () => Promise<void> + /** Open a folder's Gallery (its notes as cards, led by their covers) as a + * tab in the active pane. */ + openFolderGallery: (folder: NoteFolder, subpath: string) => Promise<void> /** Close the Tags tab in every pane and clear the selection. */ closeTagView: () => void /** Open the built-in Help tab in the active pane. */ @@ -3661,6 +3719,8 @@ interface Store { setTasksListOrder: (order: TasksListOrder) => void setTimelineRange: (range: TimelineRange) => void setTimelineFilter: (query: string) => void + setGallerySort: (sort: GallerySort) => void + setGalleryIncludeSubfolders: (include: boolean) => void setKanbanColumnTitle: ( group: KanbanGroupBy, columnId: string, @@ -3838,6 +3898,7 @@ interface Store { setListIndentGuides: (on: boolean) => void setLinkPopoverOnHover: (on: boolean) => void setRenderTablesInLivePreview: (on: boolean) => void + setDetectCodeLanguages: (on: boolean) => void setCompletedTaskStyle: (style: CompletedTaskStyle) => void setMathRenderer: (renderer: MathRenderer) => void setTypstTagPreambles: (on: boolean) => void @@ -4712,6 +4773,8 @@ function rewriteFolderWorkspace( : nextPrefix + path.slice(prefix.length) : path const rewrite = (path: string): string | null => { + // A Gallery tab follows its folder, and closes with it. + if (isGalleryTabPath(path)) return rewriteGalleryTabPath(path, rewriteFile, s.vaultSettings) const csv = csvPathFromDatabaseTab(path) const asset = isAssetTabPath(path) ? assetPathFromTab(path) : null const mapped = rewriteFile(csv ?? asset ?? path) @@ -6149,6 +6212,7 @@ export const useStore = create<Store>((set, get) => { listIndentGuides: loadPrefs().listIndentGuides, linkPopoverOnHover: loadPrefs().linkPopoverOnHover, renderTablesInLivePreview: loadPrefs().renderTablesInLivePreview, + detectCodeLanguages: loadPrefs().detectCodeLanguages, completedTaskStyle: loadPrefs().completedTaskStyle, mathRenderer: loadPrefs().mathRenderer, typstTagPreambles: loadPrefs().typstTagPreambles, @@ -6239,6 +6303,8 @@ export const useStore = create<Store>((set, get) => { tasksListOrder: loadPrefs().tasksListOrder, timelineRange: loadPrefs().timelineRange, timelineFilter: '', + gallerySort: loadPrefs().gallerySort, + galleryIncludeSubfolders: loadPrefs().galleryIncludeSubfolders, kanbanColumnTitles: loadPrefs().kanbanColumnTitles, kanbanColumnOrder: loadPrefs().kanbanColumnOrder, kanbanCardOrder: loadPrefs().kanbanCardOrder, @@ -6566,6 +6632,15 @@ export const useStore = create<Store>((set, get) => { set({ focusedPanel: 'editor' }) }, + openFolderGallery: async (folder, subpath) => { + // A card draws a vault cover from the vault's file list, which is read + // lazily and may not be there yet. + if (!get().assetFilesListed) await get().refreshAssets() + await get().openNoteInPane(get().activePaneId, galleryTabPath(folder, subpath)) + ;(document.activeElement as HTMLElement | null)?.blur?.() + set({ focusedPanel: 'editor' }) + }, + openAssetsView: async () => { // Refresh both: assets for the list, notes for fresh assetEmbeds (usage). await Promise.all([get().refreshAssets(), get().refreshNotes()]) @@ -7746,6 +7821,14 @@ export const useStore = create<Store>((set, get) => { savePrefs(collectPrefs(get())) }, setTimelineFilter: (query) => set({ timelineFilter: query }), + setGallerySort: (sort) => { + set({ gallerySort: normalizeGallerySort(sort) }) + savePrefs(collectPrefs(get())) + }, + setGalleryIncludeSubfolders: (include) => { + set({ galleryIncludeSubfolders: include }) + savePrefs(collectPrefs(get())) + }, setKanbanColumnTitle: (group, columnId, title) => { const key = `${group}:${columnId}` const normalized = typeof title === 'string' ? normalizeKanbanColumnTitle(title) : null @@ -9463,6 +9546,10 @@ export const useStore = create<Store>((set, get) => { set({ renderTablesInLivePreview: on }) savePrefs(collectPrefs(get())) }, + setDetectCodeLanguages: (on) => { + set({ detectCodeLanguages: on }) + savePrefs(collectPrefs(get())) + }, setCompletedTaskStyle: (style) => { set({ completedTaskStyle: style }) savePrefs(collectPrefs(get())) diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 68140a912..016a955c8 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -1580,9 +1580,10 @@ textarea, } /* A cover's credit shows with its buttons, on hover or focus. A screen that cannot hover (a phone, a tablet) shows it all the time, or it would never - show at all. */ + show at all. A Gallery card's thumbnail follows the same rule. */ @media (hover: none) { - .zen-note-cover .zen-note-cover-credit { + .zen-note-cover .zen-note-cover-credit, + .zen-gallery-cover .zen-note-cover-credit { opacity: 1; } } @@ -5551,32 +5552,85 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; } -/* Language label pinned top-right of the block; clicking it copies the code, - so there is no separate copy or fold control. */ -.cm-wysiwyg .cm-code-flair { +/* Language button and copy button pinned top-right of the block (#1014). The + language button opens the code block language picker; a guessed language + reads muted, in a dashed outline, since it is not in the file. */ +.cm-wysiwyg .cm-code-flair-bar { position: absolute; top: 3px; right: 8px; z-index: 2; - padding: 1px 7px; - border: none; - border-radius: 6px; - background: transparent; + display: inline-flex; + align-items: center; + gap: 2px; font-family: var(--z-interface-font, system-ui, sans-serif); font-size: 11px; line-height: 1.5; + /* One line however narrow the pane: "python · detected" must not wrap a + letter per line. */ + white-space: nowrap; +} +.cm-wysiwyg .cm-code-flair, +.cm-wysiwyg .cm-code-copy { + position: relative; + display: inline-flex; + align-items: center; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; color: rgb(var(--z-grey-1)); + font: inherit; cursor: pointer; user-select: none; - transition: background 0.12s ease, color 0.12s ease; + transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; } -.cm-wysiwyg .cm-code-flair:hover { +.cm-wysiwyg .cm-code-flair { + gap: 3px; + padding: 0 5px 0 7px; +} +.cm-wysiwyg .cm-code-copy { + justify-content: center; + width: 20px; + height: 18px; + padding: 0; +} +.cm-wysiwyg .cm-code-flair svg, +.cm-wysiwyg .cm-code-copy svg { + flex-shrink: 0; +} +.cm-wysiwyg .cm-code-flair svg { + opacity: 0.7; +} +.cm-wysiwyg .cm-code-flair:hover, +.cm-wysiwyg .cm-code-copy:hover { background: rgb(var(--z-bg-3) / 0.85); color: rgb(var(--z-fg)); } -.cm-wysiwyg .cm-code-flair.is-copied { +.cm-wysiwyg .cm-code-flair.is-detected { + border-style: dashed; + border-color: rgb(var(--z-grey-1) / 0.45); + color: rgb(var(--z-grey-1) / 0.85); + font-style: italic; +} +.cm-wysiwyg .cm-code-flair.is-detected:hover { + border-color: rgb(var(--z-grey-1) / 0.7); +} +.cm-wysiwyg .cm-code-copy.is-copied { color: rgb(var(--z-accent)); } +/* A finger needs more than the 18px a pointer does: grow the hit area to + 44px tall past the button, without moving the code under it. */ +@media (pointer: coarse) { + .cm-wysiwyg .cm-code-flair-bar { + gap: 6px; + } + .cm-wysiwyg .cm-code-flair::after, + .cm-wysiwyg .cm-code-copy::after { + content: ''; + position: absolute; + inset: -13px -3px; + } +} /* --- WYSIWYG live table widget (Edit mode) --------------------------- */ /* A GFM pipe table rendered as a real, editable <table>. Mirrors Obsidian's diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index e53a7fe0c..b139afc1f 100644 --- a/packages/bridge-contract/src/app-config.ts +++ b/packages/bridge-contract/src/app-config.ts @@ -30,6 +30,7 @@ export const PORTABLE_PREF_KEYS = [ 'listIndentGuides', 'linkPopoverOnHover', 'renderTablesInLivePreview', + 'detectCodeLanguages', 'completedTaskStyle', 'mathRenderer', 'typstTagPreambles', @@ -106,6 +107,8 @@ export const PORTABLE_PREF_KEYS = [ 'kanbanCardSort', 'tasksListOrder', 'timelineRange', + 'gallerySort', + 'galleryIncludeSubfolders', 'kanbanFolderRoot', 'kanbanColumnTitles', 'kanbanStatuses', diff --git a/packages/bridge-contract/src/databases.ts b/packages/bridge-contract/src/databases.ts index 9b11206ab..f4ed5ad35 100644 --- a/packages/bridge-contract/src/databases.ts +++ b/packages/bridge-contract/src/databases.ts @@ -94,9 +94,21 @@ export interface DbView { * any). Optional so existing views keep their AND behavior. (#394) */ filterConjunction?: FilterConjunction sorts: SortRule[] + /** + * Picked rows: when present, the view shows only the rows whose id is + * listed, and its `filters` and `sorts` then apply to those. Absent = every + * row (an empty list shows none, which the UI reports as "0 picked rows"). + * Ids naming rows that no longer exist are ignored rather than pruned, so a + * deleted row never costs a schema write. Applies to Table and Board views + * alike. Older builds ignore the field and show every row, and round-trip + * it untouched. + */ + pickedRowIds?: string[] // --- table --- /** Ordered fieldIds (display order). */ columnOrder?: string[] + /** Fields this table view leaves out. Each saved view keeps its own list; + * the UI never lets the last visible column go. */ hiddenFieldIds?: string[] columnWidths?: Record<string, number> // --- board --- diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index 96b830dd9..f5ef5ff98 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -94,6 +94,7 @@ export const PORTABLE_DEFAULTS: Record<PortablePrefKey, unknown> = { listIndentGuides: true, linkPopoverOnHover: true, renderTablesInLivePreview: true, + detectCodeLanguages: true, completedTaskStyle: 'none', mathRenderer: 'katex', typstTagPreambles: false, @@ -172,6 +173,8 @@ export const PORTABLE_DEFAULTS: Record<PortablePrefKey, unknown> = { kanbanCardSort: 'manual', tasksListOrder: 'note', timelineRange: 'two-weeks', + gallerySort: 'updated', + galleryIncludeSubfolders: false, kanbanFolderRoot: '', kanbanColumnTitles: {}, kanbanStatuses: [], diff --git a/packages/shared-domain/src/commands-catalog.ts b/packages/shared-domain/src/commands-catalog.ts index ab7ede7ab..cdf02bc8e 100644 --- a/packages/shared-domain/src/commands-catalog.ts +++ b/packages/shared-domain/src/commands-catalog.ts @@ -116,7 +116,15 @@ export const COMMAND_CATALOG: CommandCatalogEntry[] = [ { id: "task.toggle-checkbox", title: "Toggle Checkbox", category: "Editor", keywords: "toggle checkbox task check uncheck line todo checklist convert done" }, { id: "editor.reflow-paragraph", title: "Reflow Paragraph", category: "Editor", keywords: "reflow join lines unwrap hard wrap word wrap format paragraph gq fill" }, { id: "database.create-record-pages", title: "Create Pages for All Records", category: "Database", keywords: "database base records rows pages notes link wikilink bulk all csv" }, + { id: "database.columns", title: "Choose Database Columns", category: "Database", keywords: "database base table columns fields show hide visible choose pick view" }, + { id: "database.new-table-view", title: "New Table View", category: "Database", keywords: "database base view table add create new saved columns" }, + { id: "database.duplicate-view", title: "Duplicate View", category: "Database", keywords: "database base view copy duplicate clone saved table board" }, + { id: "database.pick-selected", title: "Show Only Selected Rows", category: "Database", keywords: "database base rows records pick picked picks selected selection only view subset" }, + { id: "database.show-all-rows", title: "Show All Rows", category: "Database", keywords: "database base rows records picks picked clear reset all every view" }, { id: "table.to-database", title: "Convert Table to Database…", category: "Editor", keywords: "table database convert csv base grid board rows columns markdown pipe extract" }, + { id: "editor.callout-type", title: "Set Callout Type…", category: "Editor", keywords: "callout admonition alert box turn into convert change type note info tip warning danger error success quote blockquote" }, + { id: "editor.remove-callout", title: "Remove Callout", category: "Editor", keywords: "callout admonition alert unwrap plain text blockquote delete remove" }, + { id: "editor.code-language", title: "Set Code Block Language…", category: "Editor", keywords: "code block fence fenced language syntax highlight highlighting detect detected guess picker typescript python change set" }, { id: "editor.copy-link", title: "Copy Link Under Cursor", category: "Editor", keywords: "copy link url email address clipboard gy" }, { id: "editor.resize-image", title: "Resize Image…", category: "Editor", keywords: "image picture width size resize embed px hint shrink grow" }, { id: "nav.back", title: "Go Back", category: "Tabs", keywords: "history previous" }, @@ -141,6 +149,7 @@ export const COMMAND_CATALOG: CommandCatalogEntry[] = [ { id: "view.workflows", title: "Open Workflows", category: "View", keywords: "workflow automation pipeline graph canvas nodes run dry" }, { id: "view.atlas", title: "Open Atlas", category: "View", keywords: "atlas map graph vault visualize notes links regions sky 3d" }, { id: "view.unresolved-links", title: "Open Unresolved Links", category: "View", keywords: "unresolved links missing broken dangling wikilinks notes to write later create orphan dead" }, + { id: "view.folder-gallery", title: "Open Folder Gallery", category: "View", keywords: "gallery folder covers cards grid thumbnails banner images pictures board notes browse" }, { id: "view.tags", title: "Open Tags", category: "View", keywords: "tags browse filter vault" }, { id: "view.toggle.sidebar", title: "Toggle Sidebar", category: "View" }, { id: "view.toggle.connections", title: "Toggle Connections Panel", category: "View" }, diff --git a/packages/shared-domain/src/database-transforms.test.ts b/packages/shared-domain/src/database-transforms.test.ts index 779e99cd4..eee506dd1 100644 --- a/packages/shared-domain/src/database-transforms.test.ts +++ b/packages/shared-domain/src/database-transforms.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest' -import { joinNoteLinks, splitNoteLinks } from './database-transforms' +import type { DbField, DbRow } from './databases' +import { EMPTY_GROUP } from './databases' +import { boardColumns, joinNoteLinks, pickRows, splitNoteLinks, viewRows } from './database-transforms' describe('splitNoteLinks / joinNoteLinks (#500)', () => { it('round-trips targets, including titles with commas', () => { @@ -17,3 +19,68 @@ describe('splitNoteLinks / joinNoteLinks (#500)', () => { expect(splitNoteLinks('plain text, no links')).toEqual([]) }) }) + +describe('pickRows / viewRows: picked rows', () => { + const fields: DbField[] = [ + { id: 'name', name: 'Name', type: 'text' }, + { id: 'status', name: 'Status', type: 'select', options: [] }, + { id: 'n', name: 'N', type: 'number' } + ] + const map = new Map(fields.map((f) => [f.id, f])) + const row = (id: string, name: string, status: string, n: string): DbRow => ({ + id, + cells: { name, status, n } + }) + const rows = [ + row('r1', 'Alpha', 'todo', '3'), + row('r2', 'Beta', 'done', '1'), + row('r3', 'Gamma', 'todo', '2'), + row('r4', 'Delta', 'done', '4') + ] + const ids = (list: DbRow[]): string[] => list.map((r) => r.id) + + it('keeps every row without picks, and only the picked ones with them', () => { + expect(pickRows(rows, undefined)).toBe(rows) + expect(ids(pickRows(rows, ['r3', 'r1']))).toEqual(['r1', 'r3']) + expect(pickRows(rows, [])).toEqual([]) + }) + + it('ignores picks that name deleted rows', () => { + expect(ids(pickRows(rows, ['gone', 'r2', 'also-gone']))).toEqual(['r2']) + }) + + it('applies picks before the filters and the sorts', () => { + const view = { + pickedRowIds: ['r1', 'r2', 'r3'], + filters: [{ fieldId: 'status', op: 'is' as const, value: 'todo' }], + sorts: [{ fieldId: 'n', direction: 'asc' as const }] + } + // r4 was never picked and r2 fails the filter; r1 and r3 sort by N. + expect(ids(viewRows(rows, view, map))).toEqual(['r3', 'r1']) + // An OR filter still cannot bring an unpicked row back. + const any = { + ...view, + filters: [ + { fieldId: 'status', op: 'is' as const, value: 'done' }, + { fieldId: 'name', op: 'contains' as const, value: 'a' } + ], + filterConjunction: 'or' as const + } + expect(ids(viewRows(rows, any, map))).toEqual(['r2', 'r3', 'r1']) + }) + + it('is the plain filter and sort pipeline for a view without picks', () => { + const view = { filters: [], sorts: [{ fieldId: 'n', direction: 'desc' as const }] } + expect(ids(viewRows(rows, view, map))).toEqual(['r4', 'r1', 'r3', 'r2']) + }) + + it('feeds the board the picked rows only', () => { + const picked = viewRows(rows, { pickedRowIds: ['r2', 'r3'], filters: [], sorts: [] }, map) + const columns = boardColumns(picked, fields[1], ['todo', 'done']) + expect(columns.map((c) => [c.key, ids(c.rows)])).toEqual([ + ['todo', ['r3']], + ['done', ['r2']], + [EMPTY_GROUP, []] + ]) + }) +}) diff --git a/packages/shared-domain/src/database-transforms.ts b/packages/shared-domain/src/database-transforms.ts index 850a00e89..a7ede25d1 100644 --- a/packages/shared-domain/src/database-transforms.ts +++ b/packages/shared-domain/src/database-transforms.ts @@ -2,7 +2,7 @@ * Pure, type-aware filter / sort / group transforms over database rows. * Reused by both the Table view (filter + sort) and the Board view (group-by). */ -import type { DbField, DbRow, FilterConjunction, FilterRule, SortRule } from './databases' +import type { DbField, DbRow, DbView, FilterConjunction, FilterRule, SortRule } from './databases' import { EMPTY_GROUP } from './databases' const MULTISELECT_SEP = ', ' @@ -152,6 +152,39 @@ export function sortRows( return decorated.map((d) => d.row) } +// --------------------------------------------------------------------------- +// Picked rows + the whole view pipeline +// --------------------------------------------------------------------------- + +/** + * The rows a view's picks keep, in the database's own row order (never the + * order the ids were picked in, so a sort is the only thing that reorders). + * No picks = every row. Ids naming rows that no longer exist simply match + * nothing. + */ +export function pickRows(rows: DbRow[], pickedRowIds: string[] | undefined): DbRow[] { + if (!pickedRowIds) return rows + const picked = new Set(pickedRowIds) + return rows.filter((row) => picked.has(row.id)) +} + +/** + * The rows a saved view shows, in the order it shows them: picks first, then + * the view's filters, then its sorts. Both the Table and the Board compute + * their rows here, so a view means the same rows on either surface. + */ +export function viewRows( + rows: DbRow[], + view: Pick<DbView, 'pickedRowIds' | 'filters' | 'filterConjunction' | 'sorts'>, + fieldsById: Map<string, DbField> +): DbRow[] { + return sortRows( + filterRows(pickRows(rows, view.pickedRowIds), view.filters, fieldsById, view.filterConjunction), + view.sorts, + fieldsById + ) +} + // --------------------------------------------------------------------------- // Grouping (Board view) // --------------------------------------------------------------------------- diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index d936ee72e..e259d704b 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -103,6 +103,8 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "vim.leaderTableToDatabase", group: "vim", defaultBinding: "d", title: "Leader note action: convert table to database" }, { id: "vim.leaderNoteCover", group: "vim", defaultBinding: "c", title: "Leader note action: cover image" }, { id: "vim.leaderNoteCoverUnsplash", group: "vim", defaultBinding: "u", title: "Leader note action: cover from Unsplash" }, + { id: "vim.leaderCalloutType", group: "vim", defaultBinding: "a", title: "Leader note action: callout type" }, + { id: "vim.leaderCodeLanguage", group: "vim", defaultBinding: "l", title: "Leader note action: code block language" }, { id: "vim.leaderQuickCapture", group: "vim", defaultBinding: "q", title: "Leader: open quick capture" }, { id: "vim.leaderHideToTray", group: "vim", defaultBinding: "b", title: "Leader: hide window to tray" }, { id: "vim.leaderTemplatePicker", group: "vim", defaultBinding: "t", title: "Leader: new from template" }, @@ -113,6 +115,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "vim.leaderCalendar", group: "vim", defaultBinding: "c", title: "Leader: toggle calendar" }, { id: "vim.leaderTimeline", group: "vim", defaultBinding: "n", title: "Leader: toggle timeline" }, { id: "vim.leaderUnresolvedLinks", group: "vim", defaultBinding: "u", title: "Leader: unresolved links" }, + { id: "vim.leaderFolderGallery", group: "vim", defaultBinding: "G", title: "Leader: folder gallery" }, { id: "vim.panePrefix", group: "vim", defaultBinding: "Ctrl+W", title: "Pane command prefix" }, { id: "vim.paneFocusLeft", group: "vim", defaultBinding: "h", title: "Pane: focus left" }, { id: "vim.paneFocusDown", group: "vim", defaultBinding: "j", title: "Pane: focus down" },