From 890d4af46092cfbe011fdcba269a91d239d6347a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 23 Oct 2026 08:22:23 -0500 Subject: [PATCH 1/8] Fix(table): typing [[ in a table cell suggests notes (#977) Edit-mode table cells are plain contenteditable fields inside an atomic block widget. Their keystrokes never become editor transactions, so the editor's [[ completion never saw a [[ typed in a cell, and links in tables had to be typed from memory. The cell now runs the same picker on its own text: the editor's ranking (shared as wikilinkCandidates), its item rendering and popup classes, and its keys. Arrows and Ctrl+J/K/N/P move, Enter or Ctrl+Y inserts, Tab keeps the caret inside the brackets, | starts the display text, and Escape only closes the popup, in Vim INSERT too. Inserts are the editor's edits measured on the cell string, so a #section or |alias in a closed link survives and assets become embeds; the widget commits them on blur like any typed cell edit. [[ inside inline code stays literal. Heading and block suggestions ([[note# and [[note^) stay editor-only. --- .../app-core/src/lib/cm-completion-nav.ts | 2 +- .../src/lib/cm-table-link-picker.test.ts | 250 +++++++++++++ .../app-core/src/lib/cm-table-link-picker.ts | 338 ++++++++++++++++++ packages/app-core/src/lib/cm-table.ts | 35 ++ packages/app-core/src/lib/cm-wikilinks.ts | 19 +- 5 files changed, 636 insertions(+), 8 deletions(-) create mode 100644 packages/app-core/src/lib/cm-table-link-picker.test.ts create mode 100644 packages/app-core/src/lib/cm-table-link-picker.ts diff --git a/packages/app-core/src/lib/cm-completion-nav.ts b/packages/app-core/src/lib/cm-completion-nav.ts index 249a101f9..c438099ba 100644 --- a/packages/app-core/src/lib/cm-completion-nav.ts +++ b/packages/app-core/src/lib/cm-completion-nav.ts @@ -198,7 +198,7 @@ export const completionNavKeymap = Prec.highest( * Alt together), so the modifiers cannot be pinned down; only a bare Ctrl or a * Cmd chord is certainly not text. */ -function isTypedPipe(event: KeyboardEvent): boolean { +export function isTypedPipe(event: KeyboardEvent): boolean { if (event.key !== '|' || event.metaKey) return false return !(event.ctrlKey && !event.altKey) } diff --git a/packages/app-core/src/lib/cm-table-link-picker.test.ts b/packages/app-core/src/lib/cm-table-link-picker.test.ts new file mode 100644 index 000000000..4cf96a8e1 --- /dev/null +++ b/packages/app-core/src/lib/cm-table-link-picker.test.ts @@ -0,0 +1,250 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { tablePlugin } from './cm-table' +import { + cellLinkPick, + cellLinkQueryAt, + keepCaretInsideLink, + startDisplayText +} from './cm-table-link-picker' +import { useStore } from '../store' + +describe('cellLinkQueryAt', () => { + it('finds the unclosed [[query before the caret', () => { + expect(cellLinkQueryAt('one [[Expr', 10)).toEqual({ open: 4, query: 'Expr', bang: false }) + expect(cellLinkQueryAt('[[', 2)).toEqual({ open: 0, query: '', bang: false }) + expect(cellLinkQueryAt('see ![[log', 10)).toEqual({ open: 5, query: 'log', bang: true }) + }) + + it('stays closed once the link is past its note segment', () => { + expect(cellLinkQueryAt('[[Note]] x', 10)).toBeNull() + expect(cellLinkQueryAt('[[Note|al', 9)).toBeNull() + expect(cellLinkQueryAt('[[Note#He', 9)).toBeNull() + expect(cellLinkQueryAt('[[Note^bl', 9)).toBeNull() + expect(cellLinkQueryAt('plain text', 10)).toBeNull() + }) + + it('ignores [[ inside an inline code span (#783)', () => { + expect(cellLinkQueryAt('`[[Expr', 7)).not.toBeNull() // an unclosed backtick is text + expect(cellLinkQueryAt('`a [[Ex` b', 7)).toBeNull() + expect(cellLinkQueryAt('``a ` [[Ex`` b', 10)).toBeNull() + expect(cellLinkQueryAt('`code` [[Ex', 11)).not.toBeNull() + }) +}) + +describe('cell link edits', () => { + const note = { kind: 'note' as const, target: 'Expressions' } + + it('closes a link still being typed and lands past it', () => { + const match = cellLinkQueryAt('one [[Expr', 10)! + expect(cellLinkPick('one [[Expr', 10, match, note)).toEqual({ + text: 'one [[Expressions]]', + from: 19, + to: 19 + }) + }) + + it('replaces only the note segment of a closed link (#686)', () => { + const text = '[[Exp#Intro|alias]] after' + const match = cellLinkQueryAt(text, 5)! + expect(cellLinkPick(text, 5, match, note).text).toBe('[[Expressions#Intro|alias]] after') + }) + + it('makes an asset an embed, once', () => { + const asset = { kind: 'asset' as const, target: 'img/logo.png' } + const plain = cellLinkQueryAt('x [[log', 7)! + expect(cellLinkPick('x [[log', 7, plain, asset).text).toBe('x ![[img/logo.png]]') + const bang = cellLinkQueryAt('x ![[log', 8)! + expect(cellLinkPick('x ![[log', 8, bang, asset).text).toBe('x ![[img/logo.png]]') + }) + + it('Tab keeps the caret inside the brackets', () => { + const match = cellLinkQueryAt('[[Ex', 4)! + expect(keepCaretInsideLink(cellLinkPick('[[Ex', 4, match, note))).toEqual({ + text: '[[Expressions]]', + from: 13, + to: 13 + }) + }) + + it('| starts the display text, or selects the one already there (#804)', () => { + const fresh = cellLinkQueryAt('[[Ex', 4)! + expect(startDisplayText(cellLinkPick('[[Ex', 4, fresh, note))).toEqual({ + text: '[[Expressions|]]', + from: 14, + to: 14 + }) + const aliased = cellLinkQueryAt('[[Ex|shown]]', 4) + expect(aliased).not.toBeNull() + const edit = startDisplayText(cellLinkPick('[[Ex|shown]]', 4, aliased!, note)) + expect(edit.text.slice(edit.from, edit.to)).toBe('shown') + }) +}) + +function noteMeta(path: string, title: string): unknown { + return { + path, + title, + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + hasAttachments: false, + excerpt: '' + } +} + +describe('[[ in a table cell (#977)', () => { + const saved = { + notes: useStore.getState().notes, + assetFiles: useStore.getState().assetFiles, + folders: useStore.getState().folders, + activeNote: useStore.getState().activeNote, + vimMode: useStore.getState().vimMode + } + beforeEach(() => { + useStore.setState({ + notes: [ + noteMeta('inbox/Expressions.md', 'Expressions'), + noteMeta('inbox/Expert Advice.md', 'Expert Advice'), + noteMeta('inbox/Table.md', 'Table') + ], + assetFiles: [], + folders: [], + activeNote: null + } as never) + }) + afterEach(() => { + useStore.setState(saved as never) + document.querySelectorAll('.cm-tooltip-autocomplete').forEach((el) => el.parentElement?.remove()) + }) + + const DOC = 'Intro.\n\n| A | B |\n| --- | --- |\n| one | two |\n\nAfter.' + + function mount(): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc: DOC, + extensions: [markdown({ base: markdownLanguage }), tablePlugin] + }) + }) + forceParsing(view, DOC.length, 5000) + return view + } + + /** Type into the focused cell the way the browser does: new text, caret at + * its end, then an input event. */ + function typeInto(cell: HTMLElement, text: string): void { + cell.textContent = text + const range = document.createRange() + range.setStart(cell.firstChild!, text.length) + range.collapse(true) + window.getSelection()?.removeAllRanges() + window.getSelection()?.addRange(range) + cell.dispatchEvent(new Event('input', { bubbles: true })) + } + + function key(cell: HTMLElement, init: KeyboardEventInit): KeyboardEvent { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }) + cell.dispatchEvent(event) + return event + } + + const popup = (): HTMLElement | null => document.querySelector('.cm-tooltip-autocomplete') + const options = (): string[] => + [...document.querySelectorAll('.cm-tooltip-autocomplete li')].map( + (li) => li.querySelector('.wikilink-cmd-label')?.textContent ?? '' + ) + const selected = (): string | null | undefined => + document.querySelector('.cm-tooltip-autocomplete li[aria-selected] .wikilink-cmd-label') + ?.textContent + + it('opens the note picker and inserts the picked link (Vim off)', () => { + useStore.setState({ vimMode: false }) + const view = mount() + const cell = view.dom.querySelector('.cm-table-cell[data-row="0"][data-col="0"]')! + cell.focus() + typeInto(cell, 'one [[Exp') + expect(popup()).toBeTruthy() + expect(options()).toEqual(expect.arrayContaining(['Expressions', 'Expert Advice'])) + expect(options()).not.toContain('Table') + + // The arrows move the highlight instead of leaving the cell. + const first = selected() + expect(key(cell, { key: 'ArrowDown' }).defaultPrevented).toBe(true) + expect(selected()).not.toBe(first) + key(cell, { key: 'ArrowUp' }) + expect(selected()).toBe(first) + + expect(key(cell, { key: 'Enter' }).defaultPrevented).toBe(true) + expect(popup()).toBeNull() + expect(cell.dataset.raw).toBe(`one [[${first}]]`) + expect(document.activeElement).toBe(cell) + + view.focus() + expect(view.state.doc.toString()).toContain(`| one [[${first}]] |`) + view.destroy() + }) + + it('Escape closes the picker without leaving the cell', () => { + useStore.setState({ vimMode: false }) + const view = mount() + const cell = view.dom.querySelector('.cm-table-cell[data-row="0"][data-col="1"]')! + cell.focus() + typeInto(cell, '[[Expr') + expect(popup()).toBeTruthy() + expect(key(cell, { key: 'Escape' }).defaultPrevented).toBe(true) + expect(popup()).toBeNull() + expect(document.activeElement).toBe(cell) + expect(cell.dataset.raw).toBe('[[Expr') + view.destroy() + }) + + it('closes when the cell loses focus or the query stops matching', () => { + useStore.setState({ vimMode: false }) + const view = mount() + const cell = view.dom.querySelector('.cm-table-cell[data-row="0"][data-col="0"]')! + cell.focus() + typeInto(cell, '[[zzzz') + expect(popup()).toBeNull() + typeInto(cell, '[[Ex') + expect(popup()).toBeTruthy() + cell.blur() + expect(popup()).toBeNull() + view.destroy() + }) + + it('works in Vim INSERT, where the first Escape only closes the picker', () => { + useStore.setState({ vimMode: true }) + const view = mount() + const cell = view.dom.querySelector('.cm-table-cell[data-row="0"][data-col="0"]')! + cell.focus() + key(cell, { key: 'A' }) + expect(cell.getAttribute('contenteditable')).toBe('true') + typeInto(cell, 'one [[Expr') + expect(options()).toEqual(['Expressions']) + key(cell, { key: 'Escape' }) + expect(popup()).toBeNull() + expect(cell.getAttribute('contenteditable')).toBe('true') + + typeInto(cell, 'one [[Expr') + expect(popup()).toBeTruthy() + key(cell, { key: 'Tab' }) + expect(cell.dataset.raw).toBe('one [[Expressions]]') + expect(cell.getAttribute('contenteditable')).toBe('true') + key(cell, { key: 'Escape' }) + expect(cell.getAttribute('contenteditable')).toBe('false') + view.destroy() + }) +}) diff --git a/packages/app-core/src/lib/cm-table-link-picker.ts b/packages/app-core/src/lib/cm-table-link-picker.ts new file mode 100644 index 000000000..c0902feb8 --- /dev/null +++ b/packages/app-core/src/lib/cm-table-link-picker.ts @@ -0,0 +1,338 @@ +/** + * The `[[` note picker for WYSIWYG table cells (#977). A cell is a plain + * contenteditable inside an atomic block widget, so its keystrokes never become + * editor transactions and CodeMirror's completion sources never see them: `[[` + * in a cell opened nothing, and links in tables had to be typed from memory. + * + * This is the same picker on the cell's own text: the editor's ranking + * (`wikilinkCandidates`), the editor's item rendering and popup classes (so it + * looks the same and the app's "a completion owns the keyboard" checks see it), + * and the editor's keys: arrows and Ctrl+J/K/N/P move, Enter or Ctrl+Y insert, + * Tab inserts with the caret left inside the brackets, `|` inserts and starts + * the display text, Escape closes. The edits are the editor's edits measured on + * the cell string; the widget writes them into the cell and commits on blur + * like any other cell edit. Headings (`[[note#`) and block refs stay + * editor-only. + */ +import type { Completion } from '@codemirror/autocomplete' +import type { EditorView } from '@codemirror/view' +import type { LinkCandidate } from './link-candidates' +import { wikilinkCandidates } from './cm-wikilinks' +import { slashCommandRender } from './cm-slash-commands' +import { completionNavDirection, isTypedPipe } from './cm-completion-nav' + +export interface CellLinkQuery { + /** Offset of the `[[` in the cell text. */ + open: number + query: string + /** A `!` sits right before the `[[`, so the link is already an embed. */ + bang: boolean +} + +/** A cell's next text and the selection to restore in it. */ +export interface CellTextEdit { + text: string + from: number + to: number +} + +/** + * True when `at` falls inside an inline code span: a run of N backticks opens + * one that the next run of exactly N closes, and a run with no closer is plain + * text. `[[` in code is literal, so no picker opens for it (the editor's #783). + */ +function insideCodeSpan(text: string, at: number): boolean { + const runs = [...text.matchAll(/`+/g)] + for (let i = 0; i < runs.length; i++) { + const open = runs[i] + if ((open.index ?? 0) >= at) return false + const close = runs.findIndex((run, j) => j > i && run[0].length === open[0].length) + if (close < 0) continue + if ((runs[close].index ?? 0) > at) return true + i = close + } + return false +} + +/** + * The unclosed `[[query` right before `caret`, by the editor's rules: nothing + * once the link has a `]]`, a `|` display text, or a `#`/`^` anchor. + */ +export function cellLinkQueryAt(text: string, caret: number): CellLinkQuery | null { + const before = text.slice(0, caret) + const open = before.lastIndexOf('[[') + if (open < 0) return null + const inside = before.slice(open + 2) + if (inside.includes(']]') || inside.includes('|')) return null + if (inside.includes('#') || inside.includes('^')) return null + if (insideCodeSpan(text, open)) return null + return { open, query: inside, bang: open > 0 && text[open - 1] === '!' } +} + +/** The text between `pos` and the `]]` that closes the link `pos` sits in, or + * null while that link is still being typed (`closedLinkTail` for a string). */ +function closedTail(text: string, pos: number): string | null { + const after = text.slice(pos) + const close = after.indexOf(']]') + if (close < 0) return null + const tail = after.slice(0, close) + return tail.includes('[[') ? null : tail +} + +/** + * The cell after picking `candidate` for the `[[query` at `caret`, the edit + * the editor's picker makes: a link still being typed is closed and the caret + * lands past its `]]`; inside a closed link only the note segment is replaced, + * so a `#section` or `|alias` after it survives (#686). An asset becomes an + * embed. + */ +export function cellLinkPick( + text: string, + caret: number, + match: CellLinkQuery, + candidate: Pick +): CellTextEdit { + const from = match.open + 2 + const tail = closedTail(text, caret) + let insert = candidate.target + let end = caret + if (tail === null) { + insert += ']]' + } else { + let stop = tail.length + for (const ch of '#^|') { + const at = tail.indexOf(ch) + if (at >= 0 && at < stop) stop = at + } + end = caret + stop + } + let next = text.slice(0, from) + insert + text.slice(end) + let cursor = from + insert.length + if (candidate.kind === 'asset' && !match.bang) { + next = `${next.slice(0, match.open)}!${next.slice(match.open)}` + cursor += 1 + } + return { text: next, from: cursor, to: cursor } +} + +/** Tab's pick: the caret stays inside the brackets, ready for a `#heading`. */ +export function keepCaretInsideLink(edit: CellTextEdit): CellTextEdit { + if (edit.text.slice(edit.from - 2, edit.from) !== ']]') return edit + return { ...edit, from: edit.from - 2, to: edit.from - 2 } +} + +/** `|`'s pick (#804): a `|` before the `]]` with the caret behind it, or the + * existing display text selected when the link already has one. */ +export function startDisplayText(edit: CellTextEdit): CellTextEdit { + const { text, from: pos } = edit + const inside = text.slice(pos - 2, pos) === ']]' ? pos - 2 : pos + const tail = closedTail(text, inside) ?? '' + const pipe = tail.indexOf('|') + if (pipe >= 0) return { text, from: inside + pipe + 1, to: inside + tail.length } + const at = inside + tail.length + return { text: `${text.slice(0, at)}|${text.slice(at)}`, from: at + 1, to: at + 1 } +} + +type PickMode = 'close' | 'inside' | 'display' + +/** Viewport rect of the character offset `offset` in `el`'s text. */ +function rectAtOffset(el: HTMLElement, offset: number): DOMRect { + const walker = el.ownerDocument.createTreeWalker(el, NodeFilter.SHOW_TEXT) + let left = offset + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const length = node.textContent?.length ?? 0 + if (left <= length) { + const range = el.ownerDocument.createRange() + range.setStart(node, left) + range.collapse(true) + const rect = range.getClientRects?.()[0] + if (rect) return rect + break + } + left -= length + } + return el.getBoundingClientRect() +} + +export class TableCellLinkPicker { + private container: HTMLElement | null = null + private list: HTMLUListElement | null = null + private cell: HTMLElement | null = null + private match: CellLinkQuery | null = null + private items: LinkCandidate[] = [] + private selected = 0 + private lastRead = '' + private readonly onSelectionChange = (): void => { + if (this.cell) this.update(this.cell) + } + private readonly onScroll = (): void => this.position() + + constructor( + private readonly view: EditorView, + /** The cell's source and collapsed caret, or null when it is not being + * typed in (Vim NORMAL, a range selected, focus elsewhere). */ + private readonly read: (cell: HTMLElement) => { text: string; caret: number } | null, + private readonly apply: (cell: HTMLElement, edit: CellTextEdit) => void + ) {} + + get isOpen(): boolean { + return this.container !== null + } + + /** Open, refilter, or close for the `[[` at the caret of `cell`. */ + update(cell: HTMLElement): void { + const live = cell.isConnected ? this.read(cell) : null + const key = live ? `${live.caret}\u0000${live.text}` : '' + if (this.isOpen && this.cell === cell && key === this.lastRead) return + this.lastRead = key + const match = live ? cellLinkQueryAt(live.text, live.caret) : null + const items = match ? wikilinkCandidates(match.query) : [] + if (!match || items.length === 0) { + this.close() + return + } + const sameQuery = this.isOpen && this.match?.open === match.open && this.match.query === match.query + this.cell = cell + this.match = match + if (!sameQuery) { + this.items = items + this.selected = 0 + } + this.render() + } + + /** The picker's keys while it is open; true when the key was spent. */ + handleKey(event: KeyboardEvent): boolean { + if (!this.isOpen || event.isComposing) return false + const plain = !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey + const direction = + plain && event.key === 'ArrowDown' + ? 'next' + : plain && event.key === 'ArrowUp' + ? 'previous' + : completionNavDirection(event) + if (direction) { + this.move(direction === 'next' ? 1 : -1) + return true + } + if (event.key === 'Escape') { + this.close() + return true + } + const ctrlY = + event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'y' + if ((plain && event.key === 'Enter') || ctrlY) return this.pick('close') + if (plain && event.key === 'Tab') return this.pick('inside') + if (isTypedPipe(event)) return this.pick('display') + return false + } + + close(): void { + if (!this.container) return + this.container.remove() + this.container = null + this.list = null + this.cell = null + this.match = null + this.items = [] + this.lastRead = '' + document.removeEventListener('selectionchange', this.onSelectionChange) + window.removeEventListener('scroll', this.onScroll, true) + } + + /** Insert the highlighted suggestion, re-reading the cell at key time: a + * stale list must not write over text the caret has left. */ + private pick(mode: PickMode, index = this.selected): boolean { + const cell = this.cell + const candidate = this.items[index] + const live = cell ? this.read(cell) : null + const match = live ? cellLinkQueryAt(live.text, live.caret) : null + this.close() + if (!cell || !candidate || !live || !match) return false + let edit = cellLinkPick(live.text, live.caret, match, candidate) + if (mode === 'inside') edit = keepCaretInsideLink(edit) + if (mode === 'display') edit = startDisplayText(edit) + this.apply(cell, edit) + return true + } + + private move(delta: number): void { + const count = this.items.length + if (count === 0) return + this.selected = (this.selected + delta + count) % count + this.list?.querySelectorAll('li').forEach((li, i) => { + if (i === this.selected) { + li.setAttribute('aria-selected', 'true') + li.scrollIntoView?.({ block: 'nearest' }) + } else { + li.removeAttribute('aria-selected') + } + }) + } + + private render(): void { + if (!this.container) { + // Mounted the way CodeMirror mounts its tooltips on : a wrapper + // with the editor's theme classes, so the autocomplete theme applies. + const container = document.createElement('div') + container.className = this.view.themeClasses + container.style.position = 'relative' + const tooltip = document.createElement('div') + tooltip.className = 'cm-tooltip cm-tooltip-autocomplete' + tooltip.style.position = 'fixed' + const list = document.createElement('ul') + list.setAttribute('role', 'listbox') + list.setAttribute('aria-label', 'Link suggestions') + // mousedown, as CodeMirror does: the cell keeps focus and its caret. + list.addEventListener('mousedown', (event) => { + const li = (event.target as HTMLElement | null)?.closest('li') + if (!li || event.button !== 0) return + event.preventDefault() + this.pick('close', [...list.children].indexOf(li)) + }) + tooltip.append(list) + container.append(tooltip) + document.body.append(container) + this.container = container + this.list = list + document.addEventListener('selectionchange', this.onSelectionChange) + window.addEventListener('scroll', this.onScroll, true) + } + const list = this.list as HTMLUListElement + list.replaceChildren( + ...this.items.map((candidate, i) => { + const li = document.createElement('li') + li.setAttribute('role', 'option') + li.className = 'wikilink-cmd-option' + if (i === this.selected) li.setAttribute('aria-selected', 'true') + const option = { + label: candidate.label, + detail: candidate.subtitle, + _kind: 'wikilink', + _subtitle: candidate.subtitle + } as Completion + li.append(slashCommandRender.render(option)) + return li + }) + ) + this.position() + } + + /** Under the query, where the editor anchors its popup, or above it when + * the viewport has no room underneath. */ + private position(): void { + const tooltip = this.container?.firstElementChild as HTMLElement | null + if (!tooltip || !this.cell || !this.match) return + const anchor = rectAtOffset(this.cell, this.match.open + 2) + const height = tooltip.offsetHeight + const width = tooltip.offsetWidth + const below = anchor.bottom + 4 + const top = + below + height > window.innerHeight - 4 && anchor.top - height - 4 >= 4 + ? anchor.top - height - 4 + : below + const left = Math.max(4, Math.min(anchor.left, window.innerWidth - width - 4)) + tooltip.style.top = `${top}px` + tooltip.style.left = `${left}px` + } +} diff --git a/packages/app-core/src/lib/cm-table.ts b/packages/app-core/src/lib/cm-table.ts index b727d94ad..b18304ec4 100644 --- a/packages/app-core/src/lib/cm-table.ts +++ b/packages/app-core/src/lib/cm-table.ts @@ -50,6 +50,7 @@ import { useStore } from '../store' import { isMacPlatform, matchesSequenceToken, matchesShortcutBinding, type KeymapId } from './keymaps' import { writeClipboardText } from './clipboard-text' import { followLinkTarget } from './follow-link' +import { TableCellLinkPicker, type CellTextEdit } from './cm-table-link-picker' import { linkRangeAtCursor, type LineLinkKind } from './internal-links' /** The follow target for a rendered link anchor inside a table cell, with its @@ -363,6 +364,8 @@ class TableWidget extends WidgetType { private pendingG = false /** Last `f`/`t` find, so `;`/`,` can repeat it. */ private lastFind: { cmd: 'f' | 'F' | 't' | 'T'; ch: string } | null = null + /** The `[[` note picker for the cell being typed in, made on first use. */ + private linkPicker: TableCellLinkPicker | null = null /** Set in toDOM — CodeMirror hands the live view there. Block widgets are * provided by a StateField, which has no view at build time. */ private view!: EditorView @@ -703,8 +706,10 @@ class TableWidget extends WidgetType { editable.addEventListener('input', () => { this.dirty = true editable.dataset.raw = editable.textContent ?? '' + this.cellLinkPicker().update(editable) }) editable.addEventListener('blur', () => { + this.linkPicker?.close() this.clearCellCursor(editable) editable.classList.remove('is-vim-normal') // Leaving the cell: pull the (possibly edited) source back out of the DOM @@ -873,6 +878,14 @@ class TableWidget extends WidgetType { const cols = this.model.headers.length const rowsCount = this.model.rows.length + // An open `[[` picker owns its keys (arrows, Enter, Tab, Escape, …) ahead + // of the cell's own navigation and Vim handling, as in the editor. + if (this.linkPicker?.handleKey(event)) { + event.preventDefault() + event.stopPropagation() + return + } + if (editable.getAttribute('contenteditable') === 'true') { const marker = matchesShortcutBinding(event, 'Mod+B') ? '**' @@ -1406,6 +1419,26 @@ class TableWidget extends WidgetType { placeCaretAt(cell, caretOffset) } + private cellLinkPicker(): TableCellLinkPicker { + this.linkPicker ??= new TableCellLinkPicker( + this.view, + (cell) => { + if (cell.getAttribute('contenteditable') !== 'true') return null + const selection = cellSelectionOffsets(cell) + if (!selection || selection.from !== selection.to) return null + return { text: cell.textContent ?? '', caret: selection.to } + }, + (cell, edit: CellTextEdit) => { + cell.textContent = edit.text + cell.dataset.raw = edit.text + cell.dataset.rendered = 'false' + this.dirty = true + placeCellSelection(cell, edit.from, edit.to) + } + ) + return this.linkPicker + } + /** Apply or remove a Markdown marker around the native selection in an * editable cell. Cell edits do not pass through CodeMirror's keymap, so the * widget mirrors the editor's bold and italic toggle locally. */ @@ -1430,6 +1463,7 @@ class TableWidget extends WidgetType { * removes `removeBefore` chars just before the caret first, used to strip a * typed insert-escape prefix (the `j` of `jk`) before exiting. (#341) */ private exitInsertToNormal(editable: HTMLElement, removeBefore: number): void { + this.linkPicker?.close() const sel = window.getSelection() let caret = sel && sel.anchorNode && editable.contains(sel.anchorNode) ? sel.anchorOffset : 0 let text = editable.textContent ?? '' @@ -2123,6 +2157,7 @@ class TableWidget extends WidgetType { } destroy(): void { + this.linkPicker?.close() // Flush any uncommitted edits if the widget is torn down while focused. this.commitIfDirty() this.dom = null diff --git a/packages/app-core/src/lib/cm-wikilinks.ts b/packages/app-core/src/lib/cm-wikilinks.ts index a88073751..217158534 100644 --- a/packages/app-core/src/lib/cm-wikilinks.ts +++ b/packages/app-core/src/lib/cm-wikilinks.ts @@ -101,19 +101,24 @@ type WikilinkCompletion = Completion & { _subtitle: string } -export function wikilinkSource(context: CompletionContext): CompletionResult | null { - const match = wikilinkMatch(context) - if (!match) return null - +/** The `[[` suggestions for `query`: notes, assets and databases from the live + * store, ranked the way every `[[` picker shows them (the table cells too). */ +export function wikilinkCandidates(query: string): LinkCandidate[] { const state = useStore.getState() - const activePath = state.activeNote?.path ?? null - const ranked = linkCandidates(match.query, { + return linkCandidates(query, { notes: state.notes, assetFiles: state.assetFiles, folders: state.folders, vaultSettings: state.vaultSettings, - activePath + activePath: state.activeNote?.path ?? null }) +} + +export function wikilinkSource(context: CompletionContext): CompletionResult | null { + const match = wikilinkMatch(context) + if (!match) return null + + const ranked = wikilinkCandidates(match.query) const options: Completion[] = ranked.map((candidate: LinkCandidate) => { const target = candidate.target From b0aef0e79e26ffcec5006e48ad7ebf2ef1b44610 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 23 Oct 2026 09:23:08 -0500 Subject: [PATCH 2/8] Fix(editor): deep notes keep their title in the header (zennotesandroid#126) The pane header kept every folder crumb at full width and gave the note's title whatever space was left. On a phone, a note three or four folders deep got a title a few pixels wide, and tapping it opened an 88px rename field scrolled to the tail of the name, so the start of the name could not be read while renaming it. The title now outranks the folders. The crumbs share one group that gives way first, clipped at the vault end so the nearest folder stays beside the title, and the title truncates only once it alone fills the row. Each crumb carries its trailing space as padding instead of the row's flex gap, so spacing is unchanged when everything fits and a collapsed group leaves no gap behind. The rename field is as wide as the name (field-sizing), up to the whole row, and opens at the start of the name with all of it still selected. The same layout applies on desktop: in a narrow pane or a crowded header, a deep note now loses its outer folders before its title. A pane narrower than its toolbar still leaves no room for either; that is unchanged. --- .../app-core/src/components/EditorPane.tsx | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 4998f2001..263c75934 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -5140,6 +5140,9 @@ function Breadcrumb({ const raf = requestAnimationFrame(() => { inputRef.current?.focus() inputRef.current?.select() + // Focusing scrolls a name wider than the row to its tail. Show its start + // instead, still fully selected (zennotesandroid#126). + if (inputRef.current) inputRef.current.scrollLeft = 0 if (autoFocus) onAutoFocusHandled() }) return () => cancelAnimationFrame(raf) @@ -5187,26 +5190,38 @@ function Breadcrumb({ return true } + // The note's name outranks its folders when the row runs out + // (zennotesandroid#126). On a phone or a narrow split pane, full-width crumbs + // used to squeeze the title of a note a few folders deep to a sliver. The + // crumbs share one group that gives way first, clipped at the vault end so + // the nearest folder stays beside the title, which truncates only once it + // alone fills the row. Crumbs carry their trailing space as padding, not a + // flex gap, so a collapsed group leaves nothing to push the title past the + // edge. return ( -
- {ancestors.map((c, i) => ( - - - › +
+ {ancestors.length > 0 && ( + + {ancestors.map((c, i) => ( + + + › + + ))} - ))} + )} {crumbMenu && ( @@ -5291,7 +5310,7 @@ function Breadcrumb({ type="button" onClick={() => setEditing(true)} title="Rename note" - className="truncate rounded px-1.5 py-0.5 text-sm font-semibold text-ink-900 hover:bg-paper-200/70" + className="max-w-full shrink-0 truncate rounded px-1.5 py-0.5 text-sm font-semibold text-ink-900 hover:bg-paper-200/70" > {note.title || 'Untitled'} From eb527d4d6c904321a4af567e99f8970c4547da22 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 23 Oct 2026 10:05:58 -0500 Subject: [PATCH 3/8] Feat(search): note search shows each note's folder (#982) DarellYuhu keeps documentation where the same note name repeats across folders. Search notes (Mod+P) showed each result's title and its area in capitals, so five notes called Overview read as five identical INBOX rows and opening the right one meant guessing. The buffer switcher solved the same problem in #635 and the search palette was left out. A result now shows the folder its note lives in, the way the buffer switcher does: the title keeps its natural width, the folder takes the rest of the row, and a folder too long to fit loses its start rather than its end (dir="rtl" with an LRE/PDF embedding, so the path itself still reads left to right), keeping the part that tells the notes apart. A note at the vault root, where a root-mode vault keeps its notes, has no folder to show and keeps its area label. The folder comes from parentDirOf (manual-order.ts) instead of a third copy of the helper. Search itself is unchanged: it already matched on the path. The in-app manual's note search guide says so in one sentence. In the built app over CDP (isolated userData and config, a vault with six notes called Overview), Mod+P and "overview" listed every one with its folder: inbox, archive/2025/Atlas, inbox/Projects/Billing/docs, inbox/Projects/Atlas/docs and two client folders, the deepest shown as "...rporation International Holdings/onboarding/2026/q4/handover/weekly reviews". The two new palette tests and the updated row-text assertion fail on the old rows; the trash tests' row helper now reads the title span, since a row's text includes its folder. Typecheck 8/8, app-core 3,408 tests pass. The website docs mirror of that sentence is not in this change: its checkout holds other uncommitted docs work. --- .../src/components/SearchPalette.test.ts | 83 ++++++++++++++++++- .../app-core/src/components/SearchPalette.tsx | 59 ++++++++----- packages/app-core/src/lib/help.ts | 2 +- 3 files changed, 122 insertions(+), 22 deletions(-) diff --git a/packages/app-core/src/components/SearchPalette.test.ts b/packages/app-core/src/components/SearchPalette.test.ts index 1d198049b..f8789b9a1 100644 --- a/packages/app-core/src/components/SearchPalette.test.ts +++ b/packages/app-core/src/components/SearchPalette.test.ts @@ -93,9 +93,10 @@ describe('SearchPalette: Ctrl+D moves the highlighted note to Trash', () => { if (!el) throw new Error('search input not rendered') return el } + // The titles only: each row also shows the note's folder. const rows = (): string[] => [...document.querySelectorAll('[data-search-idx]')].map( - (row) => row.textContent?.replace(/inbox$/i, '').trim() ?? '' + (row) => row.querySelector('span')?.textContent?.trim() ?? '' ) const ctrlD = async (): Promise => { await act(async () => { @@ -294,7 +295,7 @@ describe('SearchPalette: the create row opens a New note form', () => { type(search(), 'Alph') const rows = [...document.querySelectorAll('[data-search-idx]')] expect(rows.map((r) => r.dataset.searchIdx)).toEqual(['0', '1']) - expect(rows[0].textContent).toBe('Alphainbox') + expect(rows[0].textContent).toBe('Alpha‪inbox‬') expect(rows[1].hasAttribute('data-search-create')).toBe(true) expect(highlighted()).toBe('0') @@ -558,3 +559,81 @@ describe('SearchPalette: the create row opens a New note form', () => { expect(form()).toBeNull() }) }) + +describe('SearchPalette: each row shows the note\'s folder (#982)', () => { + let host: HTMLDivElement + let root: Root + let originalState: ReturnType + let originalScrollIntoView: PropertyDescriptor | undefined + + const at = (path: string, folder: NoteMeta['folder'] = 'inbox'): NoteMeta => ({ + ...note('Overview'), + path, + folder + }) + + beforeEach(() => { + originalState = useStore.getState() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + originalScrollIntoView = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView') + Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }) + useStore.setState({ + notes: [ + at('inbox/Projects/Atlas/docs/Overview.md'), + at('inbox/Projects/Billing/docs/Overview.md'), + at('archive/2025/Atlas/Overview.md', 'archive'), + // A root-mode vault keeps its primary notes at the vault root. + at('Overview.md') + ], + selectedPath: null, + noteContents: {}, + noteDirty: {}, + searchOpen: true + }) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + document.body.innerHTML = '' + if (originalScrollIntoView) { + Object.defineProperty(Element.prototype, 'scrollIntoView', originalScrollIntoView) + } else { + delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView + } + useStore.setState(originalState, true) + }) + + const shown = (): { title: string; folder: string }[] => + [...document.querySelectorAll('[data-search-idx]')].map((row) => { + const [title, folder] = [...row.querySelectorAll('span')].map((s) => s.textContent ?? '') + return { title, folder: folder.replace(/[‪‬]/g, '') } + }) + + it('tells notes that share a name apart by their folder', () => { + act(() => root.render(createElement(SearchPalette))) + expect(shown()).toEqual([ + { title: 'Overview', folder: 'inbox/Projects/Atlas/docs' }, + { title: 'Overview', folder: 'inbox/Projects/Billing/docs' }, + { title: 'Overview', folder: 'archive/2025/Atlas' }, + { title: 'Overview', folder: 'inbox' } + ]) + }) + + it('cuts a long folder from the left and keeps a root note\'s area label', () => { + act(() => root.render(createElement(SearchPalette))) + const dirs = [...document.querySelectorAll('[data-search-dir]')] + expect(dirs).toHaveLength(3) + for (const dir of dirs) { + // rtl puts the ellipsis on the left; the embedding keeps the path LTR. + expect(dir.getAttribute('dir')).toBe('rtl') + expect(dir.textContent?.startsWith('‪')).toBe(true) + expect(dir.className).toContain('truncate') + } + const rootRow = document.querySelector('[data-search-idx="3"]')! + expect(rootRow.querySelector('[data-search-dir]')).toBeNull() + expect(rootRow.querySelectorAll('span')[1].className).toContain('uppercase') + }) +}) diff --git a/packages/app-core/src/components/SearchPalette.tsx b/packages/app-core/src/components/SearchPalette.tsx index 8394930f1..1562f5dae 100644 --- a/packages/app-core/src/components/SearchPalette.tsx +++ b/packages/app-core/src/components/SearchPalette.tsx @@ -9,6 +9,7 @@ import { searchNoteIndex } from '../lib/note-search' import { focusEditorNormalMode } from '../lib/editor-focus' +import { parentDirOf } from '../lib/manual-order' import { destinationLabel, parseDestinationText, @@ -198,25 +199,45 @@ export function SearchPalette(): JSX.Element { {rowCount === 0 ? (
No matches.
) : ( - results.map((n, i) => ( - - )) + results.map((n, i) => { + const dir = parentDirOf(n.path) + return ( + + ) + }) )} {createRow && results.length > 0 && (