) : (
- results.map((n, i) => (
-
- ))
+ results.map((n, i) => {
+ const dir = parentDirOf(n.path)
+ return (
+
+ )
+ })
)}
{createRow && results.length > 0 && (
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
diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts
index d72452e9b..05fdbfe64 100644
--- a/packages/app-core/src/lib/help.ts
+++ b/packages/app-core/src/lib/help.ts
@@ -66,7 +66,7 @@ export const HELP_QUICK_START: HelpCard[] = [
{
title: 'Find things in the right place',
body:
- 'Use note search when you know the note title or path, vault text search when you know a phrase inside the note, and the command palette when you know the action you want but not where it lives. When note search turns up nothing, Shift+Enter opens a small New note form with your search as the name; pick a folder and tags if you like, press Enter, and the note opens in the editor. A search that comes up empty is two keystrokes from becoming the note.'
+ 'Use note search when you know the note title or path, vault text search when you know a phrase inside the note, and the command palette when you know the action you want but not where it lives. Each note search result shows the folder its note lives in, so notes that share a name are easy to tell apart. When note search turns up nothing, Shift+Enter opens a small New note form with your search as the name; pick a folder and tags if you like, press Enter, and the note opens in the editor. A search that comes up empty is two keystrokes from becoming the note.'
},
{
title: 'Keep supporting material nearby',
@@ -1146,7 +1146,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [
{ label: 'Vault text search backend and binary paths', detail: 'Choose Auto, the built-in searcher, ripgrep, or fzf for vault-wide text search. Auto prefers system tools when they are installed and falls back cleanly when they are not, you can provide explicit binary paths for ripgrep or fzf if they are not on your PATH, and Settings now shows the resolved runtime backend that will actually be used.' },
{ 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.' },
+ { 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: '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/whats-new.ts b/packages/app-core/src/lib/whats-new.ts
index be081843a..e91578583 100644
--- a/packages/app-core/src/lib/whats-new.ts
+++ b/packages/app-core/src/lib/whats-new.ts
@@ -39,6 +39,23 @@ export const CLOUD_ANNOUNCEMENT = {
} as const
export const WHATS_NEW: readonly WhatsNewEntry[] = [
+ {
+ version: '2.75.0',
+ highlights: [
+ {
+ title: 'Same name, different folder',
+ body: 'Search notes (Cmd+P on macOS, Ctrl+P elsewhere) now shows the folder each result lives in, so notes that share a name are easy to tell apart. A long folder keeps its end in view.'
+ },
+ {
+ title: 'Links in table cells',
+ body: 'Type [[ in a table cell in Edit mode and the same note picker as in a paragraph opens: arrows or Ctrl+N and Ctrl+P to move, Enter to insert, | to add display text.'
+ },
+ {
+ title: 'Deep notes keep their title',
+ body: 'In a narrow pane, a note several folders deep now gives up its outer folders before its title, and renaming it shows the whole name from the start.'
+ }
+ ]
+ },
{
version: '2.74.0',
highlights: [
diff --git a/packages/app-core/src/store-new-task.test.ts b/packages/app-core/src/store-new-task.test.ts
index aec0412f7..b0aa73033 100644
--- a/packages/app-core/src/store-new-task.test.ts
+++ b/packages/app-core/src/store-new-task.test.ts
@@ -58,6 +58,26 @@ describe('newTaskFile', () => {
expect(s.toasts().some((t) => t.message === 'Task added')).toBe(false)
})
+ it('adds the new task to a loaded list by rescanning only its file (zennotesandroid#128)', async () => {
+ const s = await setup(false)
+ const { useStore } = await import('./store')
+ const rescanTasksForPath = vi.fn(async () => {})
+ useStore.setState({ vaultTasks: [{ id: 'inbox/Old.md#0' } as never], rescanTasksForPath })
+ expect(await s.create('Buy milk')).toBe('inbox/Buy milk.md')
+ expect(rescanTasksForPath).toHaveBeenCalledWith('inbox/Buy milk.md')
+ expect(useStore.getState().refreshTasks).not.toHaveBeenCalled()
+ })
+
+ it('loads an unloaded task list whole, without holding up the toast', async () => {
+ const s = await setup(false)
+ const { useStore } = await import('./store')
+ const refreshTasks = vi.fn(() => new Promise(() => {}))
+ useStore.setState({ vaultTasks: [], refreshTasks })
+ expect(await s.create('Buy milk')).toBe('inbox/Buy milk.md')
+ expect(refreshTasks).toHaveBeenCalledTimes(1)
+ expect(s.toasts().some((t) => t.message === 'Task added')).toBe(true)
+ })
+
it('says nothing when the prompt is cancelled', async () => {
const s = await setup(false)
expect(await s.create(null)).toBeNull()
diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts
index 39cf7f452..b0524879b 100644
--- a/packages/app-core/src/store.test.ts
+++ b/packages/app-core/src/store.test.ts
@@ -2996,3 +2996,29 @@ describe('createAndOpen with tags (#826 follow-up)', () => {
expect(writeNote).not.toHaveBeenCalled()
})
})
+
+describe('createAndOpen on a slow vault listing (zennotesandroid#128)', () => {
+ it('opens the new note while the vault is still being listed, then takes the listing', async () => {
+ const created = makeNote('# Untitled\n\n', 'inbox/Untitled.md')
+ type Listed = ReturnType
+ let finishListing: (notes: Listed[]) => void = () => {}
+ const listNotes = vi.fn(() => new Promise((resolve) => { finishListing = resolve }))
+ installZen({
+ createNote: vi.fn().mockResolvedValue(created),
+ listNotes,
+ readNote: vi.fn().mockResolvedValue(created)
+ })
+ const { useStore } = await loadStore()
+
+ const creating = useStore.getState().createAndOpen('inbox', '', { focusTitle: true })
+ await vi.waitFor(() => expect(useStore.getState().selectedPath).toBe('inbox/Untitled.md'))
+ expect(listNotes).toHaveBeenCalled()
+ expect(useStore.getState().notes.map((n) => n.path)).toEqual(['inbox/Untitled.md'])
+ expect(useStore.getState().pendingTitleFocusPath).toBe('inbox/Untitled.md')
+
+ const other = makeNote('# Other\n\n', 'inbox/Other.md')
+ finishListing([created, other])
+ await creating
+ expect(useStore.getState().notes.map((n) => n.path).sort()).toEqual(['inbox/Other.md', 'inbox/Untitled.md'])
+ })
+})
diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts
index baa993c60..5803e5b1a 100644
--- a/packages/app-core/src/store.ts
+++ b/packages/app-core/src/store.ts
@@ -6432,7 +6432,13 @@ export const useStore = create((set, get) => {
meta.path,
composeTaskFile({ title, dateCreated: new Date().toISOString() })
)
- await get().refreshTasks()
+ // One file changed, so a loaded task list takes that file's tasks. A
+ // full scan reads every note, which on a phone vault kept "Task added"
+ // waiting for seconds (zennotesandroid#128). An empty list may never
+ // have been loaded, and Home and the calendar load it only while it is
+ // empty, so it is loaded whole, without holding up the toast.
+ if (get().vaultTasks.length > 0) await get().rescanTasksForPath(meta.path)
+ else void get().refreshTasks()
// On the Tasks view the new task shows up in the list. Anywhere else (the
// palette, `:newtask`, a phone's Action Button) nothing on screen
// changes, so say it landed and offer the note.
@@ -8504,12 +8510,18 @@ export const useStore = create((set, get) => {
if (options?.tags && options.tags.length > 0) {
await window.zen.writeNote(meta.path, composeNewNoteBody(meta.title, options.tags))
}
- await get().refreshNotes()
- set({
+ // Open the note first and re-list the vault after. Opening needs only
+ // the note, and on a phone vault the full listing is one storage query
+ // per folder: waiting for it kept a new note 12 to 15 seconds away
+ // (zennotesandroid#128). The note joins the list at once so a second
+ // quick note right away still picks the next free title.
+ set((s) => ({
+ notes: s.notes.some((n) => n.path === meta.path) ? s.notes : [...s.notes, meta],
view: { kind: 'folder', folder, subpath },
pendingTitleFocusPath: options?.focusTitle ? meta.path : null
- })
+ }))
await get().selectNote(meta.path)
+ await get().refreshNotes()
} catch (err) {
console.error('createNote failed', err)
}
diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json
index 1ce35ab71..40eb546a3 100644
--- a/packages/bridge-contract/package.json
+++ b/packages/bridge-contract/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/bridge-contract",
"private": true,
- "version": "2.74.0",
+ "version": "2.75.0",
"type": "module",
"exports": {
"./bridge": "./src/bridge.ts",
diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json
index ac01a7331..b450583df 100644
--- a/packages/shared-domain/package.json
+++ b/packages/shared-domain/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/shared-domain",
"private": true,
- "version": "2.74.0",
+ "version": "2.75.0",
"type": "module",
"exports": {
"./*": "./src/*.ts"
diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json
index 51cd48d9b..afa056900 100644
--- a/packages/shared-ui/package.json
+++ b/packages/shared-ui/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/shared-ui",
"private": true,
- "version": "2.74.0",
+ "version": "2.75.0",
"type": "module",
"exports": {
".": "./src/index.ts"