From 64abae7e3cc2edb6d2b2a3d046d9cbe19ea2002e Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 09:43:23 -0500 Subject: [PATCH 01/27] Fix(palette): picking a theme on a phone leaves the keyboard down On the phones, picking a theme from the command palette's Themes list raised the keyboard over the note being read (seen on Android 1.1.50's walk, and the iPhone shares the code). The palette's command path already treats a theme as a `stay` landing (lib/command-landing.ts): the focus goes back where it was before the palette opened, so a keyboard that was down stays down. The Themes list committed its pick on a separate path that called focusEditorNormalMode itself and skipped that rule. It now hands back through focusEditorAfterPaletteClose, like backing out of the palette or running a toggle. Desktop is unchanged: that hand-off focuses the editor there, as the theme pick always did. --- packages/app-core/src/components/CommandPalette.tsx | 4 +++- .../app-core/src/components/PaletteFocusHandoff.test.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index aab06455f..e06e1f1f9 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -409,7 +409,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/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) From 2eded351397e1405ac39a41745eeb438c72f5a24 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 09:50:09 -0500 Subject: [PATCH 02/27] Feat(database): a saved view can keep its own columns and its own picked rows charles b. imports CSVs of up to 44 columns into databases and wants saved views that show only 4 or 5 of them, and only the rows he chose, by filter or by hand. The schema already had the per-view half of the columns story (DbView.hiddenFieldIds, which the Table honors), but nothing could set it, and there was no way to save a hand-picked set of rows at all. This is the data layer for both, with no UI yet. DbView gains an optional pickedRowIds. When present, the view shows only the rows it names, and its filters and sorts then apply to those; absent means every row. An empty list is kept as an empty list (the UI will say "0 picked rows") rather than read as "everything", so taking out the last pick never floods the view with every record. Ids of rows deleted since are ignored where the rows are computed instead of pruned when a row goes, so a row delete never costs a schema write; the next edit of the picks drops them. Older builds ignore the field and round-trip it untouched, since the sidecar parser keeps view objects whole. The rows a view shows now come from one shared function, viewRows (shared-domain): picks, then filters, then sorts. The Table and the Board both call it, so a view means the same records on either surface. The Board used to filter without sorting; it now runs the sorts too, which changes nothing today because no board view carries sorts (no UI sets them). The pure helpers the UI will dispatch live in database-cells: setColumnHidden, showAllColumns and hideAllColumns write one view's hiddenFieldIds and refuse to hide its last visible column (hide all keeps the title column, which carries the page icon); duplicateView makes a deep copy of every setting, picks included, right after the source, named " copy"; setViewPicks, removeFromViewPicks and clearViewPicks edit the picks; addRowInView adds a row that joins the active view's picks so it cannot vanish the moment it is made, and says when the schema changed so the caller writes it. New views get unique names (Table 2, Board 2), now that a database will commonly carry several table views. The column-order normalization moveColumn and moveColumnToField each spelled out is one helper, normalizedColumnOrder, with the same behavior. zn and the MCP server do not apply views at all (zn base rows lists every row, and the MCP server has no database tools), so they are left as they are; picks change only what the app's views show. --- .../src/components/DatabaseBoardView.tsx | 8 +- .../src/components/DatabaseTableView.tsx | 10 +- .../app-core/src/lib/database-cells.test.ts | 205 +++++++++++++++++- packages/app-core/src/lib/database-cells.ts | 204 ++++++++++++++++- packages/bridge-contract/src/databases.ts | 12 + .../src/database-transforms.test.ts | 69 +++++- .../shared-domain/src/database-transforms.ts | 35 ++- 7 files changed, 518 insertions(+), 25 deletions(-) diff --git a/packages/app-core/src/components/DatabaseBoardView.tsx b/packages/app-core/src/components/DatabaseBoardView.tsx index fab75984d..b61cd052f 100644 --- a/packages/app-core/src/components/DatabaseBoardView.tsx +++ b/packages/app-core/src/components/DatabaseBoardView.tsx @@ -1,7 +1,7 @@ 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, @@ -39,9 +39,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) diff --git a/packages/app-core/src/components/DatabaseTableView.tsx b/packages/app-core/src/components/DatabaseTableView.tsx index 6dd2ab59e..f8408fc3d 100644 --- a/packages/app-core/src/components/DatabaseTableView.tsx +++ b/packages/app-core/src/components/DatabaseTableView.tsx @@ -9,7 +9,7 @@ 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, @@ -119,13 +119,7 @@ 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]) + const rows = useMemo(() => viewRows(doc.rows, view, map), [doc.rows, view, map]) const anySelected = selected.size > 0 const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id)) 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/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 // --- board --- 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, + fieldsById: Map +): DbRow[] { + return sortRows( + filterRows(pickRows(rows, view.pickedRowIds), view.filters, fieldsById, view.filterConjunction), + view.sorts, + fieldsById + ) +} + // --------------------------------------------------------------------------- // Grouping (Board view) // --------------------------------------------------------------------------- From 2b1d9a7a7ee276540c39b1d807cad1699d695a80 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 09:53:26 -0500 Subject: [PATCH 03/27] Feat(database): a Columns picker chooses the columns each table view shows With a 44-column CSV imported, the grid drew all 44 and there was no way to see fewer: each view could already store hidden columns, but no control set them. A table view now has a Columns button in the database header, beside Filter. It reads "Columns" while every column shows and "Columns 5 of 44" once some are hidden, and opens a popover with a find field, Show all and Hide all, and a checklist of the view's columns in its column order. Every toggle writes the active view's hiddenFieldIds, so each saved view keeps its own columns: a "Pipeline" view can show 5 while the default Table keeps all 44. The last visible column cannot be hidden (its row says why on hover), and Hide all keeps the title column, which carries the page icon, as the start of picking the few you want. The row id stays out of the list, as it stays out of the grid. Switching views closes the popover, since the next view has columns of its own. A board view has no Columns button: its cards choose their fields separately. The column header menu gains Hide column (this view only) and Choose columns..., and in the grid c opens the popover with Vim mode on. c was free among the grid's keys; like O it is a new single-key shortcut, so it is off with Vim mode off, and it does nothing while a menu or dialog is open. In the popover, with Vim on, the list holds the keyboard: j / k move, Space or x toggles, / jumps to the find field, Home / End reach the ends. With Vim off the find field takes the typing and no letter is a shortcut: the arrows move between the field and the columns, Space or Enter toggles the focused column. Escape closes it in both, and focus goes back to the grid unless a click put it somewhere else. The panel is one of the SELF_KEYED_SURFACES, so the global Vim layer (and its Vim-off panel keys) leave its keys alone instead of walking the sidebar behind it. --- .../src/components/DatabaseColumnsMenu.tsx | 259 ++++++++++++++++++ .../src/components/DatabaseTableView.tsx | 29 +- .../app-core/src/components/DatabaseView.tsx | 99 ++++++- .../app-core/src/lib/self-keyed-surfaces.ts | 3 + 4 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 packages/app-core/src/components/DatabaseColumnsMenu.tsx diff --git a/packages/app-core/src/components/DatabaseColumnsMenu.tsx b/packages/app-core/src/components/DatabaseColumnsMenu.tsx new file mode 100644 index 000000000..e80a6e5e2 --- /dev/null +++ b/packages/app-core/src/components/DatabaseColumnsMenu.tsx @@ -0,0 +1,259 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, + type RefObject +} from 'react' +import { createPortal } from 'react-dom' +import type { DatabaseDoc, DbView } from '@shared/databases' +import { + hideAllColumns, + isLastVisibleColumn, + setColumnHidden, + showAllColumns, + viewColumnFields +} from '../lib/database-cells' +import { useStore } from '../store' +import { Button } from './ui/Button' +import { SearchIcon } from './icons' + +const PANEL_WIDTH = 288 + +/** + * Popover that picks the columns one table view shows: a find field, Show all + * and Hide all, and a checklist of the view's columns in its column order. + * Every toggle writes this view's `hiddenFieldIds`, so each saved view keeps + * its own columns, and the last visible column cannot be hidden. + * + * Keys: with Vim on the list holds the keyboard (j / k move, Space or x + * toggles, / jumps to the find field); with Vim off the find field does + * (the arrows move, Space or Enter on a focused column toggles it). Escape + * closes in both. The panel carries `data-db-columns-menu`, one of the + * SELF_KEYED_SURFACES, so the global Vim layer leaves these keys alone. + */ +export function DatabaseColumnsMenu({ + csvPath, + doc, + view, + anchor, + onClose, + ignoreRef +}: { + csvPath: string + doc: DatabaseDoc + view: DbView + anchor: DOMRect + onClose: () => void + /** The trigger element: clicks on it are ignored so it can toggle cleanly. */ + ignoreRef?: RefObject +}): JSX.Element { + const updateDatabaseSchema = useStore((s) => s.updateDatabaseSchema) + const vimMode = useStore((s) => s.vimMode) + const panelRef = useRef(null) + const findRef = useRef(null) + const listRef = useRef(null) + const [query, setQuery] = useState('') + + const columns = useMemo(() => viewColumnFields(doc, view), [doc, view]) + const hidden = useMemo(() => new Set(view.hiddenFieldIds ?? []), [view.hiddenFieldIds]) + const shownCount = columns.filter((f) => !hidden.has(f.id)).length + const needle = query.trim().toLowerCase() + const matches = needle ? columns.filter((f) => f.name.toLowerCase().includes(needle)) : columns + + useEffect(() => { + const onDown = (e: PointerEvent): void => { + const target = e.target as Node + if (ignoreRef?.current?.contains(target)) return // let the trigger toggle + if (panelRef.current && !panelRef.current.contains(target)) onClose() + } + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + onClose() + } + } + document.addEventListener('pointerdown', onDown, true) + document.addEventListener('keydown', onKey, true) + return () => { + document.removeEventListener('pointerdown', onDown, true) + document.removeEventListener('keydown', onKey, true) + } + }, [onClose, ignoreRef]) + + // Vim mode starts on the list, where j / k work; otherwise the find field + // takes the typing, since letters are never shortcuts with Vim off. + useEffect(() => { + if (vimMode) focusItem(0) + else findRef.current?.focus() + // Once, on open: a later Vim toggle should not yank focus around. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const items = (): HTMLElement[] => + Array.from(listRef.current?.querySelectorAll('[data-column-id]') ?? []) + function focusItem(index: number): void { + const list = items() + if (list.length === 0) return + list[Math.max(0, Math.min(index, list.length - 1))].focus() + } + + const write = (next: DatabaseDoc): void => { + if (next !== doc) updateDatabaseSchema(csvPath, next) + } + const toggle = (fieldId: string): void => + write(setColumnHidden(doc, view.id, fieldId, !hidden.has(fieldId))) + + const onFindKeyDown = (e: ReactKeyboardEvent): void => { + if (e.key === 'ArrowDown' || e.key === 'Enter') { + e.preventDefault() + focusItem(0) + } + } + + const onItemKeyDown = (e: ReactKeyboardEvent, index: number, fieldId: string): void => { + if (e.metaKey || e.ctrlKey || e.altKey) return + const key = e.key + const down = key === 'ArrowDown' || (vimMode && key === 'j') + const up = key === 'ArrowUp' || (vimMode && key === 'k') + if (down) { + e.preventDefault() + focusItem(index + 1) + } else if (up) { + e.preventDefault() + if (index === 0) findRef.current?.focus() + else focusItem(index - 1) + } else if (key === 'Home') { + e.preventDefault() + focusItem(0) + } else if (key === 'End') { + e.preventDefault() + focusItem(Number.MAX_SAFE_INTEGER) + } else if (vimMode && key === 'x') { + e.preventDefault() + toggle(fieldId) + } else if (vimMode && key === '/') { + e.preventDefault() + findRef.current?.focus() + findRef.current?.select() + } + // Space and Enter fall through to the button's own click. + } + + const style: CSSProperties = { + position: 'fixed', + top: anchor.bottom + 6, + left: Math.max(8, Math.min(anchor.left, window.innerWidth - PANEL_WIDTH - 8)), + width: PANEL_WIDTH + } + + return createPortal( +
e.stopPropagation()} + className="z-popover flex flex-col gap-2 rounded-lg border border-paper-300 bg-paper-100 p-2 text-ink-900 shadow-float" + > +
+ + 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/DatabaseTableView.tsx b/packages/app-core/src/components/DatabaseTableView.tsx index f8408fc3d..27b0bf693 100644 --- a/packages/app-core/src/components/DatabaseTableView.tsx +++ b/packages/app-core/src/components/DatabaseTableView.tsx @@ -19,6 +19,8 @@ import { deleteField, moveColumn, moveColumnToField, + setColumnHidden, + isLastVisibleColumn, ensureSelectOption, setFieldOptionsSource, updateView, @@ -36,6 +38,7 @@ import { MoreIcon, TrashIcon, PlusIcon, DocumentIcon, DocumentTextIcon, ArrowUpR 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 { resolveWikilinkTarget } from '../lib/wikilinks' import { followLinkTarget } from '../lib/follow-link' import { NoteSuggestRows, stepSuggestIndex, useNoteSuggestions } from './DatabaseNoteSuggest' @@ -58,9 +61,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) @@ -338,6 +343,16 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. e.preventDefault() return openPage(c.row.id) } + case 'c': { + // The Columns popover, to pick the few columns this view shows. A + // single-key shortcut, so it stays off with Vim mode off (the + // header's Columns button and the palette's Choose Database Columns + // remain), and it never fires behind a menu or a dialog. + if (!useStore.getState().vimMode || isOverlayOrDialogOpen()) return + e.preventDefault() + onOpenColumns?.() + return + } case 'O': { // Every record's page at once (o opens one). A new single-key // shortcut, so it stays off with Vim mode off. @@ -420,6 +435,18 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. 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: useStore.getState().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) => ({ diff --git a/packages/app-core/src/components/DatabaseView.tsx b/packages/app-core/src/components/DatabaseView.tsx index b9586c324..d0427eaca 100644 --- a/packages/app-core/src/components/DatabaseView.tsx +++ b/packages/app-core/src/components/DatabaseView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' import { csvPathFromDatabaseTab, formDirFromCsvPath } from '@shared/databases' import { serializeRows } from '@shared/database-csv' import { useStore } from '../store' @@ -9,15 +9,26 @@ import { addView, setActiveView, removeView, - renameView + renameView, + viewColumnFields, + visibleColumnIds } from '../lib/database-cells' import { isImeComposing } from '../lib/ime' import { DatabaseTableView } from './DatabaseTableView' import { DatabaseBoardView } from './DatabaseBoardView' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { DatabaseFilterMenu } from './DatabaseFilterMenu' +import { DatabaseColumnsMenu } from './DatabaseColumnsMenu' import { Button, IconButton } from './ui/Button' -import { DatabaseIcon, TableIcon, KanbanIcon, PlusIcon, FilterIcon, MoreIcon } from './icons' +import { + DatabaseIcon, + TableIcon, + KanbanIcon, + PlusIcon, + FilterIcon, + MoreIcon, + ColumnsIcon +} from './icons' /** * Host for a CSV database tab: loads the database, renders the header @@ -49,6 +60,14 @@ export function DatabaseView({ const [editingTitle, setEditingTitle] = useState(false) const [filterAnchor, setFilterAnchor] = useState(null) const filterBtnRef = useRef(null) + const [columnsAnchor, setColumnsAnchor] = useState(null) + const columnsBtnRef = useRef(null) + const rootRef = useRef(null) + // A request to open the Columns popover for a view, carried out once that + // view is on screen with its Columns button drawn (the `c` key, the + // palette, and a just-made table view all come through here). + const pendingColumnsFor = useRef(null) + const [columnsRequest, setColumnsRequest] = useState(0) // Only `.base` databases rename by title (a legacy loose `.csv` doesn't). const canRenameTitle = !!csvPath && !!formDirFromCsvPath(csvPath) @@ -57,6 +76,38 @@ export function DatabaseView({ if (csvPath && !doc && !loading && !transitioning) void loadDatabase(csvPath) }, [csvPath, doc, loading, loadDatabase, transitioning]) + const activeViewId = doc ? (doc.views.find((v) => v.id === doc.activeViewId) ?? doc.views[0])?.id : undefined + const activeViewType = doc?.views.find((v) => v.id === activeViewId)?.type + // Each view has its own columns, so switching views closes the popover; + // a request waiting for the new view opens it again right after. + useEffect(() => { + setColumnsAnchor(null) + }, [activeViewId]) + useEffect(() => { + if (!pendingColumnsFor.current || pendingColumnsFor.current !== activeViewId) return + if (activeViewType !== 'table' || rawMode) return + pendingColumnsFor.current = null + const rect = columnsBtnRef.current?.getBoundingClientRect() + if (rect) setColumnsAnchor(rect) + }, [columnsRequest, activeViewId, activeViewType, rawMode]) + + const openColumnsFor = useCallback((viewId: string): void => { + pendingColumnsFor.current = viewId + setRawMode(false) + setColumnsRequest((n) => n + 1) + }, []) + + // Back to the grid when the popover closes with nothing else focused (its + // focused row unmounts with it); a click elsewhere keeps what it focused. + const closeColumns = useCallback((): void => { + setColumnsAnchor(null) + requestAnimationFrame(() => { + const a = document.activeElement + if (a && a !== document.body) return + rootRef.current?.querySelector('[data-zen-db-grid]')?.focus({ preventScroll: true }) + }) + }, []) + if (!csvPath) { return (
@@ -73,6 +124,9 @@ export function DatabaseView({ } const activeView = doc.views.find((v) => v.id === doc.activeViewId) ?? doc.views[0] + const columnCount = activeView.type === 'table' ? viewColumnFields(doc, activeView).length : 0 + const shownColumnCount = activeView.type === 'table' ? visibleColumnIds(doc, activeView).length : 0 + const someColumnsHidden = shownColumnCount < columnCount const viewMenuItems = (viewId: string): ContextMenuItem[] => [ { label: 'Rename view', onSelect: () => setRenamingView(viewId) }, @@ -85,7 +139,7 @@ export function DatabaseView({ ] return ( -
+
{editingTitle && canRenameTitle ? ( @@ -205,6 +259,24 @@ export function DatabaseView({ Filter {(activeView.filters?.length ?? 0) > 0 ? ` (${activeView.filters.length})` : ''} + {activeView.type === 'table' && ( + + )} + Include subfolders + +
+ + { + 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/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/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index 4196be904..a637b5f1c 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -43,6 +43,7 @@ import { CheckSquareIcon, CloseIcon, DatabaseIcon, + GalleryIcon, DocumentIcon, ExcalidrawIcon, ExpandAllIcon, @@ -503,6 +504,7 @@ export function Sidebar(): JSX.Element { const openHelpView = useStore((s) => 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..a6534adbc 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { completionStatus } from '@codemirror/autocomplete' import { isAtlasViewActive, + isGalleryViewActive, isTagsViewActive, isTasksViewActive, isUnresolvedLinksViewActive, @@ -1017,11 +1018,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 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/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/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..a7de05141 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' @@ -810,6 +817,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 /** Manual Kanban column arrangement per board. Keyed by groupBy → ordered @@ -1250,6 +1263,8 @@ export const DEFAULT_PREFS: Prefs = { kanbanCardSort: 'manual', tasksListOrder: 'note', timelineRange: DEFAULT_TIMELINE_RANGE, + gallerySort: 'updated', + galleryIncludeSubfolders: false, kanbanColumnTitles: {}, kanbanColumnOrder: {}, kanbanCardOrder: {}, @@ -1629,6 +1644,11 @@ function normalizePrefs(p: Partial): 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 +1964,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: { @@ -2591,6 +2617,8 @@ function collectPrefs(s: { kanbanCardSort: KanbanCardSort tasksListOrder: TasksListOrder timelineRange: TimelineRange + gallerySort: GallerySort + galleryIncludeSubfolders: boolean kanbanColumnTitles: Record kanbanColumnOrder: Record kanbanCardOrder: Record @@ -2709,6 +2737,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 +3086,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 @@ -3386,6 +3425,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 +3592,9 @@ interface Store { openWorkflowsView: () => Promise /** Open the Atlas map as a tab in the active pane. */ openAtlasView: () => Promise + /** 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 /** 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 +3707,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, @@ -4712,6 +4760,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) @@ -6239,6 +6289,8 @@ export const useStore = create((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 +6618,15 @@ export const useStore = create((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 +7807,14 @@ export const useStore = create((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 diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 68140a912..85473ac8d 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; } } diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index e53a7fe0c..28643653b 100644 --- a/packages/bridge-contract/src/app-config.ts +++ b/packages/bridge-contract/src/app-config.ts @@ -106,6 +106,8 @@ export const PORTABLE_PREF_KEYS = [ 'kanbanCardSort', 'tasksListOrder', 'timelineRange', + 'gallerySort', + 'galleryIncludeSubfolders', 'kanbanFolderRoot', 'kanbanColumnTitles', 'kanbanStatuses', diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index 96b830dd9..08fe2c011 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -172,6 +172,8 @@ export const PORTABLE_DEFAULTS: Record = { kanbanCardSort: 'manual', tasksListOrder: 'note', timelineRange: 'two-weeks', + gallerySort: 'updated', + galleryIncludeSubfolders: false, kanbanFolderRoot: '', kanbanColumnTitles: {}, kanbanStatuses: [], From 426f06d5d0043891733e3004ea0eff6045907e67 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 10:27:36 -0500 Subject: [PATCH 13/27] Feat(gallery): Open Folder Gallery in the palette, Space G and :gallery Every new feature ships with a Vim binding, and the folder menu is a mouse route, so the Gallery gets three keyboard openers: - Open Folder Gallery in the command palette (view.folder-gallery). It is in the commands registry, so it can take a key of its own (#971). - Space G with Vim on. `g` is the Atlas, so the Gallery takes the capital: after the leader `G` is a key of its own, as `q` and `Q` are. The free lowercase keys (j, k, x, y, z) say nothing about a gallery, and `x` is where the manual's examples put people's own command keys. A user command sequence can no longer start on `G`, since built-in leader keys win. The leader hints list it as Folder gallery. - `:gallery` (`:gal`) on the ex line, with an optional folder: `:gallery Projects/Website`, `:gallery inbox/Projects` or `:gallery archive`. A folder nothing in the vault answers says so in a toast instead of opening an empty tab. Which folder, when none is named (lib/folder-gallery-open.ts): the row under the sidebar cursor while the sidebar holds the keyboard (a folder row is that folder, a note row its note's folder, the Archive row the archive), the way the palette's Move Note to Trash follows the sidebar (#972); otherwise the open note's folder; from a Gallery its own folder; and last the top of the notes area. Verified in the built app over CDP: :gallery from a note opens its folder, the palette row shows Space G and opens the open note's folder (a subfolder, Recipes/Desserts), Space G from a note opens its folder, and with the sidebar focused and its cursor walked onto the Recipes folder row, Space G opens Recipes. Tests: the folder each context means (sidebar folder, note and Archive rows, the open note, an open Gallery, the fallback) and the argument path, including the toast for a folder that is not there. --- packages/app-core/src/components/Editor.tsx | 11 ++ packages/app-core/src/components/VimNav.tsx | 13 ++ packages/app-core/src/lib/commands.ts | 12 ++ .../src/lib/folder-gallery-open.test.ts | 116 ++++++++++++++++++ .../app-core/src/lib/folder-gallery-open.ts | 75 +++++++++++ packages/app-core/src/lib/keymaps.ts | 15 +++ .../shared-domain/src/commands-catalog.ts | 1 + packages/shared-domain/src/keymaps-catalog.ts | 1 + 8 files changed, 244 insertions(+) create mode 100644 packages/app-core/src/lib/folder-gallery-open.test.ts create mode 100644 packages/app-core/src/lib/folder-gallery-open.ts diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 07eecd677..087cd38fa 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -109,6 +109,7 @@ import { promptImageWidth, setImageWidthFromInput } from "../lib/image-resize"; import { copyLinkAtCursor } from "../lib/link-copy"; import { followLinkTarget } from "../lib/follow-link"; import { openLinkPopoverAtCursor } from "../lib/link-popover-open"; +import { openGalleryFromArgument } from "../lib/folder-gallery-open"; let vimCommandsRegistered = false; let syncedVimBindings: Partial> = {}; @@ -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/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index a6534adbc..3c8a921f5 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -8,6 +8,7 @@ import { isUnresolvedLinksViewActive, useStore } from '../store' +import { openContextGallery } from '../lib/folder-gallery-open' import { HintOverlay } from './HintOverlay' import { WhichKeyOverlay, type WhichKeyItem } from './WhichKeyOverlay' import { @@ -404,6 +405,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 ? [ { @@ -1127,6 +1133,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() diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index e865ddc48..cda4f9892 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' @@ -1566,6 +1567,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/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/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index d9af1ac0a..d6f723263 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -81,6 +81,7 @@ export type KeymapId = | "vim.leaderCalendar" | "vim.leaderTimeline" | "vim.leaderUnresolvedLinks" + | "vim.leaderFolderGallery" | "vim.panePrefix" | "vim.paneFocusLeft" | "vim.paneFocusDown" @@ -880,6 +881,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 (`q` and `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/shared-domain/src/commands-catalog.ts b/packages/shared-domain/src/commands-catalog.ts index ab7ede7ab..df16e6b8a 100644 --- a/packages/shared-domain/src/commands-catalog.ts +++ b/packages/shared-domain/src/commands-catalog.ts @@ -141,6 +141,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/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index d936ee72e..852d3c1ae 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -113,6 +113,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" }, From 77adcc998c2befefa8c6f3b815daeb6370233a3d Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 10:27:54 -0500 Subject: [PATCH 14/27] Docs(gallery): the in-app manual covers the folder Gallery The Help view now has: - a how-to card, "See a folder as a gallery of its covers", after the two cover cards: what a card shows, the four ways to open it and which folder each one picks, sort and subfolders with their config.toml keys, what is left out, the keys with Vim on and off, Ctrl+O back to the card, and that it reads only the covers on screen, works on a self-hosted server and in the web app, and keeps two columns on a phone; - Space G in the leader table, next to Space u; - `:gallery [folder]` in the ex commands, before `:unresolved`; - a Gallery view section with its keys. HelpView resolves those keys from the nav.* bindings and Space G from vim.leaderFolderGallery, so the manual follows a rebinding; - the Gallery among the list views a chord can reach (#972). Open Folder Gallery reaches the manual's command list on its own, from the palette. The website text, the same facts, is in docs/releases/v2.79.0/website-docs-folder-gallery.md for the website pass. --- packages/app-core/src/components/HelpView.tsx | 17 ++++++++++++ packages/app-core/src/lib/help.ts | 26 ++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/app-core/src/components/HelpView.tsx b/packages/app-core/src/components/HelpView.tsx index 88784a3a0..96a2976e9 100644 --- a/packages/app-core/src/components/HelpView.tsx +++ b/packages/app-core/src/components/HelpView.tsx @@ -104,6 +104,7 @@ 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 === 'Switch vault') return leaderShortcut(overrides, 'vim.leaderSwitchVault') if (action === 'Open workflows') return leaderShortcut(overrides, 'vim.leaderWorkflows') if (action === 'Review Cloud conflicts') { @@ -142,6 +143,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/lib/help.ts b/packages/app-core/src/lib/help.ts index 0e5f8e5cb..4db628962 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -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', @@ -642,6 +647,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.' }, @@ -793,6 +799,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', @@ -1051,6 +1070,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', From a7b5540f3253998cd06ba502d165d416d44fcb18 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 10:30:10 -0500 Subject: [PATCH 15/27] Docs(whats-new): 2.79.0 also brings the folder Gallery and database views that keep their own columns charles b. asked for both on release morning and they joined 2.79.0: a folder opens as a Gallery of its notes by cover, and a database table view keeps its own columns and picked rows. The What's New card names them in the words the app shows (Open as Gallery, Columns, Show only these in this view) and the keys that open them. --- packages/app-core/src/lib/whats-new.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index 1ef255109..d2a83d53a 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -57,6 +57,14 @@ 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: '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.' } ] }, From 638a7bcb2688839f91c04b75ad996037833319ce Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 11:09:30 -0500 Subject: [PATCH 16/27] Feat(editor): a block becomes a callout, changes type, or stops being one, as one edit (#1016) williamcheuk03 asked for a right-click way to make text a callout and to switch an existing callout between types. Before any menu, this lands the edits themselves as pure functions over the note text, so the menu, the palette and a leader key can share one rule for what they touch. What a caret or a selection targets: - Inside a callout, the innermost callout around the whole selection. - Otherwise a selection covers its whole lines (one that ends at the very start of a line leaves that line out), and a caret takes its paragraph: the non-blank lines around it, stopping at a heading, a code fence and the edge of a quote. A caret anywhere in a list takes the whole list, and a paragraph inside a list item stays in that item, indentation and all. - A block is never cut in half. A selection that reaches into part of a fence or a quote grows to hold all of it, so a callout it touches ends up nested, whole, inside the new one. - Nothing in a code fence or in the frontmatter: the lines there are code and YAML, and `> ` in front of them would change what they say. The three edits: - Turn into writes `> [!type]` and prefixes each line `> ` (a blank line `>`). A plain blockquote already has its markers and only gets the title line above it. Markdown reads text right under a quote as more of that quote, so a callout made from part of a paragraph, or next to a quote, gets a blank line where it would run into its neighbor; without it the title under a quote is no callout at all. - Change rewrites only the keyword, so the fold marker and the title stay. A callout already of that type, alias included (`[!error]` is danger), is left as written. - Remove drops the title line, keeping a custom title as a plain first line, and strips one level of `> `, the callout's own, so a quote or callout nested inside keeps its markers. Lines split at `\n` with any `\r` kept, so a CRLF note stays CRLF. The editor side dispatches each edit with its own user event, not `input.type`, so typing just before it never folds into the same Undo. --- .../app-core/src/lib/callout-menu.test.ts | 102 +++ packages/app-core/src/lib/callout-menu.ts | 99 +++ .../src/lib/callout-transform.test.ts | 412 ++++++++++++ .../app-core/src/lib/callout-transform.ts | 589 ++++++++++++++++++ 4 files changed, 1202 insertions(+) create mode 100644 packages/app-core/src/lib/callout-menu.test.ts create mode 100644 packages/app-core/src/lib/callout-menu.ts create mode 100644 packages/app-core/src/lib/callout-transform.test.ts create mode 100644 packages/app-core/src/lib/callout-transform.ts 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] + } + ] +} From 84f21c810522a55c5e279db4c80b6d927a34aefa Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 11:12:17 -0500 Subject: [PATCH 17/27] Feat(editor): right-click a paragraph or a callout to set its callout type (#1016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor's right-click menu gains a callout group after Paste, behind a separator: - On text, a selection or the paragraph under the pointer: "Turn into callout…". - Inside a callout, written out or drawn as a card in Live Preview: "Change callout type…" and "Remove callout". - In a code block or the frontmatter, "Turn into callout…" shows disabled with the reason beside it, the way Cut and Copy show disabled without a selection, so the menu says why rather than hiding the row. A blank line gets no row: there is nothing there to turn into anything, and it is where a paste usually goes. The "…" items open a second menu at the same spot, titled "Callout type": all twelve types in CALLOUT_TYPES order with their icons, the current type checked and the cursor starting on it. The issue asked for Error by name; `error` is danger's alias, so that row reads "Danger (Error)" and typing "error" finds it. The filter also matches the other aliases, so "caution" finds Warning and "tldr" Abstract. ContextMenu learns three small things for it, no submenus: a title, a checked row (a check mark and menuitemradio), and keywords the filter matches. What the items act on follows the pointer, not the caret. A callout in Live Preview is line decorations over editable text, so its right-click already reached this menu, but the menu acted on the selection, which can sit anywhere: a click on a rendered callout has to mean that callout. A right-click inside the selection still means the selection. The pane notes the selection when the right button goes down, because on a Mac the press selects the word under the pointer before the menu opens. A menu opened from the keyboard has no pointer and acts on the selection. The reading view stays read-only. Its only write is ticking a task; double-click a callout there and it opens in the editor, where the menu is. --- .../src/components/ContextMenu.test.ts | 68 ++++++++ .../app-core/src/components/ContextMenu.tsx | 37 +++- .../app-core/src/components/EditorPane.tsx | 159 +++++++++++++++++- 3 files changed, 252 insertions(+), 12 deletions(-) diff --git a/packages/app-core/src/components/ContextMenu.test.ts b/packages/app-core/src/components/ContextMenu.test.ts index dbdd5e566..5dba41336 100644 --- a/packages/app-core/src/components/ContextMenu.test.ts +++ b/packages/app-core/src/components/ContextMenu.test.ts @@ -45,3 +45,71 @@ 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']) + }) +}) diff --git a/packages/app-core/src/components/ContextMenu.tsx b/packages/app-core/src/components/ContextMenu.tsx index 131ad2423..f0fc73f61 100644 --- a/packages/app-core/src/components/ContextMenu.tsx +++ b/packages/app-core/src/components/ContextMenu.tsx @@ -1,5 +1,6 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' +import { CheckIcon } from './icons' export interface ContextMenuItem { kind?: 'item' | 'separator' @@ -11,6 +12,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,6 +24,8 @@ interface Props { x: number y: number items: ContextMenuItem[] + /** A heading above the items, for a menu that picks one of a set. */ + title?: string onClose: () => void } @@ -32,7 +40,7 @@ interface Props { * so the user knows they're filtering. Backspace deletes a character, * Escape clears the query (one level) before closing the menu. */ -export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element { +export function ContextMenu({ x, y, items, title, onClose }: Props): JSX.Element { const ref = useRef(null) const previousFocusRef = useRef(null) const [pos, setPos] = useState({ left: x, top: y }) @@ -51,7 +59,9 @@ export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element { // specific item once the list has been shuffled. if (item.kind === 'separator') continue const label = item.label?.toLowerCase() ?? '' - if (!label.includes(q)) continue + if (!label.includes(q) && !item.keywords?.some((k) => k.toLowerCase().includes(q))) { + continue + } } out.push({ item, originalIdx: i }) } @@ -62,12 +72,14 @@ export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element { // 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(() => { @@ -186,12 +198,16 @@ 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" style={{ left: pos.left, top: pos.top }} onContextMenu={(e) => e.preventDefault()} > + {title && ( +
{title}
+ )} {query && (
filter @@ -217,6 +233,8 @@ export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element { return ( ) }) diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 8777493f7..192df3f41 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -329,6 +329,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 { @@ -937,6 +951,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]]` @@ -1174,7 +1207,23 @@ 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 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) @@ -1297,6 +1346,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) @@ -1306,12 +1356,38 @@ 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]) + const rememberCurrentTabScroll = useCallback((path = viewPathRef.current): void => { if (!path) return const prev = recallTabScroll(path) @@ -1603,6 +1679,12 @@ 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]) + // `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. @@ -4698,6 +4780,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { : 'flex flex-1 flex-col' ].join(' ')} style={{ display: showEditor ? 'flex' : 'none' }} + onMouseDown={(e) => { + 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 @@ -4707,6 +4796,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, @@ -4714,7 +4811,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) }) }} > @@ -4877,11 +4976,60 @@ 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, + 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)} + /> + )} ) } @@ -4989,7 +5137,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 [] @@ -5073,6 +5223,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'), From 1e1d7d17e7fb6ba1fc08620df8ce907735427296 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 11:18:54 -0500 Subject: [PATCH 18/27] =?UTF-8?q?Feat(editor):=20Set=20Callout=20Type?= =?UTF-8?q?=E2=80=A6=20and=20Remove=20Callout=20from=20the=20palette,=20an?= =?UTF-8?q?d=20Space=20l=20a=20(#1016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every feature ships with a key, so the callout menu gets three ways in that need no mouse: - "Set Callout Type…" (editor.callout-type) opens the type menu at the caret. Outside a callout a pick turns the paragraph or the selected lines into one; inside, it changes that callout's type, the current one checked. In a code block, the frontmatter or on a blank line it says why in a toast instead. - "Remove Callout" (editor.remove-callout) takes the callout at the caret back to plain text, or says the cursor is not in one. - `Space l a` in Vim mode runs Set Callout Type…. `c` is the cover, so it takes `a`, for admonition and for the alerts GitHub calls them. It sits in the note-actions group with its which-key row ("Callout type"), and the right-click menu shows it beside Turn into and Change type, in Vim mode only: with Vim off the chord does nothing, so no row names it. Both commands are listed whenever a note is open, like Convert Table to Database, and both can take a key of their own (#971); the catalogs in shared-domain carry them, and the leader group knows `a` is taken. Remove Callout has no default key: it is one row away in the menu, from the right-click or from Shift+F10 on a selection. Running Set Callout Type… from the palette first left the menu open without the keyboard. The palette, closing, hands the focus back to the editor once the command has run, unless the command opened something it knows of; a ContextMenu was not on that list, so the letters typed to filter the menu went into the note as Vim commands. A command that leaves a menu open now keeps the keyboard in it. --- .../src/components/CommandPalette.tsx | 7 +++-- .../app-core/src/components/EditorPane.tsx | 9 +++++- packages/app-core/src/components/VimNav.tsx | 13 ++++++++ packages/app-core/src/lib/command-keymaps.ts | 1 + packages/app-core/src/lib/commands.ts | 31 +++++++++++++++++++ packages/app-core/src/lib/keymaps.ts | 14 +++++++++ .../shared-domain/src/commands-catalog.ts | 2 ++ packages/shared-domain/src/keymaps-catalog.ts | 1 + 8 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index e06e1f1f9..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() diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 192df3f41..c032d9260 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -1081,6 +1081,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) @@ -4981,7 +4988,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { (entry): ContextMenuItem => ({ label: entry.label, disabled: entry.disabled, - hint: entry.reason, + hint: entry.reason ?? (entry.action === 'remove' ? undefined : calloutChordHint), onSelect: () => { const view = viewRef.current if (!view) return diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index 3c8a921f5..ca4ac5bc7 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -63,6 +63,7 @@ import { trashSidebarCursorRow } from '../lib/sidebar-note-actions' import { convertTableToDatabase } from '../lib/table-to-database' +import { requestCalloutTypeMenu } from '../lib/callout-menu' import { confirmDeleteComment } from '../lib/confirm-trash' import { ZEN_OPEN_EDITOR_CONTEXT_MENU_EVENT, @@ -315,6 +316,11 @@ 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.' + }, ...(unsplashSearchAvailable() ? [ { @@ -1326,6 +1332,13 @@ export function VimNav(): JSX.Element | null { openUnsplashCoverPicker() return } + if (matchesSequenceToken(e, overrides, 'vim.leaderCalloutType') && editorNormalMode) { + e.preventDefault() + e.stopImmediatePropagation() + resetLeader() + requestCalloutTypeMenu() + return + } resetLeader() } diff --git a/packages/app-core/src/lib/command-keymaps.ts b/packages/app-core/src/lib/command-keymaps.ts index f25aad562..bb9779b25 100644 --- a/packages/app-core/src/lib/command-keymaps.ts +++ b/packages/app-core/src/lib/command-keymaps.ts @@ -163,6 +163,7 @@ const LEADER_SUBGROUP_IDS: ReadonlySet = new Set([ 'vim.leaderTableToDatabase', 'vim.leaderNoteCover', 'vim.leaderNoteCoverUnsplash', + 'vim.leaderCalloutType', 'vim.leaderSearchVaultText' ]) diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 1a76e39b6..2cd21e8dc 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -44,6 +44,7 @@ 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 { activeNoteCover, coverNotePath, @@ -1374,6 +1375,36 @@ 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() + } + }, { id: 'editor.copy-link', title: 'Copy Link Under Cursor', diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index d6f723263..3238cbd56 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -71,6 +71,7 @@ export type KeymapId = | "vim.leaderTableToDatabase" | "vim.leaderNoteCover" | "vim.leaderNoteCoverUnsplash" + | "vim.leaderCalloutType" | "vim.leaderQuickCapture" | "vim.leaderHideToTray" | "vim.leaderTemplatePicker" @@ -766,6 +767,19 @@ 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.leaderQuickCapture", kind: "sequence", diff --git a/packages/shared-domain/src/commands-catalog.ts b/packages/shared-domain/src/commands-catalog.ts index f13f2bd3e..63f07a120 100644 --- a/packages/shared-domain/src/commands-catalog.ts +++ b/packages/shared-domain/src/commands-catalog.ts @@ -122,6 +122,8 @@ export const COMMAND_CATALOG: CommandCatalogEntry[] = [ { 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.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" }, diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index 852d3c1ae..d31358e88 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -103,6 +103,7 @@ 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.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" }, From 63f079ca1243f968bcb3c78371e677e326062da7 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 11:18:54 -0500 Subject: [PATCH 19/27] Fix(menus): with Vim off, j and k are letters in a menu, not moves The shared ContextMenu moved its cursor on j and k whatever the Vim setting, while its other letters filter the list. The rule since the sidebar work is that with Vim off only the arrows, Enter and Escape act on a list; single letters are text. The callout type menu (#1016) made the gap plain: with Vim off, typing "j" should look for a type, and it jumped a row instead. Now j and k move only with Vim on. With it off they join the filter like every other letter. Ctrl+N and Ctrl+P keep moving either way, since a chord is not text. The arrows, Enter, Escape and Backspace are unchanged. --- .../src/components/ContextMenu.test.ts | 63 +++++++++++++++++++ .../app-core/src/components/ContextMenu.tsx | 16 +++-- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/packages/app-core/src/components/ContextMenu.test.ts b/packages/app-core/src/components/ContextMenu.test.ts index 5dba41336..05aca3db3 100644 --- a/packages/app-core/src/components/ContextMenu.test.ts +++ b/packages/app-core/src/components/ContextMenu.test.ts @@ -3,6 +3,7 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useStore } from '../store' import { ContextMenu } from './ContextMenu' describe('ContextMenu focus', () => { @@ -113,3 +114,65 @@ describe('ContextMenu picking one of a set (#1016)', () => { 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') + }) +}) diff --git a/packages/app-core/src/components/ContextMenu.tsx b/packages/app-core/src/components/ContextMenu.tsx index f0fc73f61..30440b95e 100644 --- a/packages/app-core/src/components/ContextMenu.tsx +++ b/packages/app-core/src/components/ContextMenu.tsx @@ -1,5 +1,6 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' +import { useStore } from '../store' import { CheckIcon } from './icons' export interface ContextMenuItem { @@ -36,7 +37,8 @@ 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. */ @@ -46,6 +48,7 @@ export function ContextMenu({ x, y, items, title, onClose }: Props): JSX.Element 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. @@ -137,19 +140,14 @@ export function ContextMenu({ x, y, items, title, 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 (e.key === 'ArrowDown' || (vimMode && plain && 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 && e.key === 'k')) { e.preventDefault() e.stopPropagation() setVisibleActive((i) => stepVisible(visible, i, -1)) From f0a1b63fa639ffbf3002d4e1805844749ec35a56 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 11:21:37 -0500 Subject: [PATCH 20/27] Docs(callouts): the in-app manual covers the callout menu (#1016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Help view now has: - a how-to card after "Callouts highlight the important bits", "Turn text into a callout from the menu": the three right-click items and the "Callout type" menu by the names they carry, Danger (Error) and the alias filter, what a selection, a paragraph, a list and a plain quote become, the blank line a callout gets beside its neighbors, what Change keeps and what Remove drops, Set Callout Type… and Remove Callout in the palette, Space l a, `m` or Shift+F10 on a selection in Vim mode, one Undo step, why code and properties are refused, and the keys with Vim off; - a pointer to it at the end of the callouts card; - Space l a in the leader table. HelpView resolves it from vim.leaderNoteActions and vim.leaderCalloutType, so the row follows a rebinding. Set Callout Type… and Remove Callout reach the manual's command list on their own, from the palette. The website text, the same facts, is in docs/releases/v2.79.0/website-docs-callout-menu.md for the website pass. --- packages/app-core/src/components/HelpView.tsx | 6 ++++++ packages/app-core/src/lib/help.ts | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/app-core/src/components/HelpView.tsx b/packages/app-core/src/components/HelpView.tsx index 96a2976e9..59698788c 100644 --- a/packages/app-core/src/components/HelpView.tsx +++ b/packages/app-core/src/components/HelpView.tsx @@ -105,6 +105,12 @@ function resolveShortcutKeys( 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 === 'Switch vault') return leaderShortcut(overrides, 'vim.leaderSwitchVault') if (action === 'Open workflows') return leaderShortcut(overrides, 'vim.leaderWorkflows') if (action === 'Review Cloud conflicts') { diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index ad5cf7bd8..2ed8d0e1c 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -452,7 +452,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', @@ -660,6 +665,7 @@ 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 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.' }, From b996b58bede9d89e15d0004e151c9371e3d1b89c Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 11:27:02 -0500 Subject: [PATCH 21/27] Test(search): the tagged-block palette test waits for its rows, not for 80 ms The gate run on 14b2159e failed "lists two blocks under a note, then a row for the rest" once: the rows were not drawn yet. The test waited a fixed 80 ms for the tagged blocks' reads, which land through a notify timer, and with every package's suite running at once (turbo test:run) that chain took longer. On its own the file passed 5 of 5 and the app-core suite 4184 of 4184. After typing a #tag query the tests now wait until the first block row is drawn (3 s at most), then settle as before. The test that turns tagged blocks off, and the wait after Enter, keep the plain settle: no block row ever appears there. No product code changed. --- .../components/SearchPaletteBlocks.test.ts | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) 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') }) From 2b2b243b8f7b90a32eb7deb17c342943a003fc8f Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 13:06:12 -0500 Subject: [PATCH 22/27] Feat(menus): a long menu gets a find field, with the best match first The code block language picker (#1014) lists every language the editor can highlight, some 130 of them. The shared ContextMenu was built for a dozen rows: it filtered on typed letters without a field to show them, it never scrolled, and a letter matched anywhere in a name, so `c` then Enter picked Brainfuck. A phone has no keyboard to type with until a field is tapped. A menu given `findPlaceholder` now has: - a find field above the rows, holding the query. With Vim off it takes the keyboard when the menu opens and every letter is text. With Vim on the list keeps it, as in every other menu: j and k move, and `/` goes to the field, where j and k are letters too. The arrows, Ctrl+N / Ctrl+P, Enter and Escape act from either place, and an input method's Enter commits its word instead of picking a row. - rows that scroll under the field (a 52vh cap) and keep the cursor's row in sight, at a fixed width so filtering does not resize the menu. - ranked matches: a label or keyword that is the query, then one that starts with it, then a word that starts with it, then anything holding it. Equal matches keep their order. - on a touch screen, the list in charge when it opens, so the keyboard does not rise over the rows it would pick from (the PromptModal rule). `anchorTop` lets a menu opened just below something open above it when there is no room below, instead of sliding up over what opened it. Menus without `findPlaceholder` behave as before. --- .../src/components/ContextMenu.test.ts | 145 +++++++++++++++++- .../app-core/src/components/ContextMenu.tsx | 128 ++++++++++++++-- 2 files changed, 255 insertions(+), 18 deletions(-) diff --git a/packages/app-core/src/components/ContextMenu.test.ts b/packages/app-core/src/components/ContextMenu.test.ts index 05aca3db3..64f46d4e4 100644 --- a/packages/app-core/src/components/ContextMenu.test.ts +++ b/packages/app-core/src/components/ContextMenu.test.ts @@ -2,7 +2,7 @@ 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' @@ -176,3 +176,146 @@ describe('ContextMenu letters with Vim mode on and off', () => { 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 30440b95e..367c05d7a 100644 --- a/packages/app-core/src/components/ContextMenu.tsx +++ b/packages/app-core/src/components/ContextMenu.tsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useStore } from '../store' -import { CheckIcon } from './icons' +import { CheckIcon, SearchIcon } from './icons' export interface ContextMenuItem { kind?: 'item' | 'separator' @@ -27,9 +27,36 @@ interface Props { 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 @@ -41,9 +68,15 @@ interface Props { * 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, title, 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) @@ -54,22 +87,25 @@ export function ContextMenu({ x, y, items, title, onClose }: Props): JSX.Element // 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) && !item.keywords?.some((k) => k.toLowerCase().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. @@ -94,19 +130,28 @@ export function ContextMenu({ x, y, items, title, 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) @@ -132,7 +177,21 @@ export function ContextMenu({ x, y, items, title, 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() @@ -141,13 +200,19 @@ export function ContextMenu({ x, y, items, title, onClose }: Props): JSX.Element return } const plain = !e.metaKey && !e.ctrlKey && !e.altKey - if (e.key === 'ArrowDown' || (vimMode && plain && e.key === 'j')) { + 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' || (vimMode && plain && e.key === 'k')) { + if (e.key === 'ArrowUp' || (vimMode && plain && !inFind && e.key === 'k')) { e.preventDefault() e.stopPropagation() setVisibleActive((i) => stepVisible(visible, i, -1)) @@ -175,6 +240,11 @@ export function ContextMenu({ x, y, items, title, 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() @@ -199,20 +269,42 @@ export function ContextMenu({ x, y, items, title, onClose }: Props): JSX.Element 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()} > {title && (
{title}
)} - {query && ( + {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 @@ -233,6 +325,7 @@ export function ContextMenu({ x, y, items, title, onClose }: Props): JSX.Element key={`${originalIdx}-${item.label}`} role={item.checked === undefined ? 'menuitem' : 'menuitemradio'} aria-checked={item.checked === undefined ? undefined : item.checked} + data-ctx-active={active ? '' : undefined} disabled={item.disabled} onMouseEnter={() => setVisibleActive(i)} onClick={() => { @@ -274,6 +367,7 @@ export function ContextMenu({ x, y, items, title, onClose }: Props): JSX.Element ) }) )} +
, document.body ) From 65aa4454a401352c9f8fa0238d6b000e6f06ad63 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 13:06:34 -0500 Subject: [PATCH 23/27] Feat(editor): a code block's label picks its language, and guesses one it lacks (#1014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit williamcheuk03 asked for a language drop-down on code blocks that lists the supported languages and allows a manual override, alongside automatic detection. The label at the top right of each fenced block in Edit mode read the fence's language and, pressed, copied the block. Changing the language meant finding the fence and typing a tag the editor might not know, and a block with no tag stayed grey in the editor while the reading view coloured it with highlight.js' best guess, however poor. The label is now a language button, "typescript ▾", with copy moved to an icon button beside it (aria-label "Copy code", a check while copied). The button opens the "Code block language" picker, the shared menu with a find field: every language the editor highlights by name (the eager grammars, language-data and the user's TextMate languages), the current one checked, tags searchable (`golang` finds Go). A pick rewrites only the opening fence's language word: the backticks or tildes, their count, a quote's markers and anything after the word stay, the caret stays on its character, and it is one Undo step of its own. "Text (plain)" takes the language away (or writes `text` when attributes follow, which deleting the word would promote to the language). Each row writes a tag the editor reads back as that language: `cpp`, `csharp`, `bash` for Shell (highlight.js reads `shell` as a terminal session); a language no fence tag can reach, such as VB.NET, is left out rather than offered as a pick that would not highlight. A block that names no language is guessed at, off the typing path: 400 ms after edits or scrolling settle, in idle time, a few blocks per slice, cached by the block's text. highlight.js scores the block in 22 languages that both lowlight's common set and the editor know, and a guess must be confident: - at least 2 lines and 24 characters; - relevance 8 or more on the first 2,000 characters (a block over 50,000 characters is not guessed at); - a hallmark of the language in the text, since relevance only adds up and a long paragraph clears any bar as CSS ("a", "code" and "line" are tag names a selector may use): `fn` or `::` for Rust, an `end` line for Ruby, a `prop: value;` rule for CSS, a statement opening a line for SQL; - no language of another family within 2 points (a class that is valid Java and valid C# stays text). A shebang, `() 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 @@ -1063,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() @@ -1227,6 +1247,18 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { 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. @@ -1395,6 +1427,73 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }) }, [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) @@ -1692,6 +1791,18 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { 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. @@ -2299,7 +2410,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { s0.renderTablesInLivePreview, s0.mathRenderer, selectTypstPreambleFor(s0, initialPath), - documentDiagramTheme() + documentDiagramTheme(), + s0.detectCodeLanguages ) : [] ), @@ -2921,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 @@ -5037,6 +5149,17 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { onClose={() => setCalloutMenu(null)} /> )} + {codeLanguageMenu && ( + setCodeLanguageMenu(null)} + /> + )} ) } 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/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/store.ts b/packages/app-core/src/store.ts index a7de05141..40b16643b 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -621,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 @@ -1170,6 +1174,7 @@ export const DEFAULT_PREFS: Prefs = { listIndentGuides: true, linkPopoverOnHover: true, renderTablesInLivePreview: true, + detectCodeLanguages: true, completedTaskStyle: 'none', mathRenderer: 'katex', typstTagPreambles: false, @@ -1365,6 +1370,10 @@ function normalizePrefs(p: Partial): 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' || @@ -2529,6 +2538,7 @@ function collectPrefs(s: { listIndentGuides: boolean linkPopoverOnHover: boolean renderTablesInLivePreview: boolean + detectCodeLanguages: boolean completedTaskStyle: CompletedTaskStyle mathRenderer: MathRenderer typstTagPreambles: boolean @@ -2652,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, @@ -3219,6 +3230,7 @@ interface Store { listIndentGuides: boolean linkPopoverOnHover: boolean renderTablesInLivePreview: boolean + detectCodeLanguages: boolean completedTaskStyle: CompletedTaskStyle mathRenderer: MathRenderer typstTagPreambles: boolean @@ -3886,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 @@ -6199,6 +6212,7 @@ export const useStore = create((set, get) => { listIndentGuides: loadPrefs().listIndentGuides, linkPopoverOnHover: loadPrefs().linkPopoverOnHover, renderTablesInLivePreview: loadPrefs().renderTablesInLivePreview, + detectCodeLanguages: loadPrefs().detectCodeLanguages, completedTaskStyle: loadPrefs().completedTaskStyle, mathRenderer: loadPrefs().mathRenderer, typstTagPreambles: loadPrefs().typstTagPreambles, @@ -9532,6 +9546,10 @@ export const useStore = create((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 85473ac8d..016a955c8 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -5552,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 . Mirrors Obsidian's From fc28cd6a290fd510ee3d1c18f7eb7b1dea72c16c Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 13:06:55 -0500 Subject: [PATCH 24/27] Feat(settings): Detect code block languages, and the reading view guesses as the editor does (#1014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Editor → Writing gets "Detect code block languages", on by default. Off, Edit mode guesses nothing (the detection extension is not loaded) and an unlabeled block is plain text. The reading view guessed on its own: rehype-highlight ran with `detect`, which takes highlight.js' best score over all of lowlight's languages however low, so `x = 1` came out as INI and a paragraph as CSS, and a block the editor now calls "python · detected" could read as another language there. The reading view now names an unlabeled block with the editor's guesser, the same subset and bar, and rehype-highlight colours it by that class with its own `detect` off. Both surfaces agree on every block, and the setting switches both: off, the reading view leaves an unlabeled block plain as well. A tiny block that `detect` used to colour stays plain now. An indented code block, which the editor cannot label, is still guessed at in the reading view, with the same bar. The setting is portable: `detect_code_languages` under `[editor]` in config.toml, beside `render_tables`. It is a writing preference with nothing machine-specific in it, like its neighbours, so it travels with the rest of the editor's settings. markdown-settings carries it to the renderer, with its revision in the render cache key, so a switch redraws the reading view at once. --- apps/desktop/src/main/app-config.ts | 5 +++ packages/app-core/src/App.tsx | 6 +++ packages/app-core/src/components/Preview.tsx | 4 ++ .../app-core/src/components/SettingsModal.tsx | 29 ++++++++++++++ .../app-core/src/lib/markdown-settings.ts | 21 ++++++++-- packages/app-core/src/lib/markdown.test.ts | 40 ++++++++++++++++++- packages/app-core/src/lib/markdown.ts | 37 ++++++++++++++++- packages/bridge-contract/src/app-config.ts | 1 + packages/shared-domain/src/app-config.ts | 1 + 9 files changed, 138 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index e226cba3d..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', 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/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/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} /> )} + { 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(' { + const python = DETECTED.find(([, label]) => label === 'python')![2] + const codeTag = (html: string): string => /]*>/.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/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index 28643653b..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', diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index 08fe2c011..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 = { listIndentGuides: true, linkPopoverOnHover: true, renderTablesInLivePreview: true, + detectCodeLanguages: true, completedTaskStyle: 'none', mathRenderer: 'katex', typstTagPreambles: false, From 6b029b121b450669714b09fdfeced3c76649f609 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 13:07:04 -0500 Subject: [PATCH 25/27] =?UTF-8?q?Feat(editor):=20Set=20Code=20Block=20Lang?= =?UTF-8?q?uage=E2=80=A6=20in=20the=20palette,=20and=20Space=20l=20l=20(#1?= =?UTF-8?q?014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every feature ships with a key, so the picker has two ways in that need no mouse: - "Set Code Block Language…" (editor.code-language) opens the picker for the code block the cursor is in, under the block's label when it is on screen, at the cursor in Split, which has no label. Outside a code block a toast says "The cursor is not in a code block." It is listed whenever a note is open, like Set Callout Type…, and can take a key of its own (#971); the catalogs in shared-domain carry it. - `Space l l` in Vim mode runs it, `l` for language: the note-actions group's own key, pressed twice. f, y, s, d, c, u and a were taken. It has its which-key row ("Code block language"), the leader group knows `l` is taken, and the Help view's shortcut table resolves it from vim.leaderNoteActions and vim.leaderCodeLanguage, so a rebinding shows. With Vim off the chord does nothing. The palette hands the keyboard to the picker it opened, as it does for the callout menu: the picker is a ContextMenu. The request goes through code-language-request.ts, a module with nothing behind it. Imported from the picker's module, the command list pulled language-data into the app's entry chunk. --- packages/app-core/src/components/HelpView.tsx | 6 ++++++ packages/app-core/src/components/VimNav.tsx | 13 +++++++++++++ packages/app-core/src/lib/command-keymaps.ts | 1 + packages/app-core/src/lib/commands.ts | 18 ++++++++++++++++++ packages/app-core/src/lib/keymaps.ts | 14 ++++++++++++++ packages/shared-domain/src/commands-catalog.ts | 1 + packages/shared-domain/src/keymaps-catalog.ts | 1 + 7 files changed, 54 insertions(+) diff --git a/packages/app-core/src/components/HelpView.tsx b/packages/app-core/src/components/HelpView.tsx index 59698788c..116ae9326 100644 --- a/packages/app-core/src/components/HelpView.tsx +++ b/packages/app-core/src/components/HelpView.tsx @@ -111,6 +111,12 @@ function resolveShortcutKeys( 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') { diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index ca4ac5bc7..21afbe25b 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -64,6 +64,7 @@ import { } 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, @@ -321,6 +322,11 @@ export function VimNav(): JSX.Element | null { 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() ? [ { @@ -1339,6 +1345,13 @@ export function VimNav(): JSX.Element | null { requestCalloutTypeMenu() return } + if (matchesSequenceToken(e, overrides, 'vim.leaderCodeLanguage') && editorNormalMode) { + e.preventDefault() + e.stopImmediatePropagation() + resetLeader() + requestCodeLanguagePicker() + return + } resetLeader() } diff --git a/packages/app-core/src/lib/command-keymaps.ts b/packages/app-core/src/lib/command-keymaps.ts index bb9779b25..932e3a5b6 100644 --- a/packages/app-core/src/lib/command-keymaps.ts +++ b/packages/app-core/src/lib/command-keymaps.ts @@ -164,6 +164,7 @@ const LEADER_SUBGROUP_IDS: ReadonlySet = new Set([ '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 2cd21e8dc..363ee98e2 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -45,6 +45,7 @@ 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, @@ -1405,6 +1406,23 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma 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', diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index 3238cbd56..db02280cc 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -72,6 +72,7 @@ export type KeymapId = | "vim.leaderNoteCover" | "vim.leaderNoteCoverUnsplash" | "vim.leaderCalloutType" + | "vim.leaderCodeLanguage" | "vim.leaderQuickCapture" | "vim.leaderHideToTray" | "vim.leaderTemplatePicker" @@ -780,6 +781,19 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ 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", diff --git a/packages/shared-domain/src/commands-catalog.ts b/packages/shared-domain/src/commands-catalog.ts index 63f07a120..cdf02bc8e 100644 --- a/packages/shared-domain/src/commands-catalog.ts +++ b/packages/shared-domain/src/commands-catalog.ts @@ -124,6 +124,7 @@ export const COMMAND_CATALOG: CommandCatalogEntry[] = [ { 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" }, diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index d31358e88..e259d704b 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -104,6 +104,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { 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" }, From 2830023d1ea001eabfb0a3453c858cca0ec3d338 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 13:07:10 -0500 Subject: [PATCH 26/27] Docs(code blocks): the in-app manual covers the code block language picker (#1014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Help view now has: - a how-to card before "Add syntax highlighting for another language", "Set a code block’s language": the language button and the copy button beside it, the "Code block language" picker and its find field (tags count, an exact name first), what a pick rewrites and what it keeps, one Undo step, "Text (plain)", the user's own languages in the list, the guess for a block with no language ("python · detected", never written, "Python (detected)" writes it), tiny and ambiguous blocks staying text, the reading view agreeing, Set Code Block Language… and `Space l l`, the keys with Vim on and off, and the setting; - `Space l l` in the leader table, resolved from vim.leaderNoteActions and vim.leaderCodeLanguage; - "Detect code block languages" in the Editor behavior settings, with its config.toml key. Set Code Block Language… reaches the manual's command list on its own, from the palette. The website text, the same facts, is in docs/releases/v2.79.0/website-docs-code-language.md for the website pass. --- packages/app-core/src/lib/help.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 2ed8d0e1c..48823d901 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -261,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: @@ -666,6 +671,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { 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.' }, @@ -1277,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.' }, From 47d26827eb7baa4351164ae2a445188808e9268c Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 29 Oct 2026 13:11:56 -0500 Subject: [PATCH 27/27] Docs(whats-new): 2.79.0 also makes callouts and picks code languages from a menu #1016 and #1014 (both asked for by williamcheuk03) joined 2.79.0 on release day: right-click to turn lines into a callout or change one's type, and a code block's label that picks its language and shows a guess for a block without one. One What's New card names both, in the words the app shows. --- packages/app-core/src/lib/whats-new.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index d2a83d53a..381b6de6a 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -62,6 +62,10 @@ export const WHATS_NEW: readonly WhatsNewEntry[] = [ 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.'