From 004619cc32bc17fe513276af75134d1360c65304 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 20:25:21 -0500 Subject: [PATCH 1/6] Fix(editor): a note opened to read shows its first line rendered, not as raw Markdown Found on the phones once their read-open guard shipped (iPhone 1.34.0, Android 1.1.49): a note opened from Home, a list or a zennotes:// link now opens with the keyboard down, as it should, and with its title drawn as a raw `# Garden plan`. Live preview hides the Markdown markers on every line except the ones the selection covers, and a note opened to be read still has a selection: the caret parked on line 1. Nobody was editing that line, but it read as source. Desktop showed the same wherever an editor sits unfocused: a hovered link's preview popover, the pinned reference pane, and the note a window restores at launch. Source is now revealed only on lines the user is editing. A state field (lib/cm-source-reveal.ts) says whether the editor has taken the focus since its note opened. Until it has, every line renders, the caret's too. The first focus turns revealing on, and a blur leaves it on, so a palette, a menu or a dialog over a note being edited redraws nothing (and a phone's keyboard going down is not the end of editing either). A note put into an editor in place of another starts over, unless the editor holds the focus as it arrives: a tab switch or a followed link from the keyboard keeps revealing. Every reveal asks the same question: the line markers, links, ==highlights==, template tokens, callout heads, rules, fences and bullets, and the math, Mermaid/D2 and embed blocks. The editors that share these extensions (the main panes, the reference pane, the link popover, floating and external-file windows) all carry the field; one without it (Quick Capture, the template editor) reveals as before. The focus is taken up in a microtask of the focus event, not on CodeMirror's own focus notice, which comes 10 ms later: by then a frame with the caret's line rendered would already be painted, and every note desktop opens would flicker its first line. A focus a phone shell turns straight back (the read-open guard blurs it in focusin) never reveals. What it deliberately does not change: when anything takes the focus, and the parser (the note grammar's full reparse is untouched). Desktop opens a note and focuses it exactly as before, so the caret's line shows its markers from the first painted frame. Desktop, visible only where an editor is unfocused: the link preview popover and the reference pane draw their first line rendered, a note a split's other pane switched to while unfocused renders its caret line, and a note restored at launch renders its caret line until the editor is clicked or focused. Tests: cm-source-reveal (an unfocused note renders its caret line; an editor without the field reveals; the first focus reveals before any timer runs; a blur keeps revealing; a refused focus never reveals; a note swapped in unfocused is read, swapped in focused reveals; a note left to read renders again; highlights and inline math follow it). In the built app over CDP with isolated stores, sampled once per painted frame on the 2.78.0 build and on this one: opening from Home, the sidebar, Search and Search Text in Vault, Vim gg/j/k and an A edit that reaches disk, a palette over a focused editor, Read to Edit, Split and the find panel paint the same caret-line states on both; the link popover and the launch restore show the change. On the iPhone shell (web build at phone size, rehearsal core from this tree): a note opened from Home, and the one restored at launch, show their heading rendered with no `#`, and a tap into the text focuses it and reveals that line. --- packages/app-core/README.md | 5 + .../app-core/src/components/EditorPane.tsx | 7 + .../src/components/ExternalFileApp.tsx | 2 + .../src/components/FloatingNoteApp.tsx | 2 + .../app-core/src/components/LinkPopover.tsx | 2 + .../src/components/PinnedReferencePane.tsx | 5 +- packages/app-core/src/lib/cm-embed-render.ts | 10 +- packages/app-core/src/lib/cm-highlight.ts | 4 +- packages/app-core/src/lib/cm-live-preview.ts | 12 +- .../src/lib/cm-live-template-tokens.ts | 4 +- packages/app-core/src/lib/cm-math-render.ts | 4 +- .../app-core/src/lib/cm-mermaid-render.ts | 4 +- .../app-core/src/lib/cm-source-reveal.test.ts | 186 ++++++++++++++++++ packages/app-core/src/lib/cm-source-reveal.ts | 105 ++++++++++ .../app-core/src/lib/cm-wikilink-render.ts | 4 +- .../app-core/src/lib/cm-wysiwyg-blocks.ts | 4 +- 16 files changed, 347 insertions(+), 13 deletions(-) create mode 100644 packages/app-core/src/lib/cm-source-reveal.test.ts create mode 100644 packages/app-core/src/lib/cm-source-reveal.ts diff --git a/packages/app-core/README.md b/packages/app-core/README.md index 7d471b59a..21df1bb8f 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -340,6 +340,11 @@ and writing suggestions through the editor's content attributes. The native keyboard decides which features to provide. It does not install keyboard plugins or change the host's spelling capabilities. +Live preview reveals a line's Markdown source (a heading's `#`, `**`, `[[ ]]`) +only on lines the user is editing: once an editor has taken the focus since its +note opened. A note opened to be read renders every line, the caret's too, with +no host configuration. + The measurement callback receives frozen copies of editor and scroll-viewport bounds (`top`, `bottom`, `left`, `right`, `width`, `height`) in CSS pixels. It receives no DOM element or CodeMirror object. Read host geometry there; do not mutate layout diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 216f1ac8e..18827ebfc 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -114,6 +114,7 @@ import type { LineNumberMode } from '../store' import type { PaneEdge, PaneLeaf } from '../lib/pane-layout' import { findLeaf, inferPaneDropEdge } from '../lib/pane-layout' import { livePreviewPlugin } from '../lib/cm-live-preview' +import { sourceRevealExtension, sourceRevealForNewNote } from '../lib/cm-source-reveal' import { codeBlockFlairPlugin } from '../lib/cm-code-block-flair' import { tablePlugin, tableVimEntry } from '../lib/cm-table' import { wysiwygBlocksPlugin } from '../lib/cm-wysiwyg-blocks' @@ -2101,6 +2102,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { extensions: [ noteEditingLockExtension(() => ({ vault: useStore.getState().vault, path: viewPathRef.current })), noteEditorHostExtension(), + // Outside the live-preview compartment, so toggling it keeps whether + // the note is being edited or read. + sourceRevealExtension, appMarkdownSnippetExtension(), vimCompartment.of(s0.vimMode ? vim() : []), // No text input outside Vim insert mode, so a CJK input method @@ -2534,6 +2538,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { !!markdownCompartment && !!markdownSyntaxCompartment const effects: StateEffect[] = [] + // Another note is read until the editor takes the focus (on desktop, the + // focus every opener hands over right after), unless it holds it now. + if (switched) effects.push(sourceRevealForNewNote(view)) if (deferRichMarkdown && markdownCompartment && markdownSyntaxCompartment) { richMarkdownDeferredRef.current = true effects.push( diff --git a/packages/app-core/src/components/ExternalFileApp.tsx b/packages/app-core/src/components/ExternalFileApp.tsx index 286b8b2e2..0270b2666 100644 --- a/packages/app-core/src/components/ExternalFileApp.tsx +++ b/packages/app-core/src/components/ExternalFileApp.tsx @@ -30,6 +30,7 @@ import { isTouchPrimaryDevice, vimImeGuard } from '../lib/cm-vim-ime-guard' import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language' import type { ExternalFileContent } from '@shared/ipc' import { livePreviewPlugin } from '../lib/cm-live-preview' +import { sourceRevealExtension } from '../lib/cm-source-reveal' import { headingFolding } from '../lib/cm-heading-fold' import { LazyPreview as Preview } from './LazyPreview' import { CloseIcon, InboxIcon } from './icons' @@ -167,6 +168,7 @@ export function ExternalFileApp(): JSX.Element { doc: bodyRef.current ?? '', extensions: [ appMarkdownSnippetExtension(), + sourceRevealExtension, new Compartment().of(prefs.vimMode ? vim() : []), // No text input outside Vim insert mode, as in every other editor: // without it a CJK input method composed over hjkl and typed `ia` diff --git a/packages/app-core/src/components/FloatingNoteApp.tsx b/packages/app-core/src/components/FloatingNoteApp.tsx index f9bae9a0a..4982eae8d 100644 --- a/packages/app-core/src/components/FloatingNoteApp.tsx +++ b/packages/app-core/src/components/FloatingNoteApp.tsx @@ -48,6 +48,7 @@ import type { NoteContent, VaultChangeEvent } from '@shared/ipc' import type { VimWrappedLineMotionMode } from '@shared/app-config' import type { LineNumberMode } from '../store' import { livePreviewPlugin } from '../lib/cm-live-preview' +import { sourceRevealExtension } from '../lib/cm-source-reveal' import { headingFolding } from '../lib/cm-heading-fold' import { LazyPreview as Preview } from './LazyPreview' import { CloseIcon, PinIcon } from './icons' @@ -346,6 +347,7 @@ export function FloatingNoteApp({ notePath }: { notePath: string }): JSX.Element doc: dirtyBodyRef.current ?? content?.body ?? '', extensions: [ appMarkdownSnippetExtension(), + sourceRevealExtension, new Compartment().of(prefs.vimMode ? vim() : []), vimImeGuard(() => prefs.vimBlockImeInNormalMode && !isTouchPrimaryDevice()), vimVisualHighlightExtension, diff --git a/packages/app-core/src/components/LinkPopover.tsx b/packages/app-core/src/components/LinkPopover.tsx index bbba3c378..cbd1f6e32 100644 --- a/packages/app-core/src/components/LinkPopover.tsx +++ b/packages/app-core/src/components/LinkPopover.tsx @@ -35,6 +35,7 @@ import { customCodeFenceHighlightExtension } from '../lib/cm-custom-code-languag import { markdownLinkExtension } from '../lib/cm-markdown-links' import { listIndentWidth, markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' import { livePreviewPlugin } from '../lib/cm-live-preview' +import { sourceRevealExtension } from '../lib/cm-source-reveal' import { frontmatterStyle, frontmatterTagExtension } from '../lib/cm-frontmatter' import { slashCommandRender, slashCommandSource } from '../lib/cm-slash-commands' import { calloutTypeSource } from '../lib/cm-callouts' @@ -429,6 +430,7 @@ function PopoverNoteEditor({ extensions: [ noteEditingLockExtension(() => ({ vault: useStore.getState().vault, path: pathRef.current })), appMarkdownSnippetExtension(), + sourceRevealExtension, s0.vimMode ? vim() : [], vimImeGuard(() => useStore.getState().vimBlockImeInNormalMode && !isTouchPrimaryDevice()), vimVisualHighlightExtension, diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index a8960c296..920ad58ab 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -48,6 +48,7 @@ import { autocompletion } from '@codemirror/autocomplete' import { useStore } from '../store' import type { LineNumberMode } from '../store' import { livePreviewPlugin } from '../lib/cm-live-preview' +import { sourceRevealExtension, sourceRevealForNewNote } from '../lib/cm-source-reveal' import { frontmatterStyle, frontmatterTagExtension } from '../lib/cm-frontmatter' import { headingFolding } from '../lib/cm-heading-fold' import { slashCommandSource, slashCommandRender } from '../lib/cm-slash-commands' @@ -245,6 +246,7 @@ export function PinnedReferencePane(): JSX.Element | null { extensions: [ noteEditingLockExtension(() => ({ vault: useStore.getState().vault, path: viewPathRef.current })), appMarkdownSnippetExtension(), + sourceRevealExtension, vimCompartment.of(s0.vimMode ? vim() : []), // The same input-method guard as the main editor (#84, #464, #921). vimImeGuard( @@ -335,7 +337,8 @@ export function PinnedReferencePane(): JSX.Element | null { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: nextBody }, annotations: [programmatic.of(true), noteEditingSync.of(true)], - selection: pathChanged ? { anchor: 0 } : { anchor: clampedAnchor, head: clampedHead } + selection: pathChanged ? { anchor: 0 } : { anchor: clampedAnchor, head: clampedHead }, + effects: pathChanged ? sourceRevealForNewNote(view) : undefined }) viewPathRef.current = nextPath }, [content?.body, content?.path]) diff --git a/packages/app-core/src/lib/cm-embed-render.ts b/packages/app-core/src/lib/cm-embed-render.ts index 19649f552..6b54c5c02 100644 --- a/packages/app-core/src/lib/cm-embed-render.ts +++ b/packages/app-core/src/lib/cm-embed-render.ts @@ -15,6 +15,7 @@ import { syntaxTree } from '@codemirror/language' import { RangeSetBuilder, StateField, type EditorState, type Extension } from '@codemirror/state' import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' import { renderBookmarkElement, renderEmbedElement } from './embed-renderers' const FENCE_INFO_RE = /^\s*(?:`{3,}|~{3,})\s*([^\s`]*)/ @@ -87,7 +88,7 @@ class EmbedBlockWidget extends WidgetType { /** Cursor/selection overlaps (or just touches an edge of) `[from, to]`. */ function selectionTouches(state: EditorState, from: number, to: number): boolean { - for (const range of state.selection.ranges) { + for (const range of revealedSelectionRanges(state)) { if (Math.max(range.from, from) <= Math.min(range.to, to)) return true } return false @@ -152,7 +153,12 @@ function buildEmbedDecorations(state: EditorState): EmbedRenderValue { const embedRenderField = StateField.define({ create: (state) => buildEmbedDecorations(state), update(value, tr) { - if (tr.docChanged || tr.selection || syntaxTree(tr.startState) !== syntaxTree(tr.state)) { + if ( + tr.docChanged || + tr.selection || + sourceRevealChanged(tr) || + syntaxTree(tr.startState) !== syntaxTree(tr.state) + ) { return buildEmbedDecorations(tr.state) } return value diff --git a/packages/app-core/src/lib/cm-highlight.ts b/packages/app-core/src/lib/cm-highlight.ts index 50ba12795..d42684fb5 100644 --- a/packages/app-core/src/lib/cm-highlight.ts +++ b/packages/app-core/src/lib/cm-highlight.ts @@ -20,6 +20,7 @@ import { ViewPlugin, type ViewUpdate } from '@codemirror/view' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' export type HighlightColorId = | 'yellow' @@ -123,7 +124,7 @@ function colorClass(color: string | undefined): string { /** True when the cursor or a selection overlaps `[from, to]` — reveal the raw * syntax then, like the rest of the live-preview. */ function selectionTouches(state: EditorView['state'], from: number, to: number): boolean { - for (const range of state.selection.ranges) { + for (const range of revealedSelectionRanges(state)) { if (range.empty) { if (range.from >= from && range.from <= to) return true continue @@ -247,6 +248,7 @@ const highlightPlugin = ViewPlugin.fromClass( update.selectionSet || update.viewportChanged || update.focusChanged || + sourceRevealChanged(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-live-preview.ts b/packages/app-core/src/lib/cm-live-preview.ts index 97dc8fdfb..89fa47bfb 100644 --- a/packages/app-core/src/lib/cm-live-preview.ts +++ b/packages/app-core/src/lib/cm-live-preview.ts @@ -26,6 +26,7 @@ import { useCloudSyncStatusStore } from './cloud-auto-sync' import { oversizedCloudFileNotice, oversizedCloudFiles } from './cloud-oversized-files' import { noteEditorPath } from './note-editor-context' import { isResolvedMarkdownLink, terminatedLinkTailEnd } from './cm-markdown-links' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' import { parseBlockAnchors } from './block-anchors' import { setImageBlockDragPayload } from './image-block-dnd' import { imageCacheKey, rememberImageOnLoad, takeCachedImage } from './image-element-cache' @@ -47,7 +48,8 @@ import { * Obsidian-style WYSIWYG feel. When you move off a line the `#`, `**`, * `[`, `](url)`, backticks, etc. fade away and the heading/bold/link * renders cleanly. When you land on that line again, the markers come - * back so you can edit them. + * back so you can edit them. A note that is being read, not edited, + * renders every line, the selection's too (cm-source-reveal.ts). */ /** Node names from @lezer/markdown that correspond to syntax markers. */ @@ -159,7 +161,7 @@ function selectionTouchesRange( from: number, to: number ): boolean { - for (const range of state.selection.ranges) { + for (const range of revealedSelectionRanges(state)) { if (range.empty) { if (range.from >= from && range.from <= to) return true continue @@ -1318,9 +1320,10 @@ function computeDecorations(view: EditorView): DecorationSet { const { state } = view // Every line that holds part of a selection range is "active" and - // therefore keeps its syntax markers visible for editing. + // therefore keeps its syntax markers visible for editing, once the note is + // being edited rather than read. const activeLines = new Set() - for (const r of state.selection.ranges) { + for (const r of revealedSelectionRanges(state)) { const fromLine = state.doc.lineAt(r.from).number const toLine = state.doc.lineAt(r.to).number for (let l = fromLine; l <= toLine; l++) activeLines.add(l) @@ -1856,6 +1859,7 @@ export const livePreviewPlugin = ViewPlugin.fromClass( update.selectionSet || update.viewportChanged || update.focusChanged || + sourceRevealChanged(update) || syntaxTree(update.startState) !== syntaxTree(update.state) || externalRefresh ) { diff --git a/packages/app-core/src/lib/cm-live-template-tokens.ts b/packages/app-core/src/lib/cm-live-template-tokens.ts index e0aafcbbe..b605fa368 100644 --- a/packages/app-core/src/lib/cm-live-template-tokens.ts +++ b/packages/app-core/src/lib/cm-live-template-tokens.ts @@ -15,6 +15,7 @@ import { type ViewUpdate, WidgetType } from '@codemirror/view' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' import { useStore } from '../store' import { LIVE_TOKEN_RE, formatLiveToken, liveTokenFromMatch } from './live-template-tokens' @@ -52,7 +53,7 @@ function isInsideCode(state: EditorView['state'], pos: number): boolean { } function selectionTouches(state: EditorView['state'], from: number, to: number): boolean { - for (const range of state.selection.ranges) { + for (const range of revealedSelectionRanges(state)) { if (range.empty) { if (range.from >= from && range.from <= to) return true } else if (Math.max(range.from, from) < Math.min(range.to, to)) { @@ -126,6 +127,7 @@ const liveTemplateTokenPlugin = ViewPlugin.fromClass( refreshed || update.docChanged || update.selectionSet || + sourceRevealChanged(update) || update.viewportChanged || // A parse cut short by its time budget finishes in a later update // that changes nothing else. diff --git a/packages/app-core/src/lib/cm-math-render.ts b/packages/app-core/src/lib/cm-math-render.ts index 0a3d21e68..01dad7506 100644 --- a/packages/app-core/src/lib/cm-math-render.ts +++ b/packages/app-core/src/lib/cm-math-render.ts @@ -18,6 +18,7 @@ import { syntaxTree } from '@codemirror/language' import { Facet, RangeSetBuilder, StateField, type EditorState, type Extension } from '@codemirror/state' import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' import katex from 'katex' import type { MathRenderer } from '@shared/app-config' import { peekTypstMathSvg, renderTypstMathToSvg } from './typst-math-render' @@ -220,7 +221,7 @@ class BlockMathWidget extends WidgetType { /** Cursor/selection overlaps (or just touches an edge of) `[from, to]`. */ function selectionTouches(state: EditorState, from: number, to: number): boolean { - for (const range of state.selection.ranges) { + for (const range of revealedSelectionRanges(state)) { if (Math.max(range.from, from) <= Math.min(range.to, to)) return true } return false @@ -342,6 +343,7 @@ const mathRenderField = StateField.define({ if ( tr.docChanged || tr.selection || + sourceRevealChanged(tr) || syntaxTree(tr.startState) !== syntaxTree(tr.state) || tr.startState.facet(mathRendererFacet) !== tr.state.facet(mathRendererFacet) || tr.startState.facet(typstPreambleFacet) !== tr.state.facet(typstPreambleFacet) diff --git a/packages/app-core/src/lib/cm-mermaid-render.ts b/packages/app-core/src/lib/cm-mermaid-render.ts index 6c049fda3..78a8913ab 100644 --- a/packages/app-core/src/lib/cm-mermaid-render.ts +++ b/packages/app-core/src/lib/cm-mermaid-render.ts @@ -29,6 +29,7 @@ import { syntaxTree, syntaxTreeAvailable } from '@codemirror/language' import { Facet, RangeSetBuilder, StateField, type EditorState, type Extension } from '@codemirror/state' import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' import { peekD2Svg, renderD2Svg, withUniqueD2Ids } from './d2-render' import { peekMermaidSvg, renderMermaidSvg } from './mermaid-render' @@ -100,7 +101,7 @@ function paint( /** Cursor/selection overlaps (or just touches an edge of) `[from, to]`. */ function selectionTouches(state: EditorState, from: number, to: number): boolean { - for (const range of state.selection.ranges) { + for (const range of revealedSelectionRanges(state)) { if (Math.max(range.from, from) <= Math.min(range.to, to)) return true } return false @@ -271,6 +272,7 @@ const mermaidRenderField = StateField.define({ if ( tr.docChanged || tr.selection || + sourceRevealChanged(tr) || syntaxTree(tr.startState) !== syntaxTree(tr.state) || tr.startState.facet(diagramThemeFacet).key !== tr.state.facet(diagramThemeFacet).key ) { diff --git a/packages/app-core/src/lib/cm-source-reveal.test.ts b/packages/app-core/src/lib/cm-source-reveal.test.ts new file mode 100644 index 000000000..16cc46c61 --- /dev/null +++ b/packages/app-core/src/lib/cm-source-reveal.test.ts @@ -0,0 +1,186 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { EditorState, type Extension } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { livePreviewPlugin } from './cm-live-preview' +import { highlightExtension } from './cm-highlight' +import { mathRenderExtension } from './cm-math-render' +import { + readSourceRevealEffect, + revealedSelectionRanges, + sourceRevealExtension, + sourceRevealForNewNote +} from './cm-source-reveal' + +vi.mock('../store', () => { + const state = { + activeNote: null, + assetFiles: [], + noteRefs: {}, + pdfEmbedInEditMode: 'compact', + pinnedRefKind: 'note', + pinnedRefPath: null, + vault: null + } + const useStore = Object.assign(() => null, { + getState: () => state, + subscribe: () => () => {} + }) + return { useStore } +}) + +const NOTE = '# Garden plan\n\nThe **tomatoes** go in the sunny bed.\n\n## Herbs\n\nBasil and thyme.' + +const views: EditorView[] = [] +function mount(doc: string, extensions: Extension[] = [sourceRevealExtension]): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc, + selection: { anchor: 0 }, + extensions: [markdown({ base: markdownLanguage }), livePreviewPlugin, ...extensions] + }) + }) + forceParsing(view, doc.length, 1000) + views.push(view) + return view +} + +const firstLine = (view: EditorView): string | null => + view.contentDOM.querySelector('.cm-line')?.textContent ?? null +const lineText = (view: EditorView, n: number): string | null => + view.contentDOM.querySelectorAll('.cm-line')[n - 1]?.textContent ?? null +/** The microtask the focus observer queues, and nothing later. */ +const microtasks = (): Promise => Promise.resolve().then(() => undefined) + +beforeEach(() => { + // jsdom reports no window focus; CodeMirror's hasFocus asks for it. + vi.spyOn(document, 'hasFocus').mockReturnValue(true) +}) +afterEach(() => { + while (views.length) views.pop()!.destroy() + vi.restoreAllMocks() + document.body.innerHTML = '' +}) + +describe('source reveal', () => { + it('renders the selection line of a note that has not been focused', () => { + const view = mount(NOTE) + expect(view.state.selection.main.head).toBe(0) + expect(firstLine(view)).toBe('Garden plan') + expect(revealedSelectionRanges(view.state)).toEqual([]) + }) + + it('an editor without the extension reveals on the selection line as it always did', () => { + const view = mount(NOTE, []) + expect(firstLine(view)).toBe('# Garden plan') + expect(revealedSelectionRanges(view.state)).toEqual(view.state.selection.ranges) + }) + + it('the first focus reveals the line before the frame paints, not on the 10 ms focus notice', async () => { + vi.useFakeTimers() + try { + const view = mount(NOTE) + view.focus() + expect(view.hasFocus).toBe(true) + await microtasks() + // No timer has run: this is the focus observer, not CodeMirror's + // delayed focus-change transaction. + expect(firstLine(view)).toBe('# Garden plan') + } finally { + vi.useRealTimers() + } + }) + + it('a blur keeps revealing, so a palette over a note being edited redraws nothing', async () => { + const view = mount(NOTE) + view.focus() + await microtasks() + view.dispatch({ selection: { anchor: NOTE.indexOf('Herbs') } }) + expect(lineText(view, 5)).toBe('## Herbs') + view.contentDOM.blur() + await new Promise((r) => setTimeout(r, 30)) + expect(view.hasFocus).toBe(false) + expect(lineText(view, 5)).toBe('## Herbs') + expect(firstLine(view)).toBe('Garden plan') + }) + + it('a focus turned straight back by the host never reveals', async () => { + const view = mount(NOTE) + // A phone shell's read-open guard undoes a focus that lands while held. + const refuse = (event: FocusEvent): void => { + if (event.target === view.contentDOM) view.contentDOM.blur() + } + document.addEventListener('focusin', refuse, true) + try { + view.focus() + await new Promise((r) => setTimeout(r, 30)) + expect(view.hasFocus).toBe(false) + expect(firstLine(view)).toBe('Garden plan') + } finally { + document.removeEventListener('focusin', refuse, true) + } + }) + + it('another note put in while unfocused is read; put in while focused it reveals', async () => { + const view = mount(NOTE) + view.focus() + await microtasks() + view.contentDOM.blur() + const next = '# Reading list\n\nA **short** list.' + // A list opened the note: the editor is not focused as it arrives. + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + selection: { anchor: 0 }, + effects: sourceRevealForNewNote(view) + }) + forceParsing(view, next.length, 1000) + expect(firstLine(view)).toBe('Reading list') + + // A tab switch from the keyboard: the editor keeps the focus throughout. + view.focus() + await microtasks() + expect(firstLine(view)).toBe('# Reading list') + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: NOTE }, + selection: { anchor: 0 }, + effects: sourceRevealForNewNote(view) + }) + forceParsing(view, NOTE.length, 1000) + expect(firstLine(view)).toBe('# Garden plan') + }) + + it('a note left to be read renders its selection line again until the next focus', async () => { + const view = mount(NOTE) + view.focus() + await microtasks() + expect(firstLine(view)).toBe('# Garden plan') + view.contentDOM.blur() + view.dispatch({ effects: readSourceRevealEffect() }) + expect(firstLine(view)).toBe('Garden plan') + view.focus() + await microtasks() + expect(firstLine(view)).toBe('# Garden plan') + }) + + it('gates the span reveals too: highlights and inline math on the selection line', async () => { + const doc = 'A ==marked== word and $x^2$ here.\n\nNext line.' + const view = mount(doc, [sourceRevealExtension, highlightExtension, mathRenderExtension('katex')]) + view.dispatch({ selection: { anchor: doc.indexOf('marked') + 2 } }) + expect(firstLine(view)).not.toContain('==') + view.dispatch({ selection: { anchor: doc.indexOf('x^2') + 1 } }) + expect(view.contentDOM.querySelector('.cm-line')?.textContent).not.toContain('$x^2$') + + view.focus() + await microtasks() + // Now being edited: the math under the caret reads as source again. + expect(firstLine(view)).toContain('$x^2$') + view.dispatch({ selection: { anchor: doc.indexOf('marked') + 2 } }) + expect(firstLine(view)).toContain('==marked==') + }) +}) diff --git a/packages/app-core/src/lib/cm-source-reveal.ts b/packages/app-core/src/lib/cm-source-reveal.ts new file mode 100644 index 000000000..70c40bab7 --- /dev/null +++ b/packages/app-core/src/lib/cm-source-reveal.ts @@ -0,0 +1,105 @@ +import { + StateEffect, + StateField, + type EditorState, + type Extension, + type SelectionRange +} from '@codemirror/state' +import { EditorView } from '@codemirror/view' + +/** + * Which lines live preview gives back their Markdown source. + * + * Live preview draws a note as it reads and shows the source markers (a + * heading's `#`, `**`, `[[ ]]`, `==`, `$$`, a callout's `[!type]`, a fence) + * where the user is editing, which it always took to mean the lines the + * selection covers. But an editor has a selection whether or not anyone is + * editing in it: a note opened to be read keeps its caret parked on line 1, + * so a phone that opened a note with the keyboard down showed its title as a + * raw `# Title`, and so did desktop's reference pane and a hovered link's + * preview. + * + * So the selection's lines reveal their source only once the editor has taken + * the keyboard focus since its note opened; until then every line renders. + * The first focus turns revealing on, and a later blur leaves it on: a + * palette, a menu or a dialog opening over a note being edited must not + * redraw the line under the caret (and on a phone, the keyboard going down + * while editing is not the end of editing). A note put into an editor in + * place of another starts over, unless the editor holds the focus as it + * changes (a tab switch or a followed link from the keyboard), where the + * user is still editing. + * + * Desktop focuses the editor when it opens a note, and the focus is taken up + * at once, not on CodeMirror's own focus notification ten milliseconds later: + * by then a frame with the caret's line rendered could already be on screen, + * and every opened note would flicker its first line. + * + * An editor without this extension reveals on the selection's lines as it + * always did. + */ + +/** Turns revealing on (the editor took the focus) or off (a new note). */ +const setRevealing = StateEffect.define() + +const revealingField = StateField.define({ + create: () => false, + update(value, tr) { + for (const effect of tr.effects) if (effect.is(setRevealing)) value = effect.value + return value + } +}) + +function reading(state: EditorState): boolean { + return state.field(revealingField, false) === false +} + +export const sourceRevealExtension: Extension = [ + revealingField, + // CodeMirror's own notice of the focus, the path for every focus the + // observer below did not already take up. + EditorView.focusChangeEffect.of((state, focusing) => + focusing && reading(state) ? setRevealing.of(true) : null + ), + EditorView.domEventObservers({ + focus(_event, view) { + if (!reading(view.state)) return + // A microtask: it runs after this event's other listeners (a phone shell + // that turns the focus straight back is one) and before the frame paints. + queueMicrotask(() => { + if (view.hasFocus && reading(view.state)) { + view.dispatch({ effects: setRevealing.of(true) }) + } + }) + } + }) +] + +/** + * The selection ranges whose lines and spans show their source: every range + * once the editor is being edited, none while its note is being read. + */ +export function revealedSelectionRanges(state: EditorState): readonly SelectionRange[] { + return reading(state) ? [] : state.selection.ranges +} + +/** True when an update or transaction turned revealing on or off: the + * decorations that read `revealedSelectionRanges` redraw on it, the way they + * redraw on a selection change. */ +export function sourceRevealChanged(update: { + readonly startState: EditorState + readonly state: EditorState +}): boolean { + return reading(update.startState) !== reading(update.state) +} + +/** For the transaction that puts another note into an editor: the new note is + * read until the editor takes the focus, unless the editor holds it already. */ +export function sourceRevealForNewNote(view: EditorView): StateEffect { + return setRevealing.of(view.hasFocus) +} + +/** A note left to be read (a phone's search pick): every line renders again + * until the next focus. */ +export function readSourceRevealEffect(): StateEffect { + return setRevealing.of(false) +} diff --git a/packages/app-core/src/lib/cm-wikilink-render.ts b/packages/app-core/src/lib/cm-wikilink-render.ts index f1b75fe39..c09440e2e 100644 --- a/packages/app-core/src/lib/cm-wikilink-render.ts +++ b/packages/app-core/src/lib/cm-wikilink-render.ts @@ -19,6 +19,7 @@ import { ViewPlugin, type ViewUpdate } from '@codemirror/view' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' import { useStore } from '../store' import { isSameFileBlockLink, isSameFileHeadingLink, resolveWikilinkTarget } from './wikilinks' import { openDatabaseFromWikilink, openWikilinkTarget } from './wikilink-navigation' @@ -132,7 +133,7 @@ function selectionTouches( from: number, to: number ): boolean { - for (const range of state.selection.ranges) { + for (const range of revealedSelectionRanges(state)) { if (range.empty) { if (range.from >= from && range.from <= to) return true } else if (Math.max(range.from, from) < Math.min(range.to, to)) { @@ -227,6 +228,7 @@ const wikilinkRenderPlugin = ViewPlugin.fromClass( refreshed || update.docChanged || update.selectionSet || + sourceRevealChanged(update) || update.viewportChanged || // A parse cut short by its time budget finishes in a later update // that changes nothing else. diff --git a/packages/app-core/src/lib/cm-wysiwyg-blocks.ts b/packages/app-core/src/lib/cm-wysiwyg-blocks.ts index e7efcc5cb..326507ac3 100644 --- a/packages/app-core/src/lib/cm-wysiwyg-blocks.ts +++ b/packages/app-core/src/lib/cm-wysiwyg-blocks.ts @@ -16,6 +16,7 @@ import { type ViewUpdate, WidgetType } from '@codemirror/view' +import { revealedSelectionRanges, sourceRevealChanged } from './cm-source-reveal' import { calloutGroupFor } from './callout-types' import { CALLOUT_HEAD_RE, CalloutFoldChevron } from './cm-callout-fold' import { foldedExactly } from './cm-list-fold' @@ -93,7 +94,7 @@ class CalloutTitleWidget extends WidgetType { function activeLineSet(view: EditorView): Set { const lines = new Set() - for (const r of view.state.selection.ranges) { + for (const r of revealedSelectionRanges(view.state)) { const from = view.state.doc.lineAt(r.from).number const to = view.state.doc.lineAt(r.to).number for (let l = from; l <= to; l++) lines.add(l) @@ -269,6 +270,7 @@ export const wysiwygBlocksPlugin = ViewPlugin.fromClass( if ( update.docChanged || update.selectionSet || + sourceRevealChanged(update) || update.viewportChanged || // A parse cut short by its time budget finishes in a later update that // changes nothing else; without this the cards and hidden fences stay From 0a46efb4daae14263b877c34643ecbdee8edcd94 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 20:25:46 -0500 Subject: [PATCH 2/6] Fix(search): a Search pick on a phone opens the note to read, keyboard down The phones' read-open guard keeps the keyboard down for a note opened from Home, the lists and a zennotes:// link, but a pick in Search still raised it: every one of core's searches ends with focusEditorNormalMode, the hand-off desktop wants (the next keystroke edits or moves in the note), and on a phone that focus is the soft keyboard over half the note the user came to read. Traced on the iPhone shell, that one call was the only editor focus after a pick from the note search, from Search in all notes and from the outline, and after cancelling a search too (the shell's Cancel sends the Escape that App's window handler answers the same way). The outline jump also focused the editor itself. The host now says how it wants a pick to land. EditorHostOptions (the editor host the shells already install for native typing and keyboard insets) gains `openNotesToRead`, asked at each pick. Where it returns true, a pick in the note search (notes and tagged blocks), Search in all notes, the outline palette and the buffer switcher opens the note at the pick without taking the focus, and the note is left to read (every line rendered, see the previous commit). If the closing palette gave the focus back to an editor that opened it (an iPhone keeps the focus in the text under a tap on a button), that hand-back is undone a frame later. Closing one of those searches without a pick leaves the focus where the palette returns it, to what had it before. Where it is absent, false or throws, nothing changes: desktop and the web app focus exactly as before. Why the editor host and not a bridge-contract capability: the answer is the phone layout, which a tablet changes at runtime (Split View), so it has to be a question asked at the pick, not a static capability; the shells already register this host; and the bridge is the vault and IO contract, with nothing for desktop or web to say here. The touch media query was not enough either: it is true on tablets in the desktop layout, which keep core's focus as the guard does. What it deliberately does not do: the list openers the shells' guard already covers keep focusing as before (the guard refuses it), and the command, template and drawing palettes keep their hand-off. Creating a note from Search still focuses it, as a new note should be written in. Shells: one line in src/ui-mobile/editor-host.ts, in both phones: `openNotesToRead: isPhoneViewport` in the installEditorHost options (isPhoneViewport from '../viewport'). Without it, a phone that adopts this core behaves as today for Search. Tests: editor-focus (desktop focuses; a host that answers no or throws keeps it; a reading host leaves the editor unfocused and read, undoes a hand-back to the editor, and leaves the focus alone on close); SearchPalette (a pick goes through the pick hand-off, Escape through the close one) and SearchPaletteBlocks (a block pick). On the iPhone shell (web build at phone size, rehearsal core from this tree, the one line above): picks from Search (from a note and from Home), Search in all notes and the outline open the note at the pick with the editor never focused and the caret line rendered; Cancel keeps it unfocused; a tap into the text focuses it and shows that line's `#`. Without the line, the same picks focus as before. On desktop over CDP every search still focuses the editor, painted the same as on 2.78.0. --- packages/app-core/README.md | 9 ++ packages/app-core/src/App.tsx | 12 ++- .../app-core/src/components/BufferPalette.tsx | 6 +- .../app-core/src/components/EditorPane.tsx | 6 +- .../src/components/OutlinePalette.tsx | 6 +- .../src/components/SearchPalette.test.ts | 23 ++++- .../app-core/src/components/SearchPalette.tsx | 12 ++- .../components/SearchPaletteBlocks.test.ts | 13 ++- .../src/components/VaultTextSearchPalette.tsx | 6 +- packages/app-core/src/editor.ts | 9 ++ .../app-core/src/lib/editor-focus.test.ts | 93 ++++++++++++++++++- packages/app-core/src/lib/editor-focus.ts | 40 ++++++++ packages/app-core/src/lib/editor-host.ts | 13 ++- 13 files changed, 223 insertions(+), 25 deletions(-) diff --git a/packages/app-core/README.md b/packages/app-core/README.md index 21df1bb8f..d7fc716ce 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -321,6 +321,8 @@ import { installEditorHost, revealEditorCaret } from '@zennotes/app-core/editor' const host = installEditorHost({ nativeTyping: true, + // A phone layout reads what a search finds; the keyboard waits for a tap. + openNotesToRead: () => isPhoneLayout(), measureBottomInsets: ({ editor, scroll }) => ({ // These geometry helpers and overlay elements belong to the native shell. layout: bottomOverlap(editor, selectionToolbarBounds()), @@ -340,6 +342,13 @@ and writing suggestions through the editor's content attributes. The native keyboard decides which features to provide. It does not install keyboard plugins or change the host's spelling capabilities. +`openNotesToRead` is asked each time a pick in one of core's searches lands +(the note search, Search in all notes, the outline, the buffer switcher). When +it returns true the note opens at the pick without taking the keyboard focus, +so a soft keyboard stays down until the user taps into the text, and closing +such a search leaves the focus where it was. Absent, false or throwing, the +editor takes the focus as on desktop. Core's other openers are unaffected. + Live preview reveals a line's Markdown source (a heading's `#`, `**`, `[[ ]]`) only on lines the user is editing: once an editor has taken the focus since its note opened. A note opened to be read renders every line, the caret's too, with diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 9f9ad5fa4..687be7b5d 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -51,7 +51,7 @@ import { } from './lib/panel-rows' import { requestPaneMode } from './lib/pane-mode' import { recordRendererPerf } from './lib/perf' -import { focusEditorNormalMode } from './lib/editor-focus' +import { focusEditorAfterSearchClose, focusEditorNormalMode } from './lib/editor-focus' import { isAppOverlayOpen, isOverlayOrDialogOpen } from './lib/overlay-open' import { installMarkdownFileDropHandler } from './lib/markdown-file-drop' import { @@ -955,14 +955,16 @@ function App(): JSX.Element { void state.reopenLastClosedTab() return } + // A search closed here hands back the focus the way its own Escape and + // Cancel do: on a phone, that leaves the keyboard where it was. if (e.key === 'Escape' && state.searchOpen) { setSearchOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() return } if (e.key === 'Escape' && state.vaultTextSearchOpen) { setVaultTextSearchOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() return } if (e.key === 'Escape' && state.commandPaletteOpen) { @@ -972,7 +974,7 @@ function App(): JSX.Element { } if (e.key === 'Escape' && state.bufferPaletteOpen) { setBufferPaletteOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() return } if (e.key === 'Escape' && state.templatePaletteOpen) { @@ -987,7 +989,7 @@ function App(): JSX.Element { } if (e.key === 'Escape' && state.outlinePaletteOpen) { setOutlinePaletteOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() return } // ⌘1 — toggle sidebar diff --git a/packages/app-core/src/components/BufferPalette.tsx b/packages/app-core/src/components/BufferPalette.tsx index 6637a2987..7d22c848a 100644 --- a/packages/app-core/src/components/BufferPalette.tsx +++ b/packages/app-core/src/components/BufferPalette.tsx @@ -28,7 +28,7 @@ import { isArchiveTabPath } from '@shared/archive' import { isTrashTabPath } from '@shared/trash' import { isQuickNotesTabPath } from '@shared/quick-notes' import { resolveSystemFolderLabels, type SystemFolderLabels } from '../lib/system-folder-labels' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { focusEditorAfterPick, focusEditorAfterSearchClose } from '../lib/editor-focus' import { noteFolderLabel } from '../lib/vault-layout' import { Modal } from './ui/Modal' @@ -323,12 +323,12 @@ export function BufferPalette(): JSX.Element { setActivePane(targetPaneId) } - focusEditorNormalMode() + focusEditorAfterPick() } const close = (): void => { setOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() } const closeSelected = async (): Promise => { diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 18827ebfc..1db5d74bc 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -40,7 +40,7 @@ import { import { Vim, getCM, vim } from '@replit/codemirror-vim' import type { AssetMeta, ImportedAsset, NoteComment, NoteFolder } from '@shared/ipc' import { registerNoteEditor } from '../lib/note-editor-context' -import { noteEditorHostExtension } from '../lib/editor-host' +import { hostOpensNotesToRead, noteEditorHostExtension } from '../lib/editor-host' import { indentWithTab, moveLineDown, @@ -1774,7 +1774,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }) }) setFocusedPanel('editor') - view.focus() + // An outline jump is a search pick too: where the host opens notes to + // read, the heading is shown without raising the keyboard. + if (!hostOpensNotesToRead()) view.focus() return true }, [schedulePreviewOutlineJump, setFocusedPanel]) diff --git a/packages/app-core/src/components/OutlinePalette.tsx b/packages/app-core/src/components/OutlinePalette.tsx index 6f14be253..ade50d33f 100644 --- a/packages/app-core/src/components/OutlinePalette.tsx +++ b/packages/app-core/src/components/OutlinePalette.tsx @@ -20,7 +20,7 @@ import { isWorkflowsTabPath } from '@shared/workflows-view' import { isAtlasTabPath } from '@shared/atlas-view' import { isTrashTabPath } from '@shared/trash' import { isQuickNotesTabPath } from '@shared/quick-notes' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { focusEditorAfterPick, focusEditorAfterSearchClose } from '../lib/editor-focus' import { Modal } from './ui/Modal' function isVirtualPath(path: string | null): boolean { @@ -75,12 +75,12 @@ export function OutlinePalette(): JSX.Element { detail: { line: item.line } }) ) - focusEditorNormalMode() + focusEditorAfterPick() } const close = (): void => { setOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() } return ( diff --git a/packages/app-core/src/components/SearchPalette.test.ts b/packages/app-core/src/components/SearchPalette.test.ts index f3772ffb9..dd10f017a 100644 --- a/packages/app-core/src/components/SearchPalette.test.ts +++ b/packages/app-core/src/components/SearchPalette.test.ts @@ -15,7 +15,13 @@ vi.mock('../lib/confirm-requests', () => ({ confirmApp })) // Closing the palette hands focus back to the editor with a few timed retries; // there is no editor here and the retries would outlive the jsdom window. const focusEditorNormalMode = vi.hoisted(() => vi.fn()) -vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode })) +const focusEditorAfterPick = vi.hoisted(() => vi.fn()) +const focusEditorAfterSearchClose = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ + focusEditorNormalMode, + focusEditorAfterPick, + focusEditorAfterSearchClose +})) function note(title: string): NoteMeta { return { @@ -178,6 +184,8 @@ describe('SearchPalette: the create row opens a New note form', () => { createAndOpen = vi.fn(async () => {}) selectNote = vi.fn(async () => {}) focusEditorNormalMode.mockClear() + focusEditorAfterPick.mockClear() + focusEditorAfterSearchClose.mockClear() useStore.setState({ notes: [ { ...note('Alpha'), tags: ['ops', 'prod'] }, @@ -315,6 +323,19 @@ describe('SearchPalette: the create row opens a New note form', () => { await key(search(), 'Enter') expect(selectNote).toHaveBeenCalledWith('inbox/Alpha.md') expect(form()).toBeNull() + // A pick hands over through the pick's own path, which decides between + // focusing the editor (desktop) and leaving the note to be read (phones). + expect(focusEditorAfterPick).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + }) + + it('Escape closes through the search close hand-off, not a plain editor focus', async () => { + type(search(), 'Alph') + await key(search(), 'Escape') + expect(useStore.getState().searchOpen).toBe(false) + expect(focusEditorAfterSearchClose).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + expect(focusEditorAfterPick).not.toHaveBeenCalled() }) it('a typed path fills the Folder field, and the Folder picker filters, picks and moves on to Tags', async () => { diff --git a/packages/app-core/src/components/SearchPalette.tsx b/packages/app-core/src/components/SearchPalette.tsx index 1f6d1cd42..169fce3fd 100644 --- a/packages/app-core/src/components/SearchPalette.tsx +++ b/packages/app-core/src/components/SearchPalette.tsx @@ -8,7 +8,11 @@ import { parseNoteSearchQuery, searchNoteIndex } from '../lib/note-search' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { + focusEditorAfterPick, + focusEditorAfterSearchClose, + focusEditorNormalMode +} from '../lib/editor-focus' import { destinationLabel, parseDestinationText, @@ -144,7 +148,7 @@ export function SearchPalette(): JSX.Element { const open = async (note: NoteMeta): Promise => { setSearchOpen(false) await selectNote(note.path) - focusEditorNormalMode() + focusEditorAfterPick() } // A block opens its note at the block's line, highlighted, and records the @@ -155,7 +159,7 @@ export function SearchPalette(): JSX.Element { setSearchOpen(false) if (row.kind === 'block') { await openTaggedBlock(row.note.path, row.block.startLine) - focusEditorNormalMode() + focusEditorAfterPick() return } await openTagViewFor(tagTokens, freeText) @@ -174,7 +178,7 @@ export function SearchPalette(): JSX.Element { const close = (): void => { setSearchOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() } // Trash the highlighted note without leaving the palette (Discord ask: a diff --git a/packages/app-core/src/components/SearchPaletteBlocks.test.ts b/packages/app-core/src/components/SearchPaletteBlocks.test.ts index 51d195100..d1983d292 100644 --- a/packages/app-core/src/components/SearchPaletteBlocks.test.ts +++ b/packages/app-core/src/components/SearchPaletteBlocks.test.ts @@ -12,7 +12,13 @@ import { SearchPalette } from './SearchPalette' const confirmApp = vi.hoisted(() => vi.fn(async () => true)) vi.mock('../lib/confirm-requests', () => ({ confirmApp })) const focusEditorNormalMode = vi.hoisted(() => vi.fn()) -vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode })) +const focusEditorAfterPick = vi.hoisted(() => vi.fn()) +const focusEditorAfterSearchClose = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ + focusEditorNormalMode, + focusEditorAfterPick, + focusEditorAfterSearchClose +})) function note(title: string, tags: string[], updatedAt: number): NoteMeta { return { @@ -55,6 +61,8 @@ describe('SearchPalette: tagged blocks under a #tag query', () => { beforeEach(() => { originalState = useStore.getState() + focusEditorNormalMode.mockClear() + focusEditorAfterPick.mockClear() host = document.createElement('div') document.body.append(host) root = createRoot(host) @@ -151,6 +159,9 @@ describe('SearchPalette: tagged blocks under a #tag query', () => { BODIES['inbox/Ideas.md']!.indexOf('Recipe gallery'), { scrollMode: 'center', highlightLine: true } ) + // A block is a pick like a note: read on a phone, edited on desktop. + expect(focusEditorAfterPick).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() }) it('opens the Tags view on the query tags from the "+N more" row', async () => { diff --git a/packages/app-core/src/components/VaultTextSearchPalette.tsx b/packages/app-core/src/components/VaultTextSearchPalette.tsx index 0f634b465..f640f9568 100644 --- a/packages/app-core/src/components/VaultTextSearchPalette.tsx +++ b/packages/app-core/src/components/VaultTextSearchPalette.tsx @@ -9,7 +9,7 @@ import { resolveSystemFolderLabels } from '../lib/system-folder-labels' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' import { isImeComposing } from '../lib/ime' import { recordRendererPerf } from '../lib/perf' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { focusEditorAfterPick, focusEditorAfterSearchClose } from '../lib/editor-focus' import { Modal } from './ui/Modal' type ResolvedVaultTextSearchBackend = 'builtin' | 'ripgrep' | 'fzf' @@ -365,12 +365,12 @@ export function VaultTextSearchPalette(): JSX.Element { const openMatch = async (match: VaultTextSearchMatch): Promise => { setOpen(false) await openNoteAtOffset(match.path, match.offset, { scrollMode: 'center' }) - focusEditorNormalMode() + focusEditorAfterPick() } const close = (): void => { setOpen(false) - focusEditorNormalMode() + focusEditorAfterSearchClose() } return ( diff --git a/packages/app-core/src/editor.ts b/packages/app-core/src/editor.ts index 4eb07cfe8..852eb11d5 100644 --- a/packages/app-core/src/editor.ts +++ b/packages/app-core/src/editor.ts @@ -34,6 +34,15 @@ export interface EditorHostOptions { readonly nativeTyping?: boolean /** Read-only measurement callback. Return CSS pixels; do not change layout here. */ readonly measureBottomInsets?: (viewport: EditorViewport) => EditorBottomInsets + /** + * Asked as a pick in one of core's searches (the note search, Search in all + * notes, the outline, the buffer switcher) lands: true opens the note to be + * read, at the pick, without handing the editor the keyboard focus, so a + * soft keyboard stays down until the user taps into the text. Closing such + * a search then leaves the focus where it was too. Absent or false, the + * editor takes the focus, as on desktop. + */ + readonly openNotesToRead?: () => boolean } export interface EditorHostRegistration { diff --git a/packages/app-core/src/lib/editor-focus.test.ts b/packages/app-core/src/lib/editor-focus.test.ts index 5b76808cc..150b7b522 100644 --- a/packages/app-core/src/lib/editor-focus.test.ts +++ b/packages/app-core/src/lib/editor-focus.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const storeState = vi.hoisted(() => ({ current: { - editorViewRef: null as null | { dom: HTMLElement; focus: () => void }, + editorViewRef: null as null | Record, vimMode: false, setFocusedPanel: vi.fn() } @@ -23,7 +23,15 @@ vi.mock('@replit/codemirror-vim', () => ({ getCM: vi.fn(() => null) })) -import { focusEditorNormalMode } from './editor-focus' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { + focusEditorAfterPick, + focusEditorAfterSearchClose, + focusEditorNormalMode +} from './editor-focus' +import { installNoteEditorHost } from './editor-host' +import { revealedSelectionRanges, sourceRevealExtension } from './cm-source-reveal' describe('focusEditorNormalMode', () => { beforeEach(() => { @@ -62,3 +70,84 @@ describe('focusEditorNormalMode', () => { expect(document.activeElement).toBe(dom) }) }) + +describe('search hand-offs', () => { + let host: { dispose(): void } | null = null + let view: EditorView | null = null + + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + return window.setTimeout(() => callback(performance.now()), 0) + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => window.clearTimeout(id)) + const parent = document.createElement('div') + document.body.appendChild(parent) + view = new EditorView({ + parent, + state: EditorState.create({ doc: '# Title\n\nBody', extensions: [sourceRevealExtension] }) + }) + storeState.current = { editorViewRef: view as never, vimMode: false, setFocusedPanel: vi.fn() } + }) + + afterEach(() => { + host?.dispose() + host = null + view?.destroy() + view = null + vi.restoreAllMocks() + vi.useRealTimers() + document.body.innerHTML = '' + }) + + it('a pick focuses the editor where the host does not open notes to read (desktop)', async () => { + focusEditorAfterPick() + await vi.advanceTimersByTimeAsync(0) + expect(view!.hasFocus).toBe(true) + expect(storeState.current.setFocusedPanel).toHaveBeenCalledWith('editor') + }) + + it('a host that answers no, or throws, keeps the desktop hand-off', async () => { + host = installNoteEditorHost({ + openNotesToRead: () => { + throw new Error('gone') + } + }) + focusEditorAfterPick() + await vi.advanceTimersByTimeAsync(0) + expect(view!.hasFocus).toBe(true) + }) + + it('a pick on a host that opens notes to read leaves the keyboard down and the note read', async () => { + host = installNoteEditorHost({ openNotesToRead: () => true }) + focusEditorAfterPick() + await vi.advanceTimersByTimeAsync(50) + expect(view!.hasFocus).toBe(false) + expect(storeState.current.setFocusedPanel).not.toHaveBeenCalled() + expect(revealedSelectionRanges(view!.state)).toEqual([]) + }) + + it('undoes the focus a closing palette handed back to the editor that opened it', async () => { + host = installNoteEditorHost({ openNotesToRead: () => true }) + view!.focus() + await vi.advanceTimersByTimeAsync(0) + expect(revealedSelectionRanges(view!.state)).not.toEqual([]) + focusEditorAfterPick() + await vi.advanceTimersByTimeAsync(50) + expect(view!.hasFocus).toBe(false) + expect(revealedSelectionRanges(view!.state)).toEqual([]) + }) + + it('closing a search gives the editor the keyboard on desktop, and leaves the focus alone on a reading host', async () => { + focusEditorAfterSearchClose() + await vi.advanceTimersByTimeAsync(0) + expect(view!.hasFocus).toBe(true) + view!.contentDOM.blur() + + host = installNoteEditorHost({ openNotesToRead: () => true }) + focusEditorAfterSearchClose() + await vi.advanceTimersByTimeAsync(50) + expect(view!.hasFocus).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/editor-focus.ts b/packages/app-core/src/lib/editor-focus.ts index 935e76be4..a9bd41b53 100644 --- a/packages/app-core/src/lib/editor-focus.ts +++ b/packages/app-core/src/lib/editor-focus.ts @@ -1,5 +1,7 @@ import { Vim, getCM } from '@replit/codemirror-vim' import { useStore } from '../store' +import { readSourceRevealEffect } from './cm-source-reveal' +import { hostOpensNotesToRead } from './editor-host' /** * Return focus to the active editor pane, dropping out of vim insert @@ -46,3 +48,41 @@ export function focusEditorNormalMode( requestAnimationFrame(() => run(attempts)) } + +/** + * The hand-off after a pick in one of core's searches (the note search, Search + * in all notes, the outline, the buffer switcher) put a note on screen or moved + * within one. Desktop gives the editor the keyboard, so the next keystroke + * edits or moves in the note. A host that opens notes to read (a phone; the + * editor host's `openNotesToRead`) keeps it down: there the focus is the soft + * keyboard over half the note, and the user came to read what they found. The + * note stays at the pick with every line rendered, and a tap into the text + * raises the keyboard as always. + * + * A palette closing hands the focus back to whatever had it when it opened, + * which can be the editor itself (an iPhone keeps the focus in the text under + * a tap on a button). The pick still ends with the note read and the keyboard + * down: that hand-back is undone, a frame on, once the pick has landed. + */ +export function focusEditorAfterPick(): void { + if (!hostOpensNotesToRead()) { + focusEditorNormalMode() + return + } + requestAnimationFrame(() => { + const view = useStore.getState().editorViewRef + if (!view) return + if (view.hasFocus) view.contentDOM.blur() + view.dispatch({ effects: readSourceRevealEffect() }) + }) +} + +/** + * Closing one of those searches without a pick. Desktop gives the editor the + * keyboard back, as it always has; a host that opens notes to read leaves the + * focus where the palette returns it, to what had it before the search, so + * backing out of a search over a note being read keeps the keyboard down. + */ +export function focusEditorAfterSearchClose(): void { + if (!hostOpensNotesToRead()) focusEditorNormalMode() +} diff --git a/packages/app-core/src/lib/editor-host.ts b/packages/app-core/src/lib/editor-host.ts index 30124593b..7e393c0ad 100644 --- a/packages/app-core/src/lib/editor-host.ts +++ b/packages/app-core/src/lib/editor-host.ts @@ -143,7 +143,8 @@ export function installNoteEditorHost(options: EditorHostOptions): EditorHostReg const owner = { options: { nativeTyping: options.nativeTyping, - measureBottomInsets: options.measureBottomInsets + measureBottomInsets: options.measureBottomInsets, + openNotesToRead: options.openNotesToRead } } active = owner @@ -160,6 +161,16 @@ export function installNoteEditorHost(options: EditorHostOptions): EditorHostReg } } +/** Whether the host wants a search pick to open its note to be read + * (`openNotesToRead`). A host that throws answers no, which is desktop's way. */ +export function hostOpensNotesToRead(): boolean { + try { + return active?.options.openNotesToRead?.() === true + } catch { + return false + } +} + export function requestNoteEditorReveal(view: EditorView, isCurrent: () => boolean): boolean { const host = view.plugin(hostPlugin) if (!host) return false From a3a5c0814f39f602f4443ebae49063119cd82965 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 20:33:55 -0500 Subject: [PATCH 3/6] Release(core): 2.78.1 opens notes to read on a phone, title rendered and keyboard down The phones shipped 2.78.0's core in Android 1.1.48 and iPhone 1.34.0, and walking those builds found two core-side problems: a note opened to read showed its first-line heading as raw Markdown (live preview revealed the caret line's markers with the editor unfocused, 004619cc), and a Search pick raised the keyboard on a phone (0a46efb4). The shared packages move to 2.78.1 so Android 1.1.50 and the replacement iPhone 1.34.0 build adopt it. The desktop app stays at 2.78.0 until its next release, which carries both changes. --- package-lock.json | 6 +++--- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 666c906f0..0a79f2b66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16409,7 +16409,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.78.0", + "version": "2.78.1", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16498,14 +16498,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.78.0", + "version": "2.78.1", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.78.0", + "version": "2.78.1", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", diff --git a/packages/app-core/package.json b/packages/app-core/package.json index ad3d82868..ccd337376 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.78.0", + "version": "2.78.1", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 529d80ae2..23021ef43 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.78.0", + "version": "2.78.1", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 72aff5696..f9f630f9f 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.78.0", + "version": "2.78.1", "type": "module", "exports": { "./*": "./src/*.ts" From e443b86f439ef83b03a3bca9ba621a4131654f09 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 20:49:50 -0500 Subject: [PATCH 4/6] Fix(palette): a phone backs out of a palette, and opens a note from a command, keyboard down After 0a46efb4 a Search pick on a phone opened its note to read, but two of core's hand-offs still raised the keyboard there. Closing the command palette, the template palette or the drawing palette without picking anything called focusEditorNormalMode, from the palette's own Escape and from App's window handler that answers the shells' Cancel button. And a command run from the command palette that only goes to a note already there (Go Back, today's daily note) was followed by the same editor focus, twice for the periodic notes: the palette refocuses after every command that lands on the editor, and openDailyNoteForDate and its weekly and monthly twins ask for the focus themselves (#353). Both now go through the editor host's `openNotesToRead`, the question 0a46efb4 added. The two hand-offs it introduced are renamed for what they now cover: focusEditorAfterReadOpen (a note opened to be read, from a search pick or a command) and focusEditorAfterPaletteClose (any of these palettes closed without a pick). On a host that opens notes to read, a palette closed without a pick leaves the focus where the palette returns it, to whatever had it before the palette opened, so backing out over a note being read keeps the keyboard down and backing out over a note being written keeps it up. Desktop and the web app focus the editor exactly as before, with the same retries. Commands say whether they open to read through a new `Command.opensToRead`, asked just before the command runs (afterwards a daily note exists either way). The rule: a command whose whole effect is to put an existing note on screen opens it to read; a command that creates a note, or works on the note in place, keeps the focus. Open to read (the palette hands over like a search pick): - nav.back, nav.forward, nav.toggle-recent (Go Back, Go Forward, Switch to Previous Note): history only holds notes that exist. - tab.reopen, tab.next, tab.previous (Reopen Closed Tab, Next Tab, Previous Tab): another note that is already open. - tab.close (Close Tab): what remains on screen is the neighboring tab. - note.daily.today, note.weekly.thisWeek, note.monthly.thisMonth: only when that note is there already. When the command has to create it, the note is new and is focused to be written in, as today. The command passes `readIfExisting`, so the opener's own focus request follows the same rule (a `read` detail on zen:focus-editor that App lands like a pick); other callers of those openers (the leader keys, the Calendar, Home, the phones' Daily note row) are unchanged. - note.daily.rollover (Roll Over Unfinished Tasks to Today): the same rule on today's note. Keep the focus, unchanged: every command that creates a note to write in (note.new.quick, note.new.inbox, note.new.here, task.new, task.new.folder, database.new, drawing.new, embed.drawing.new, and the template palette's create and insert); commands that act on the open note in place (format, rename, favorite, cover, archive and restore, copy, export, folds, task state, the editor toggles); nav.focus.editor and ref.focus, whose point is the focus; and split.right/split.down. The command palette has no command that follows a link: Open Link Popover peeks at one over the current note and keeps its own focus. Commands that open another palette or dialog still hand nothing over, and a theme picked in the palette still returns to the editor. What it deliberately does not do: change the commands themselves, Home or the Calendar, or anything on a host without `openNotesToRead`. Tests: commands (exactly those eleven commands answer opensToRead, history and tab commands always, the periodic ones only for an existing note, the writing commands never, and the periodic commands ask their opener for readIfExisting); store (the daily opener requests a read landing only for an existing note opened that way); editor-focus (a read open keeps its caller's retries on desktop); a palette test (the command, template and drawing palettes close through the close hand-off, a read command lands through the read hand-off, a writing one still focuses). app-core 3,624 and shared-domain 2,164 pass; typecheck clean (8/8). In the built app over CDP with isolated stores, on 2.78.0 and on this build: Escape out of the command palette (from its input and from a window Escape like the phones' Cancel), the template palette and the drawing palette, then Open Today's Daily Note creating the note, Go Back, Open Today's Daily Note again, Switch to Previous Note, Close Tab, Reopen Closed Tab and Next Tab each leave the same note on screen with the editor focused on both builds. --- packages/app-core/README.md | 14 +- packages/app-core/src/App.tsx | 32 ++-- .../app-core/src/components/BufferPalette.tsx | 6 +- .../src/components/CommandPalette.tsx | 18 ++- .../src/components/EmbedDrawingPalette.tsx | 4 +- .../src/components/OutlinePalette.tsx | 6 +- .../components/PaletteFocusHandoff.test.ts | 147 ++++++++++++++++++ .../src/components/SearchPalette.test.ts | 18 +-- .../app-core/src/components/SearchPalette.tsx | 10 +- .../components/SearchPaletteBlocks.test.ts | 12 +- .../src/components/TemplatePalette.tsx | 4 +- .../src/components/VaultTextSearchPalette.tsx | 6 +- packages/app-core/src/editor.ts | 13 +- packages/app-core/src/lib/commands.test.ts | 92 +++++++++++ packages/app-core/src/lib/commands.ts | 42 ++++- .../app-core/src/lib/editor-focus.test.ts | 34 ++-- packages/app-core/src/lib/editor-focus.ts | 39 +++-- packages/app-core/src/store.test.ts | 48 ++++++ packages/app-core/src/store.ts | 55 ++++--- 19 files changed, 492 insertions(+), 108 deletions(-) create mode 100644 packages/app-core/src/components/PaletteFocusHandoff.test.ts diff --git a/packages/app-core/README.md b/packages/app-core/README.md index d7fc716ce..3f3e40dd2 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -343,11 +343,15 @@ keyboard decides which features to provide. It does not install keyboard plugins or change the host's spelling capabilities. `openNotesToRead` is asked each time a pick in one of core's searches lands -(the note search, Search in all notes, the outline, the buffer switcher). When -it returns true the note opens at the pick without taking the keyboard focus, -so a soft keyboard stays down until the user taps into the text, and closing -such a search leaves the focus where it was. Absent, false or throwing, the -editor takes the focus as on desktop. Core's other openers are unaffected. +(the note search, Search in all notes, the outline, the buffer switcher), and +each time a command-palette command that only goes to a note already there +finishes (Go Back, Go Forward, Switch to Previous Note, the tab commands, and +today's daily, weekly or monthly note when it exists). When it returns true the +note opens without taking the keyboard focus, so a soft keyboard stays down +until the user taps into the text. Closing any of those palettes, or the +template or drawing palette, without a pick leaves the focus where it was. +Commands that create a note to write in keep focusing the editor. Absent, +false or throwing, the editor takes the focus as on desktop. Live preview reveals a line's Markdown source (a heading's `#`, `**`, `[[ ]]`) only on lines the user is editing: once an editor has taken the focus since its diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 687be7b5d..8a6ace8fd 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -51,7 +51,11 @@ import { } from './lib/panel-rows' import { requestPaneMode } from './lib/pane-mode' import { recordRendererPerf } from './lib/perf' -import { focusEditorAfterSearchClose, focusEditorNormalMode } from './lib/editor-focus' +import { + focusEditorAfterPaletteClose, + focusEditorAfterReadOpen, + focusEditorNormalMode +} from './lib/editor-focus' import { isAppOverlayOpen, isOverlayOrDialogOpen } from './lib/overlay-open' import { installMarkdownFileDropHandler } from './lib/markdown-file-drop' import { @@ -482,7 +486,15 @@ function App(): JSX.Element { // pane jump or mouse click. Routed through a DOM event to keep the store free // of an editor-focus import cycle. (#353) useEffect(() => { - const handler = (): void => focusEditorNormalMode({ attempts: 10, delayMs: 24 }) + // A request marked `read` (a periodic note that already existed, opened + // from the command palette) lands the way a search pick does: focused on + // desktop, left to read on a phone. + const handler = (event: Event): void => { + const options = { attempts: 10, delayMs: 24 } + const detail = (event as CustomEvent<{ read?: boolean } | null>).detail + if (detail?.read) focusEditorAfterReadOpen(options) + else focusEditorNormalMode(options) + } window.addEventListener('zen:focus-editor', handler) return () => window.removeEventListener('zen:focus-editor', handler) }, []) @@ -955,41 +967,41 @@ function App(): JSX.Element { void state.reopenLastClosedTab() return } - // A search closed here hands back the focus the way its own Escape and + // A palette closed here hands back the focus the way its own Escape and // Cancel do: on a phone, that leaves the keyboard where it was. if (e.key === 'Escape' && state.searchOpen) { setSearchOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.vaultTextSearchOpen) { setVaultTextSearchOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.commandPaletteOpen) { setCommandPaletteOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.bufferPaletteOpen) { setBufferPaletteOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.templatePaletteOpen) { setTemplatePaletteOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.embedDrawingPaletteOpen) { setEmbedDrawingPaletteOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.outlinePaletteOpen) { setOutlinePaletteOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() return } // ⌘1 — toggle sidebar diff --git a/packages/app-core/src/components/BufferPalette.tsx b/packages/app-core/src/components/BufferPalette.tsx index 7d22c848a..668302141 100644 --- a/packages/app-core/src/components/BufferPalette.tsx +++ b/packages/app-core/src/components/BufferPalette.tsx @@ -28,7 +28,7 @@ import { isArchiveTabPath } from '@shared/archive' import { isTrashTabPath } from '@shared/trash' import { isQuickNotesTabPath } from '@shared/quick-notes' import { resolveSystemFolderLabels, type SystemFolderLabels } from '../lib/system-folder-labels' -import { focusEditorAfterPick, focusEditorAfterSearchClose } from '../lib/editor-focus' +import { focusEditorAfterReadOpen, focusEditorAfterPaletteClose } from '../lib/editor-focus' import { noteFolderLabel } from '../lib/vault-layout' import { Modal } from './ui/Modal' @@ -323,12 +323,12 @@ export function BufferPalette(): JSX.Element { setActivePane(targetPaneId) } - focusEditorAfterPick() + focusEditorAfterReadOpen() } const close = (): void => { setOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() } const closeSelected = async (): Promise => { diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index a01ecde16..daa250d25 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -27,7 +27,11 @@ import { } from '../lib/vault-switcher' import { runWorkflowById } from '../lib/workflow-trigger' import type { WorkflowIndexEntry } from '../lib/workflow-index' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { + focusEditorAfterPaletteClose, + focusEditorAfterReadOpen, + focusEditorNormalMode +} from '../lib/editor-focus' import { useCloudSyncStatusStore } from '../lib/cloud-auto-sync' import { getPublishNoteRequest } from '../lib/publish-note-requests' import { Modal } from './ui/Modal' @@ -280,7 +284,7 @@ export function CommandPalette(): JSX.Element { revertTheme() } setOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() } /* -------- Actions -------- */ @@ -304,6 +308,8 @@ export function CommandPalette(): JSX.Element { return } setOpen(false) + // Asked before the run: afterwards today's note exists either way. + const toRead = cmd.opensToRead?.() === true try { await cmd.run() // A command that opens a note (or otherwise lands on the editor) should @@ -316,6 +322,8 @@ export function CommandPalette(): JSX.Element { // 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. + // A command that only went to a note that was already there hands over + // like a search pick: on a phone the note is left to read. if ( shouldRefocusEditorAfterCommand( useStore.getState(), @@ -323,8 +331,10 @@ export function CommandPalette(): JSX.Element { useCloudSyncStatusStore.getState().settingsConflictPromptOpen || getPublishNoteRequest() !== null ) - ) - focusEditorNormalMode() + ) { + if (toRead) focusEditorAfterReadOpen() + else focusEditorNormalMode() + } } catch (err) { console.error('command failed', cmd.id, err) } diff --git a/packages/app-core/src/components/EmbedDrawingPalette.tsx b/packages/app-core/src/components/EmbedDrawingPalette.tsx index 8ae58268d..3c10d4db7 100644 --- a/packages/app-core/src/components/EmbedDrawingPalette.tsx +++ b/packages/app-core/src/components/EmbedDrawingPalette.tsx @@ -5,7 +5,7 @@ import { isExcalidrawPath, isObsidianExcalidrawPath } from '@shared/excalidraw' import { rankItems } from '../lib/fuzzy-score' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' import { isImeComposing } from '../lib/ime' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { focusEditorAfterPaletteClose, focusEditorNormalMode } from '../lib/editor-focus' import { Modal } from './ui/Modal' /** @@ -55,7 +55,7 @@ export function EmbedDrawingPalette(): JSX.Element { const close = (): void => { setOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() } return ( diff --git a/packages/app-core/src/components/OutlinePalette.tsx b/packages/app-core/src/components/OutlinePalette.tsx index ade50d33f..49af09860 100644 --- a/packages/app-core/src/components/OutlinePalette.tsx +++ b/packages/app-core/src/components/OutlinePalette.tsx @@ -20,7 +20,7 @@ import { isWorkflowsTabPath } from '@shared/workflows-view' import { isAtlasTabPath } from '@shared/atlas-view' import { isTrashTabPath } from '@shared/trash' import { isQuickNotesTabPath } from '@shared/quick-notes' -import { focusEditorAfterPick, focusEditorAfterSearchClose } from '../lib/editor-focus' +import { focusEditorAfterReadOpen, focusEditorAfterPaletteClose } from '../lib/editor-focus' import { Modal } from './ui/Modal' function isVirtualPath(path: string | null): boolean { @@ -75,12 +75,12 @@ export function OutlinePalette(): JSX.Element { detail: { line: item.line } }) ) - focusEditorAfterPick() + focusEditorAfterReadOpen() } const close = (): void => { setOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() } return ( diff --git a/packages/app-core/src/components/PaletteFocusHandoff.test.ts b/packages/app-core/src/components/PaletteFocusHandoff.test.ts new file mode 100644 index 000000000..c762cf51a --- /dev/null +++ b/packages/app-core/src/components/PaletteFocusHandoff.test.ts @@ -0,0 +1,147 @@ +// @vitest-environment jsdom + +import { act, createElement, type FunctionComponent } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useStore } from '../store' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +// Which hand-off each palette asks for; the hand-offs themselves (desktop +// focuses, a reading host keeps the keyboard down) are covered in +// lib/editor-focus.test.ts. +const focusEditorNormalMode = vi.hoisted(() => vi.fn()) +const focusEditorAfterReadOpen = vi.hoisted(() => vi.fn()) +const focusEditorAfterPaletteClose = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ + focusEditorNormalMode, + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose +})) + +// The command palette lists whatever buildCommands gives it: two stand-ins, +// one that goes to a note already there and one that writes. +const goBack = vi.hoisted(() => vi.fn()) +const newNote = vi.hoisted(() => vi.fn()) +vi.mock('../lib/commands', () => ({ + buildCommands: () => [ + { + id: 'test.read', + title: 'Read Something', + category: 'Go', + run: goBack, + opensToRead: () => true + }, + { id: 'test.write', title: 'Write Something', category: 'Note', run: newNote } + ] +})) + +import { CommandPalette } from './CommandPalette' +import { TemplatePalette } from './TemplatePalette' +import { EmbedDrawingPalette } from './EmbedDrawingPalette' + +let host: HTMLDivElement +let root: Root +let originalScrollIntoView: PropertyDescriptor | undefined + +beforeEach(() => { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + getAppInfo: () => ({ runtime: 'desktop' }), + getCapabilities: () => ({ supportsRemoteWorkspace: false }) + } + }) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + originalScrollIntoView = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView') + Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }) + for (const fn of [focusEditorNormalMode, focusEditorAfterReadOpen, focusEditorAfterPaletteClose]) { + fn.mockClear() + } + goBack.mockClear() + newNote.mockClear() + // The refocus after a command only happens when the editor was the panel + // and nothing else is open over it. + useStore.setState({ + focusedPanel: 'editor', + commandPaletteOpen: false, + templatePaletteOpen: false, + embedDrawingPaletteOpen: false + }) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + if (originalScrollIntoView) { + Object.defineProperty(Element.prototype, 'scrollIntoView', originalScrollIntoView) + } +}) + +const render = (component: FunctionComponent): void => { + act(() => root.render(createElement(component))) +} +const input = (): HTMLInputElement => document.querySelector('input') as HTMLInputElement +const type = (text: string): void => { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + act(() => { + setValue.call(input(), text) + input().dispatchEvent(new Event('input', { bubbles: true })) + }) +} +const key = async (name: string): Promise => { + await act(async () => { + input().dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true })) + await Promise.resolve() + await Promise.resolve() + }) +} + +describe('palette hand-offs', () => { + it('the command palette closes through the palette close hand-off', async () => { + useStore.setState({ commandPaletteOpen: true }) + render(CommandPalette) + await key('Escape') + expect(useStore.getState().commandPaletteOpen).toBe(false) + expect(focusEditorAfterPaletteClose).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + }) + + it('a command that goes to a note already there lands like a search pick', async () => { + render(CommandPalette) + type('Read Something') + await key('Enter') + expect(goBack).toHaveBeenCalledTimes(1) + expect(focusEditorAfterReadOpen).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + }) + + it('a writing command still focuses the editor', async () => { + render(CommandPalette) + type('Write Something') + await key('Enter') + expect(newNote).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).toHaveBeenCalledTimes(1) + expect(focusEditorAfterReadOpen).not.toHaveBeenCalled() + }) + + it('the template palette closes through the palette close hand-off', async () => { + useStore.setState({ templatePaletteOpen: true }) + render(TemplatePalette) + await key('Escape') + expect(useStore.getState().templatePaletteOpen).toBe(false) + expect(focusEditorAfterPaletteClose).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + }) + + it('the drawing palette closes through the palette close hand-off', async () => { + useStore.setState({ embedDrawingPaletteOpen: true }) + render(EmbedDrawingPalette) + await key('Escape') + expect(useStore.getState().embedDrawingPaletteOpen).toBe(false) + expect(focusEditorAfterPaletteClose).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/components/SearchPalette.test.ts b/packages/app-core/src/components/SearchPalette.test.ts index dd10f017a..b0c2de49c 100644 --- a/packages/app-core/src/components/SearchPalette.test.ts +++ b/packages/app-core/src/components/SearchPalette.test.ts @@ -15,12 +15,12 @@ vi.mock('../lib/confirm-requests', () => ({ confirmApp })) // Closing the palette hands focus back to the editor with a few timed retries; // there is no editor here and the retries would outlive the jsdom window. const focusEditorNormalMode = vi.hoisted(() => vi.fn()) -const focusEditorAfterPick = vi.hoisted(() => vi.fn()) -const focusEditorAfterSearchClose = vi.hoisted(() => vi.fn()) +const focusEditorAfterReadOpen = vi.hoisted(() => vi.fn()) +const focusEditorAfterPaletteClose = vi.hoisted(() => vi.fn()) vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode, - focusEditorAfterPick, - focusEditorAfterSearchClose + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose })) function note(title: string): NoteMeta { @@ -184,8 +184,8 @@ describe('SearchPalette: the create row opens a New note form', () => { createAndOpen = vi.fn(async () => {}) selectNote = vi.fn(async () => {}) focusEditorNormalMode.mockClear() - focusEditorAfterPick.mockClear() - focusEditorAfterSearchClose.mockClear() + focusEditorAfterReadOpen.mockClear() + focusEditorAfterPaletteClose.mockClear() useStore.setState({ notes: [ { ...note('Alpha'), tags: ['ops', 'prod'] }, @@ -325,7 +325,7 @@ describe('SearchPalette: the create row opens a New note form', () => { expect(form()).toBeNull() // A pick hands over through the pick's own path, which decides between // focusing the editor (desktop) and leaving the note to be read (phones). - expect(focusEditorAfterPick).toHaveBeenCalledTimes(1) + expect(focusEditorAfterReadOpen).toHaveBeenCalledTimes(1) expect(focusEditorNormalMode).not.toHaveBeenCalled() }) @@ -333,9 +333,9 @@ describe('SearchPalette: the create row opens a New note form', () => { type(search(), 'Alph') await key(search(), 'Escape') expect(useStore.getState().searchOpen).toBe(false) - expect(focusEditorAfterSearchClose).toHaveBeenCalledTimes(1) + expect(focusEditorAfterPaletteClose).toHaveBeenCalledTimes(1) expect(focusEditorNormalMode).not.toHaveBeenCalled() - expect(focusEditorAfterPick).not.toHaveBeenCalled() + expect(focusEditorAfterReadOpen).not.toHaveBeenCalled() }) it('a typed path fills the Folder field, and the Folder picker filters, picks and moves on to Tags', async () => { diff --git a/packages/app-core/src/components/SearchPalette.tsx b/packages/app-core/src/components/SearchPalette.tsx index 169fce3fd..1ac93ad74 100644 --- a/packages/app-core/src/components/SearchPalette.tsx +++ b/packages/app-core/src/components/SearchPalette.tsx @@ -9,8 +9,8 @@ import { searchNoteIndex } from '../lib/note-search' import { - focusEditorAfterPick, - focusEditorAfterSearchClose, + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose, focusEditorNormalMode } from '../lib/editor-focus' import { @@ -148,7 +148,7 @@ export function SearchPalette(): JSX.Element { const open = async (note: NoteMeta): Promise => { setSearchOpen(false) await selectNote(note.path) - focusEditorAfterPick() + focusEditorAfterReadOpen() } // A block opens its note at the block's line, highlighted, and records the @@ -159,7 +159,7 @@ export function SearchPalette(): JSX.Element { setSearchOpen(false) if (row.kind === 'block') { await openTaggedBlock(row.note.path, row.block.startLine) - focusEditorAfterPick() + focusEditorAfterReadOpen() return } await openTagViewFor(tagTokens, freeText) @@ -178,7 +178,7 @@ export function SearchPalette(): JSX.Element { const close = (): void => { setSearchOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() } // Trash the highlighted note without leaving the palette (Discord ask: a diff --git a/packages/app-core/src/components/SearchPaletteBlocks.test.ts b/packages/app-core/src/components/SearchPaletteBlocks.test.ts index d1983d292..e9ab44b7f 100644 --- a/packages/app-core/src/components/SearchPaletteBlocks.test.ts +++ b/packages/app-core/src/components/SearchPaletteBlocks.test.ts @@ -12,12 +12,12 @@ import { SearchPalette } from './SearchPalette' const confirmApp = vi.hoisted(() => vi.fn(async () => true)) vi.mock('../lib/confirm-requests', () => ({ confirmApp })) const focusEditorNormalMode = vi.hoisted(() => vi.fn()) -const focusEditorAfterPick = vi.hoisted(() => vi.fn()) -const focusEditorAfterSearchClose = vi.hoisted(() => vi.fn()) +const focusEditorAfterReadOpen = vi.hoisted(() => vi.fn()) +const focusEditorAfterPaletteClose = vi.hoisted(() => vi.fn()) vi.mock('../lib/editor-focus', () => ({ focusEditorNormalMode, - focusEditorAfterPick, - focusEditorAfterSearchClose + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose })) function note(title: string, tags: string[], updatedAt: number): NoteMeta { @@ -62,7 +62,7 @@ describe('SearchPalette: tagged blocks under a #tag query', () => { beforeEach(() => { originalState = useStore.getState() focusEditorNormalMode.mockClear() - focusEditorAfterPick.mockClear() + focusEditorAfterReadOpen.mockClear() host = document.createElement('div') document.body.append(host) root = createRoot(host) @@ -160,7 +160,7 @@ describe('SearchPalette: tagged blocks under a #tag query', () => { { scrollMode: 'center', highlightLine: true } ) // A block is a pick like a note: read on a phone, edited on desktop. - expect(focusEditorAfterPick).toHaveBeenCalledTimes(1) + expect(focusEditorAfterReadOpen).toHaveBeenCalledTimes(1) expect(focusEditorNormalMode).not.toHaveBeenCalled() }) diff --git a/packages/app-core/src/components/TemplatePalette.tsx b/packages/app-core/src/components/TemplatePalette.tsx index 2cd12f136..748f4f7f6 100644 --- a/packages/app-core/src/components/TemplatePalette.tsx +++ b/packages/app-core/src/components/TemplatePalette.tsx @@ -11,7 +11,7 @@ import { useStore } from '../store' import { rankItems } from '../lib/fuzzy-score' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' import { isImeComposing } from '../lib/ime' -import { focusEditorNormalMode } from '../lib/editor-focus' +import { focusEditorAfterPaletteClose } from '../lib/editor-focus' import { BUILTIN_TEMPLATES } from '@shared/builtin-templates' import { mergeTemplates } from '@shared/template-files' import type { NoteTemplate } from '@bridge-contract/templates' @@ -70,7 +70,7 @@ export function TemplatePalette(): JSX.Element { const close = (): void => { setOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() } return ( diff --git a/packages/app-core/src/components/VaultTextSearchPalette.tsx b/packages/app-core/src/components/VaultTextSearchPalette.tsx index f640f9568..f9901641b 100644 --- a/packages/app-core/src/components/VaultTextSearchPalette.tsx +++ b/packages/app-core/src/components/VaultTextSearchPalette.tsx @@ -9,7 +9,7 @@ import { resolveSystemFolderLabels } from '../lib/system-folder-labels' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' import { isImeComposing } from '../lib/ime' import { recordRendererPerf } from '../lib/perf' -import { focusEditorAfterPick, focusEditorAfterSearchClose } from '../lib/editor-focus' +import { focusEditorAfterReadOpen, focusEditorAfterPaletteClose } from '../lib/editor-focus' import { Modal } from './ui/Modal' type ResolvedVaultTextSearchBackend = 'builtin' | 'ripgrep' | 'fzf' @@ -365,12 +365,12 @@ export function VaultTextSearchPalette(): JSX.Element { const openMatch = async (match: VaultTextSearchMatch): Promise => { setOpen(false) await openNoteAtOffset(match.path, match.offset, { scrollMode: 'center' }) - focusEditorAfterPick() + focusEditorAfterReadOpen() } const close = (): void => { setOpen(false) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() } return ( diff --git a/packages/app-core/src/editor.ts b/packages/app-core/src/editor.ts index 852eb11d5..a7b7e91fd 100644 --- a/packages/app-core/src/editor.ts +++ b/packages/app-core/src/editor.ts @@ -36,11 +36,14 @@ export interface EditorHostOptions { readonly measureBottomInsets?: (viewport: EditorViewport) => EditorBottomInsets /** * Asked as a pick in one of core's searches (the note search, Search in all - * notes, the outline, the buffer switcher) lands: true opens the note to be - * read, at the pick, without handing the editor the keyboard focus, so a - * soft keyboard stays down until the user taps into the text. Closing such - * a search then leaves the focus where it was too. Absent or false, the - * editor takes the focus, as on desktop. + * notes, the outline, the buffer switcher) lands, and as a command-palette + * command that only goes to a note already there (Go Back, Reopen Closed + * Tab, today's daily note when it exists) finishes: true opens the note to + * be read, without handing the editor the keyboard focus, so a soft + * keyboard stays down until the user taps into the text. Closing one of + * those palettes, or the template or drawing palette, without a pick then + * leaves the focus where it was too. Absent or false, the editor takes the + * focus, as on desktop. */ readonly openNotesToRead?: () => boolean } diff --git a/packages/app-core/src/lib/commands.test.ts b/packages/app-core/src/lib/commands.test.ts index 1a8970384..6aca5d45d 100644 --- a/packages/app-core/src/lib/commands.test.ts +++ b/packages/app-core/src/lib/commands.test.ts @@ -502,3 +502,95 @@ describe('reference pane commands with a per-note pin', () => { expect(buildCommands().some((c) => c.id === 'ref.toggle')).toBe(false) }) }) + +describe('commands that open a note to read', () => { + const today = new Date() + const iso = [ + today.getFullYear(), + String(today.getMonth() + 1).padStart(2, '0'), + String(today.getDate()).padStart(2, '0') + ].join('-') + const daily = { + path: `inbox/Daily Notes/${iso}.md`, + title: iso, + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 1, + size: 0, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '' + } as never + + async function commandsWith(notes: never[]) { + const { buildCommands, useStore } = await loadCommands() + const settings = useStore.getState().vaultSettings + useStore.setState({ + notes, + selectedPath: 'inbox/Plan.md', + activeNote: { folder: 'inbox', path: 'inbox/Plan.md', title: 'Plan', body: '' } as never, + closedTabStack: [{ path: 'inbox/Old.md' }] as never, + vaultSettings: { + ...settings, + dailyNotes: { ...settings.dailyNotes, enabled: true, directory: 'Daily Notes' }, + weeklyNotes: { ...settings.weeklyNotes, enabled: true }, + monthlyNotes: { ...settings.monthlyNotes, enabled: true } + } + }) + return { commands: buildCommands({ includeUnavailable: true }), useStore } + } + + it('marks only the commands that go to a note already there', async () => { + const { commands } = await commandsWith([]) + const marked = commands.filter((c) => c.opensToRead).map((c) => c.id).sort() + expect(marked).toEqual([ + 'nav.back', + 'nav.forward', + 'nav.toggle-recent', + 'note.daily.rollover', + 'note.daily.today', + 'note.monthly.thisMonth', + 'note.weekly.thisWeek', + 'tab.close', + 'tab.next', + 'tab.previous', + 'tab.reopen' + ]) + // History and tabs only ever land on notes that exist. + const navigation = ['nav.back', 'nav.forward', 'nav.toggle-recent', 'tab.close', 'tab.next'] + for (const id of [...navigation, 'tab.previous', 'tab.reopen']) { + expect(commands.find((c) => c.id === id)?.opensToRead?.()).toBe(true) + } + // Writing commands keep the editor focus. + const writing = ['note.new.inbox', 'note.new.quick', 'note.new.here', 'task.new', 'template.create'] + for (const id of [...writing, 'database.new', 'drawing.new', 'embed.drawing.new', 'nav.focus.editor']) { + expect(commands.find((c) => c.id === id)?.opensToRead).toBeUndefined() + } + }) + + it("reads today's daily note when it is there, and writes in one the command creates", async () => { + let { commands } = await commandsWith([]) + expect(commands.find((c) => c.id === 'note.daily.today')?.opensToRead?.()).toBe(false) + expect(commands.find((c) => c.id === 'note.daily.rollover')?.opensToRead?.()).toBe(false) + ;({ commands } = await commandsWith([daily])) + expect(commands.find((c) => c.id === 'note.daily.today')?.opensToRead?.()).toBe(true) + expect(commands.find((c) => c.id === 'note.daily.rollover')?.opensToRead?.()).toBe(true) + }) + + it('asks the periodic openers to open an existing note to read', async () => { + const { commands, useStore } = await commandsWith([daily]) + const openTodayDailyNote = vi.fn().mockResolvedValue(undefined) + const openThisWeekWeeklyNote = vi.fn().mockResolvedValue(undefined) + const openThisMonthMonthlyNote = vi.fn().mockResolvedValue(undefined) + useStore.setState({ openTodayDailyNote, openThisWeekWeeklyNote, openThisMonthMonthlyNote }) + await commands.find((c) => c.id === 'note.daily.today')?.run() + await commands.find((c) => c.id === 'note.weekly.thisWeek')?.run() + await commands.find((c) => c.id === 'note.monthly.thisMonth')?.run() + expect(openTodayDailyNote).toHaveBeenCalledWith({ readIfExisting: true }) + expect(openThisWeekWeeklyNote).toHaveBeenCalledWith({ readIfExisting: true }) + expect(openThisMonthMonthlyNote).toHaveBeenCalledWith({ readIfExisting: true }) + }) +}) diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 1736d39b5..a11713c3b 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -56,7 +56,13 @@ import { copyLinkAtCursor } from './link-copy' import { getKeymapDisplay, getLeaderChordDisplay, type KeymapId } from './keymaps' import { dispatchKeyboardContextMenu, findTabContextMenuTarget } from './keyboard-context-menu' import { resolveSystemFolderLabels } from './system-folder-labels' -import { isCalendarToggleAvailable, noteFolderSubpath } from './vault-layout' +import { + findDailyNoteForDate, + findMonthlyNoteForDate, + findWeeklyNoteForDate, + isCalendarToggleAvailable, + noteFolderSubpath +} from './vault-layout' import { runWorkflowById } from './workflow-trigger' import { requestPublishNote } from './publish-note-requests' import { activeReferencePath } from './pinned-reference' @@ -105,6 +111,16 @@ export interface Command { when?: () => boolean /** Runs when the user picks this entry. Async is fine. */ run: () => void | Promise + /** + * Asked just before the command runs: true when all it does is take the + * user to a note that already exists, to read it (Go Back, Reopen Closed + * Tab, today's daily note when it is there already). The palette then hands + * over the way a search pick does: the editor takes the keyboard on desktop, + * and a host that opens notes to read (a phone) keeps it down. Commands that + * create a note to be written in, or act on the note in place, leave it out + * and keep focusing the editor. + */ + opensToRead?: () => boolean } export function buildCommands(options?: { includeUnavailable?: boolean }): Command[] { @@ -119,6 +135,10 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma const parts = ids.map(shortcut) return parts.every(Boolean) ? parts.join(' ') : '' } + // Whether this period's daily, weekly or monthly note is already there: + // such a note opens to read, one the command creates is written in. + const periodicNoteExists = (find: typeof findDailyNoteForDate): boolean => + !!find(getState().notes, getState().vaultSettings, new Date()) const leaderShortcut = (id: KeymapId): string => getLeaderChordDisplay(getState().keymapOverrides, id) const paneShortcut = (id: KeymapId): string => chord('vim.panePrefix', id) @@ -193,7 +213,9 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma keywords: 'daily journal date today log', shortcut: leaderShortcut('vim.leaderDailyNote'), when: () => getState().vaultSettings.dailyNotes.enabled, - run: () => getState().openTodayDailyNote() + // Read when today's note is there already; one created here is written in. + opensToRead: () => periodicNoteExists(findDailyNoteForDate), + run: () => getState().openTodayDailyNote({ readIfExisting: true }) }, { id: 'note.daily.rollover', @@ -201,6 +223,9 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Note', keywords: 'daily tasks rollover roll over migrate unfinished carry forward today', when: () => getState().vaultSettings.dailyNotes.enabled, + // Like today's daily note: read when it is there already, focused when + // the rollover creates it. + opensToRead: () => periodicNoteExists(findDailyNoteForDate), run: () => { void getState().rolloverUnfinishedTasksIntoToday({ force: true, open: true }) } @@ -212,7 +237,8 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma keywords: 'weekly week review date log', shortcut: leaderShortcut('vim.leaderWeeklyNote'), when: () => getState().vaultSettings.weeklyNotes.enabled, - run: () => getState().openThisWeekWeeklyNote() + opensToRead: () => periodicNoteExists(findWeeklyNoteForDate), + run: () => getState().openThisWeekWeeklyNote({ readIfExisting: true }) }, { id: 'note.monthly.thisMonth', @@ -221,7 +247,8 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma keywords: 'monthly month review reflection date log', shortcut: leaderShortcut('vim.leaderMonthlyNote'), when: () => getState().vaultSettings.monthlyNotes.enabled, - run: () => getState().openThisMonthMonthlyNote() + opensToRead: () => periodicNoteExists(findMonthlyNoteForDate), + run: () => getState().openThisMonthMonthlyNote({ readIfExisting: true }) }, { id: 'template.create', @@ -681,6 +708,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma // save shows `:w`. Outside Vim, the real Mod+W binding is correct. shortcut: getState().vimMode ? ':q' : shortcut('global.closeActiveTab'), when: () => !!getState().selectedPath, + opensToRead: () => true, run: () => getState().closeActiveNote() }, { @@ -690,6 +718,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma keywords: 'restore last undo closed reopen', shortcut: shortcut('global.reopenClosedTab'), when: () => getState().closedTabStack.length > 0, + opensToRead: () => true, run: () => getState().reopenLastClosedTab() }, { @@ -702,6 +731,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma const s = getState() return getTabStepTarget(s.paneLayout, s.activePaneId, 1).kind !== 'none' }, + opensToRead: () => true, run: () => stepActiveTab(getState(), 1) }, { @@ -714,6 +744,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma const s = getState() return getTabStepTarget(s.paneLayout, s.activePaneId, -1).kind !== 'none' }, + opensToRead: () => true, run: () => stepActiveTab(getState(), -1) }, { @@ -1178,6 +1209,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Tabs', shortcut: shortcut('vim.historyBack'), keywords: 'history previous', + opensToRead: () => true, run: () => getState().jumpToPreviousNote() }, { @@ -1186,6 +1218,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Tabs', shortcut: shortcut('vim.historyForward'), keywords: 'history next', + opensToRead: () => true, run: () => getState().jumpToNextNote() }, { @@ -1194,6 +1227,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Tabs', shortcut: shortcut('global.toggleRecentNote'), keywords: 'recent last previous alternate toggle switch note', + opensToRead: () => true, run: () => getState().toggleRecentNote() } ) diff --git a/packages/app-core/src/lib/editor-focus.test.ts b/packages/app-core/src/lib/editor-focus.test.ts index 150b7b522..0aab7b6ee 100644 --- a/packages/app-core/src/lib/editor-focus.test.ts +++ b/packages/app-core/src/lib/editor-focus.test.ts @@ -26,8 +26,8 @@ vi.mock('@replit/codemirror-vim', () => ({ import { EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { - focusEditorAfterPick, - focusEditorAfterSearchClose, + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose, focusEditorNormalMode } from './editor-focus' import { installNoteEditorHost } from './editor-host' @@ -101,27 +101,39 @@ describe('search hand-offs', () => { document.body.innerHTML = '' }) - it('a pick focuses the editor where the host does not open notes to read (desktop)', async () => { - focusEditorAfterPick() + it('a read open focuses the editor where the host does not open notes to read (desktop)', async () => { + focusEditorAfterReadOpen() await vi.advanceTimersByTimeAsync(0) expect(view!.hasFocus).toBe(true) expect(storeState.current.setFocusedPanel).toHaveBeenCalledWith('editor') }) + it('on desktop a read open keeps the retries its caller asked for', async () => { + // The daily-note request retries 10 times, 24 ms apart: the note's editor + // can mount after the first frames. + storeState.current.editorViewRef = null + focusEditorAfterReadOpen({ attempts: 10, delayMs: 24 }) + await vi.advanceTimersByTimeAsync(24 * 5) + expect(view!.hasFocus).toBe(false) + storeState.current.editorViewRef = view as never + await vi.advanceTimersByTimeAsync(24 * 2) + expect(view!.hasFocus).toBe(true) + }) + it('a host that answers no, or throws, keeps the desktop hand-off', async () => { host = installNoteEditorHost({ openNotesToRead: () => { throw new Error('gone') } }) - focusEditorAfterPick() + focusEditorAfterReadOpen() await vi.advanceTimersByTimeAsync(0) expect(view!.hasFocus).toBe(true) }) - it('a pick on a host that opens notes to read leaves the keyboard down and the note read', async () => { + it('a read open on a host that opens notes to read leaves the keyboard down and the note read', async () => { host = installNoteEditorHost({ openNotesToRead: () => true }) - focusEditorAfterPick() + focusEditorAfterReadOpen() await vi.advanceTimersByTimeAsync(50) expect(view!.hasFocus).toBe(false) expect(storeState.current.setFocusedPanel).not.toHaveBeenCalled() @@ -133,20 +145,20 @@ describe('search hand-offs', () => { view!.focus() await vi.advanceTimersByTimeAsync(0) expect(revealedSelectionRanges(view!.state)).not.toEqual([]) - focusEditorAfterPick() + focusEditorAfterReadOpen() await vi.advanceTimersByTimeAsync(50) expect(view!.hasFocus).toBe(false) expect(revealedSelectionRanges(view!.state)).toEqual([]) }) - it('closing a search gives the editor the keyboard on desktop, and leaves the focus alone on a reading host', async () => { - focusEditorAfterSearchClose() + it('closing a palette gives the editor the keyboard on desktop, and leaves the focus alone on a reading host', async () => { + focusEditorAfterPaletteClose() await vi.advanceTimersByTimeAsync(0) expect(view!.hasFocus).toBe(true) view!.contentDOM.blur() host = installNoteEditorHost({ openNotesToRead: () => true }) - focusEditorAfterSearchClose() + focusEditorAfterPaletteClose() await vi.advanceTimersByTimeAsync(50) expect(view!.hasFocus).toBe(false) }) diff --git a/packages/app-core/src/lib/editor-focus.ts b/packages/app-core/src/lib/editor-focus.ts index a9bd41b53..0a45f0911 100644 --- a/packages/app-core/src/lib/editor-focus.ts +++ b/packages/app-core/src/lib/editor-focus.ts @@ -50,23 +50,28 @@ export function focusEditorNormalMode( } /** - * The hand-off after a pick in one of core's searches (the note search, Search - * in all notes, the outline, the buffer switcher) put a note on screen or moved - * within one. Desktop gives the editor the keyboard, so the next keystroke - * edits or moves in the note. A host that opens notes to read (a phone; the - * editor host's `openNotesToRead`) keeps it down: there the focus is the soft - * keyboard over half the note, and the user came to read what they found. The - * note stays at the pick with every line rendered, and a tap into the text - * raises the keyboard as always. + * The hand-off after a note was opened to be read, or a place in one: a pick + * in one of core's searches (the note search, Search in all notes, the + * outline, the buffer switcher), or a command that only goes to a note that + * already exists (Go Back, Reopen Closed Tab, today's daily note when it is + * there already; see `Command.opensToRead`). Desktop gives the editor the + * keyboard, so the next keystroke edits or moves in the note, with the same + * retries the caller would have asked of `focusEditorNormalMode`. A host that + * opens notes to read (a phone; the editor host's `openNotesToRead`) keeps it + * down: there the focus is the soft keyboard over half the note, and the user + * came to read it. The note stays where it opened with every line rendered, + * and a tap into the text raises the keyboard as always. * * A palette closing hands the focus back to whatever had it when it opened, * which can be the editor itself (an iPhone keeps the focus in the text under - * a tap on a button). The pick still ends with the note read and the keyboard - * down: that hand-back is undone, a frame on, once the pick has landed. + * a tap on a button). The open still ends with the note read and the keyboard + * down: that hand-back is undone, a frame on, once the note has landed. */ -export function focusEditorAfterPick(): void { +export function focusEditorAfterReadOpen( + options: { attempts?: number; delayMs?: number } = {} +): void { if (!hostOpensNotesToRead()) { - focusEditorNormalMode() + focusEditorNormalMode(options) return } requestAnimationFrame(() => { @@ -78,11 +83,13 @@ export function focusEditorAfterPick(): void { } /** - * Closing one of those searches without a pick. Desktop gives the editor the + * Closing a palette without a pick: the searches above, the command palette, + * the template palette, the drawing palette. Desktop gives the editor the * keyboard back, as it always has; a host that opens notes to read leaves the - * focus where the palette returns it, to what had it before the search, so - * backing out of a search over a note being read keeps the keyboard down. + * focus where the palette returns it, to what had it before the palette + * opened, so backing out over a note being read keeps the keyboard down and + * backing out over a note being written keeps it up. */ -export function focusEditorAfterSearchClose(): void { +export function focusEditorAfterPaletteClose(): void { if (!hostOpensNotesToRead()) focusEditorNormalMode() } diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index d11a06226..d6ce0d25b 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -340,6 +340,54 @@ describe('daily note patterns', () => { }) }) +describe('daily note focus request', () => { + const settings = { + primaryNotesLocation: 'inbox' as const, + dailyNotes: { enabled: true, directory: 'Daily Notes' }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + monthlyNotes: { enabled: false, directory: 'Monthly Notes' }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } + const date = new Date(2026, 5, 9) + const path = 'inbox/Daily Notes/2026-06-09.md' + + async function focusRequestsWhileOpening(exists: boolean, readIfExisting?: boolean) { + const note = makeNote('# 2026-06-09\n', path) + installZen({ + createNote: vi.fn().mockResolvedValue(note), + listNotes: vi.fn().mockResolvedValue([note]), + readNote: vi.fn().mockResolvedValue(note), + writeNote: vi.fn(async () => note) + }) + const { useStore } = await loadStore() + useStore.setState({ notes: exists ? [note] : [], customTemplates: [], vaultSettings: settings as never }) + const requests: unknown[] = [] + const listen = (event: Event): void => { + requests.push((event as CustomEvent).detail ?? null) + } + window.addEventListener('zen:focus-editor', listen) + try { + await useStore + .getState() + .openDailyNoteForDate(date, readIfExisting === undefined ? undefined : { readIfExisting }) + } finally { + window.removeEventListener('zen:focus-editor', listen) + } + return requests + } + + it('asks for a read landing when the caller opens an existing note to read', async () => { + expect(await focusRequestsWhileOpening(true, true)).toEqual([{ read: true }]) + }) + + it('asks for the plain editor focus otherwise: no option, or a note it had to create', async () => { + expect(await focusRequestsWhileOpening(true)).toEqual([null]) + expect(await focusRequestsWhileOpening(false, true)).toEqual([null]) + }) +}) + describe('daily task rollover', () => { it.each([false, true])('replaces empty template tasks and preserves real work (open: %s)', async (open) => { const iso = (date: Date) => [date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'), diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index a4983b641..c7c3171e9 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -308,9 +308,15 @@ const WORKSPACE_KEY = 'zen:workspace:v1' * event (handled in App.tsx via `focusEditorNormalMode`) so the store doesn't * have to import editor-focus, which imports the store. Used when a focused * panel (Tasks/Tags) closes so typing lands in the editor again. (#353) */ -function requestEditorFocus(): void { +function requestEditorFocus(options: { read?: boolean } = {}): void { if (typeof window === 'undefined') return - window.dispatchEvent(new Event('zen:focus-editor')) + // `read`: the note already existed and was opened to be read, which App + // hands over like a search pick (left unfocused on a phone). + window.dispatchEvent( + options.read + ? new CustomEvent('zen:focus-editor', { detail: { read: true } }) + : new Event('zen:focus-editor') + ) } /** Debounce for mirroring the workspace snapshot to the synced vault file — * localStorage updates immediately; the file lags to bound sync churn. (#292) */ @@ -505,6 +511,14 @@ function isDeletedAssetRecord(value: unknown): value is DeletedAsset { /** Which weekday the calendar grid starts on. `locale` derives it from the * user's locale (falling back to Monday). */ export type CalendarWeekStart = 'monday' | 'sunday' | 'locale' + +/** How a daily, weekly or monthly note opener lands. `readIfExisting`: a note + * that is already there opens to be read (the command palette's Open Today's + * Daily Note and friends), so a phone keeps its keyboard down; one the opener + * has to create still takes the focus, to be written in. */ +export interface PeriodicNoteOpenOptions { + readIfExisting?: boolean +} const VALID_CALENDAR_WEEK_STARTS: CalendarWeekStart[] = ['monday', 'sunday', 'locale'] /** The editor-pane right-side panels whose width the user can drag-resize. */ @@ -3882,9 +3896,9 @@ interface Store { setQuickNoteDateTitle: (on: boolean) => void setQuickNoteTitlePrefix: (prefix: string | null) => void - openTodayDailyNote: () => Promise - openThisWeekWeeklyNote: () => Promise - openThisMonthMonthlyNote: () => Promise + openTodayDailyNote: (options?: PeriodicNoteOpenOptions) => Promise + openThisWeekWeeklyNote: (options?: PeriodicNoteOpenOptions) => Promise + openThisMonthMonthlyNote: (options?: PeriodicNoteOpenOptions) => Promise setTemplatePaletteOpen: (open: boolean) => void setEmbedDrawingPaletteOpen: (open: boolean) => void setNoteCoverPickerPath: (path: string | null) => void @@ -3938,9 +3952,9 @@ interface Store { setAutoCalendarPanel: (enabled: boolean) => void setCalendarWeekStart: (start: CalendarWeekStart) => void setCalendarShowWeekNumbers: (show: boolean) => void - openDailyNoteForDate: (date: Date) => Promise - openWeeklyNoteForDate: (date: Date) => Promise - openMonthlyNoteForDate: (date: Date) => Promise + openDailyNoteForDate: (date: Date, options?: PeriodicNoteOpenOptions) => Promise + openWeeklyNoteForDate: (date: Date, options?: PeriodicNoteOpenOptions) => Promise + openMonthlyNoteForDate: (date: Date, options?: PeriodicNoteOpenOptions) => Promise /** Find the daily note for `date`, creating it on disk (template-aware) * WITHOUT navigating to it. Returns its meta, or null if daily notes are * disabled or creation failed. */ @@ -9926,7 +9940,7 @@ export const useStore = create((set, get) => { savePrefs(collectPrefs(get())) }, - openDailyNoteForDate: async (date) => { + openDailyNoteForDate: async (date, options) => { const state = get() const settings = normalizeVaultSettings(state.vaultSettings) if (!settings.dailyNotes.enabled) return @@ -9947,7 +9961,8 @@ export const useStore = create((set, get) => { // instead of leaving focus on the sidebar item that just got selected. The // command is a jump-and-type flow and is often fired from outside the // editor (leader key, palette), where focus would otherwise stay put. (#353) - requestEditorFocus() + // A caller that opens it to read asks for that when the note was there. + requestEditorFocus({ read: !!existing && options?.readIfExisting === true }) // Opening *today's* note rolls unfinished tasks forward from past daily // notes (Obsidian-style) when enabled. Fire-and-forget so the note shows // right away; the rollover appends into the now-open buffer. @@ -9956,8 +9971,8 @@ export const useStore = create((set, get) => { } }, - openTodayDailyNote: async () => { - await get().openDailyNoteForDate(new Date()) + openTodayDailyNote: async (options) => { + await get().openDailyNoteForDate(new Date(), options) }, ensureDailyNoteForDate: trackNoteWrite(null, async (date) => { @@ -10141,7 +10156,7 @@ export const useStore = create((set, get) => { return movedLines.length }), - openWeeklyNoteForDate: async (date) => { + openWeeklyNoteForDate: async (date, options) => { const state = get() const settings = normalizeVaultSettings(state.vaultSettings) if (!settings.weeklyNotes.enabled) return @@ -10159,14 +10174,14 @@ export const useStore = create((set, get) => { } } // Focus the editor so `i` starts insert immediately (see openDailyNoteForDate). - requestEditorFocus() + requestEditorFocus({ read: !!existing && options?.readIfExisting === true }) }, - openThisWeekWeeklyNote: async () => { - await get().openWeeklyNoteForDate(new Date()) + openThisWeekWeeklyNote: async (options) => { + await get().openWeeklyNoteForDate(new Date(), options) }, - openMonthlyNoteForDate: async (date) => { + openMonthlyNoteForDate: async (date, options) => { const state = get() const settings = normalizeVaultSettings(state.vaultSettings) if (!settings.monthlyNotes.enabled) return @@ -10184,11 +10199,11 @@ export const useStore = create((set, get) => { } } // Focus the editor so `i` starts insert immediately (see openDailyNoteForDate). - requestEditorFocus() + requestEditorFocus({ read: !!existing && options?.readIfExisting === true }) }, - openThisMonthMonthlyNote: async () => { - await get().openMonthlyNoteForDate(new Date()) + openThisMonthMonthlyNote: async (options) => { + await get().openMonthlyNoteForDate(new Date(), options) }, setTemplatePaletteOpen: (open) => From 42865ca7fabe59ecee040382789ccb175a3a5674 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 28 Oct 2026 09:24:03 -0500 Subject: [PATCH 5/6] Feat(settings): a Status bar switch hides the bar along the bottom, except while Cloud needs you MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Piotr asked on Discord (2026-10-28), with an Android screenshot: "On android, can we hide the cloud connected / setup bar??" On the phones the bar along the bottom carries only its Cloud half, so a device that is signed in but not linked showed "ZenNotes Cloud · Cloud connected · Set up" under every note for as long as it stayed that way, and on Android the circle was lifted above that row too. There was no way to turn it off, on the phones or on desktop. Settings, Appearance, Chrome gains Status bar, on by default, next to the title bar switch, with the words people search for (cloud, cloud connected, footer, word count). Turned off, the bar is not drawn at all on any surface: desktop gets its 32 px back, and on Android the Cloud row goes and the circle drops back to the bottom, because the shell sizes that row and lifts the circle from the bar's data-cloud-sync-* hooks, which are no longer in the page. It stays off except while Cloud needs the person. cloudSyncNeedsUser (cloud-auto-sync.ts) is the same set the phones already key their Cloud row on: the attention and error phases (a stopped or failed run, including Can't reach Cloud), a Cloud vault that went away and a device Cloud signed out (both drawn as attention, now one cloudSyncShownPhase that the status row uses too), and files or the vault settings question waiting for a decision. Then the bar comes back until that is dealt with, so turning it off never hides the one place that says sync has stopped. Zen mode still clears the bar whatever it says, as before. statusBarVisible (lib/status-bar-visibility.ts) holds the rule. It is a portable preference: showStatusBar in PORTABLE_PREF_KEYS, show_status_bar under [appearance] in config.toml beside show_window_title_bar. The phones keep it with the rest of app-core's preferences (zen:prefs:v2, which both shells mirror into native storage), so it needs no shell code. Keyboard: the command palette's Toggle Status Bar (and so :view_status_bar), and `:set statusbar`, `:set nostatusbar`, `:set statusbar?` (alias `stb`) in Vim mode, the way `:set undofile` works. No single-key binding, so Vim-off gating is untouched. The option is defined without a default value: codemirror-vim applies a truthy default through the callback when the option is defined, and the first desktop relaunch over a saved show_status_bar = false came back with the bar on. Docs: the Appearance entry, the Cloud card and the ex command list in help.ts; the website follows in its own branch. What it deliberately does not do: change the iPhone, whose shell already shows the Cloud row only while Cloud needs the user, so there the switch changes nothing until the shell decides otherwise; or add a global shortcut or leader key, which none of the other chrome switches have. Tests: statusBarVisible (on by default, off until Cloud needs the user, Zen mode wins); cloudSyncNeedsUser (quiet for the everyday phases; needs the user for attention, error, a removed vault, a signed-out device, waiting files even mid-sync, and the settings question); the Settings search finds Status bar by "cloud connected" and its switch turns it off; Toggle Status Bar flips and saves it; `:set statusbar` leaves a saved choice alone when it is defined (fails with a truthy default) and sets, clears and reports it; config.toml round-trips [appearance].show_status_bar; PORTABLE_DEFAULTS still matches DEFAULT_PREFS. app-core 3,636, shared-domain 2,164 and desktop 1,198 pass; typecheck clean (8/8). In the built app over CDP with isolated stores: Toggle Status Bar hides the bar and the editor gains 32 px, config.toml says show_status_bar = false, `:set statusbar` and `:set nostatusbar` bring it back and hide it; with the main process's Cloud handlers swapped for a linked account whose sync fails, the hidden bar comes back as "Sync failed", and goes again once a sync succeeds; a relaunch with a fresh userData and the same config.toml opens with the bar hidden. --- apps/desktop/src/main/app-config.test.ts | 4 ++ apps/desktop/src/main/app-config.ts | 5 +++ packages/app-core/src/components/Editor.tsx | 18 +++++++-- .../src/components/SettingsModal.test.ts | 12 ++++++ .../app-core/src/components/SettingsModal.tsx | 28 +++++++++++++ .../app-core/src/components/StatusBar.tsx | 9 +++-- .../app-core/src/lib/cloud-auto-sync.test.ts | 39 +++++++++++++++++++ packages/app-core/src/lib/cloud-auto-sync.ts | 36 +++++++++++++++++ packages/app-core/src/lib/commands.test.ts | 14 +++++++ packages/app-core/src/lib/commands.ts | 9 +++++ packages/app-core/src/lib/help.ts | 8 +++- .../src/lib/status-bar-visibility.test.ts | 35 +++++++++++++++++ .../app-core/src/lib/status-bar-visibility.ts | 26 +++++++++++++ .../src/lib/vim-status-bar-option.test.ts | 28 +++++++++++++ .../app-core/src/lib/vim-status-bar-option.ts | 20 ++++++++++ packages/app-core/src/store.ts | 15 +++++++ packages/bridge-contract/src/app-config.ts | 1 + packages/shared-domain/src/app-config.ts | 1 + 18 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 packages/app-core/src/lib/status-bar-visibility.test.ts create mode 100644 packages/app-core/src/lib/status-bar-visibility.ts create mode 100644 packages/app-core/src/lib/vim-status-bar-option.test.ts create mode 100644 packages/app-core/src/lib/vim-status-bar-option.ts diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index d77de0fb8..c1f4b5bb7 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -116,6 +116,7 @@ describe('TOML serialization', () => { themeFamily: 'nord', themeMode: 'dark', showWindowTitleBar: false, + showStatusBar: false, autoPairs: false, autoPairQuotesInProse: true, showHeadingLevelLabels: true, @@ -151,6 +152,9 @@ describe('TOML serialization', () => { expect(round.themeFamily).toBe('nord') expect(text).toContain('show_window_title_bar = false') expect(round.showWindowTitleBar).toBe(false) + // Piotr's status bar switch travels as [appearance].show_status_bar. + expect(text).toMatch(/\[appearance\][^[]*show_status_bar = false/) + expect(round.showStatusBar).toBe(false) expect(round.autoPairs).toBe(false) expect(round.autoPairQuotesInProse).toBe(true) expect(round.showHeadingLevelLabels).toBe(true) diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index c15494479..dc61b2e28 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -307,6 +307,11 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'show_window_title_bar', comment: 'show the main window title bar and window controls' }, + showStatusBar: { + section: 'appearance', + tomlKey: 'show_status_bar', + comment: 'show the bar along the bottom; while hidden it still appears when Cloud needs you' + }, showSidebarChevrons: { section: 'appearance', tomlKey: 'show_sidebar_chevrons', diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index 6474e31e6..b953ecdc7 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -81,7 +81,9 @@ import { openExternalFileLink, } from "../lib/external-file-link"; import { StatusBar } from "./StatusBar"; -import { useCloudSyncStatusStore } from "../lib/cloud-auto-sync"; +import { cloudSyncNeedsUser, useCloudSyncStatusStore } from "../lib/cloud-auto-sync"; +import { statusBarVisible } from "../lib/status-bar-visibility"; +import { defineStatusBarOption } from "../lib/vim-status-bar-option"; import { EditorPane } from "./EditorPane"; import { focusPaneInDirection, focusPaneOrEdgePanel } from "../lib/pane-nav"; import { requestPaneMode } from "../lib/pane-mode"; @@ -642,6 +644,7 @@ function registerVimCommands(): void { return undefined; }, ); + defineStatusBarOption(); // `:harper on|off` (or bare `:harper` to toggle) is the ex twin of the // Settings toggle "Grammar and spelling with Harper". Vim.defineEx( @@ -1817,6 +1820,15 @@ export function Editor(): JSX.Element { const cloudSyncVisible = useCloudSyncStatusStore( (state) => state.phase !== "hidden", ); + const showStatusBar = useStore((s) => s.showStatusBar); + const cloudNeedsUser = useCloudSyncStatusStore(cloudSyncNeedsUser); + const statusBarShown = statusBarVisible({ + zenMode, + hasNote: activeNote !== null, + cloudShown: cloudSyncVisible, + showStatusBar, + cloudNeedsUser, + }); useEffect(() => { registerVimCommands(); @@ -1836,9 +1848,7 @@ export function Editor(): JSX.Element {
- {!zenMode && (activeNote || cloudSyncVisible) && ( - - )} + {statusBarShown && } ); } diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index 795e849b3..21c69011d 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -41,6 +41,8 @@ const mocks = vi.hoisted(() => { darkSidebar: false, showWindowTitleBar: true, setShowWindowTitleBar: vi.fn(), + showStatusBar: true, + setShowStatusBar: vi.fn(), editorFontSize: 16, editorLineHeight: 1.6, editorScrollOff: 0, @@ -290,6 +292,16 @@ describe("SettingsModal date note directories", () => { expect(mocks.state.setShowWindowTitleBar).toHaveBeenCalledWith(false); }); + it("finds the status bar by the Cloud row it carries and turns it off", async () => { + await act(async () => root.render(createElement(SettingsModal))); + const search = host.querySelector('input[placeholder="Search settings…"]'); + await act(async () => changeInput(search!, "cloud connected")); + const toggle = host.querySelector('[data-settings-search-id="status-bar"] [role="switch"]'); + expect(toggle?.getAttribute("aria-checked")).toBe("true"); + await act(async () => toggle!.click()); + expect(mocks.state.setShowStatusBar).toHaveBeenCalledWith(false); + }); + it("finds the new-file location rows by the words people search for", async () => { await act(async () => root.render(createElement(SettingsModal))); const search = host.querySelector('input[placeholder="Search settings…"]')!; diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 98b1833d9..9c3b42c71 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -956,6 +956,8 @@ export function SettingsModal(): JSX.Element { const showWindowTitleBar = useStore((s) => s.showWindowTitleBar); const keepRunningInTray = useTrayStore((s) => s.keepRunningInTray); const setShowWindowTitleBar = useStore((s) => s.setShowWindowTitleBar); + const showStatusBar = useStore((s) => s.showStatusBar); + const setShowStatusBar = useStore((s) => s.setShowStatusBar); const setDarkSidebar = useStore((s) => s.setDarkSidebar); const showSidebarChevrons = useStore((s) => s.showSidebarChevrons); const setShowSidebarChevrons = useStore((s) => s.setShowSidebarChevrons); @@ -1601,6 +1603,25 @@ export function SettingsModal(): JSX.Element { : "Show the bar with the open note's title, or hide it to reclaim vertical space.", keywords: ["decorations", "frameless", "traffic lights", "tiling", "hyprland", "linux", "header"], }, + { + id: "status-bar", + title: "Status bar", + description: + "Show the bar along the bottom with Cloud sync status and the note's backlinks, words and position. Turned off, it still appears while Cloud needs you.", + keywords: [ + "status bar", + "footer", + "bottom bar", + "cloud", + "sync", + "cloud connected", + "set up", + "word count", + "backlinks", + "cursor position", + "hide", + ], + }, { id: "keep-running-in-tray", title: "Keep running in the tray", @@ -2046,6 +2067,13 @@ export function SettingsModal(): JSX.Element { onChange={setShowWindowTitleBar} /> )} + ; const label = removal ? cloudVaultRemovalLabel(removal) diff --git a/packages/app-core/src/lib/cloud-auto-sync.test.ts b/packages/app-core/src/lib/cloud-auto-sync.test.ts index ef2fa569d..e7e2ad8ab 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.test.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.test.ts @@ -18,6 +18,7 @@ import { cloudSyncAttentionItems, cloudSyncAttentionLabel, cloudSyncAttentionMessage, + cloudSyncNeedsUser, cloudVaultGoneReason, cloudVaultRemovalLabel, cloudVaultRemovalMessage, @@ -1405,3 +1406,41 @@ describe("a Cloud vault that went away", () => { expect(values.size).toBe(0); }); }); + +describe("cloudSyncNeedsUser (the status bar shows for it even when turned off)", () => { + const quiet = { + phase: "ready" as const, + removedVault: null, + errorKind: null, + lastSummary: null, + settingsConflict: null, + }; + + it("is quiet for the everyday phases", () => { + for (const phase of ["hidden", "disconnected", "connecting", "unlinked", "ready", "syncing"] as const) { + expect(cloudSyncNeedsUser({ ...quiet, phase })).toBe(false); + } + }); + + it("needs the user for a stopped or failed run", () => { + expect(cloudSyncNeedsUser({ ...quiet, phase: "attention" })).toBe(true); + expect(cloudSyncNeedsUser({ ...quiet, phase: "error", errorKind: "offline" })).toBe(true); + }); + + it("needs the user for a vault that went away and a device Cloud signed out", () => { + expect( + cloudSyncNeedsUser({ + ...quiet, + phase: "unlinked", + removedVault: { vaultName: "Notes", reason: "deleted" }, + }), + ).toBe(true); + expect(cloudSyncNeedsUser({ ...quiet, phase: "disconnected", errorKind: "signed-out" })).toBe(true); + }); + + it("needs the user while files or the settings question wait, even mid-sync", () => { + const summary = { pending_conflicts: [{ id: "c1" }] } as never; + expect(cloudSyncNeedsUser({ ...quiet, phase: "syncing", lastSummary: summary })).toBe(true); + expect(cloudSyncNeedsUser({ ...quiet, phase: "ready", settingsConflict: {} as never })).toBe(true); + }); +}); diff --git a/packages/app-core/src/lib/cloud-auto-sync.ts b/packages/app-core/src/lib/cloud-auto-sync.ts index 8bd792848..267a811d4 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.ts @@ -788,6 +788,42 @@ function announceNewCloudConflicts( ); } +/** + * The phase the status row draws. A device whose Cloud vault went away, or + * that Cloud signed out, stopped syncing without the person choosing to, so + * it reads as attention rather than as a quiet unlinked or disconnected row. + */ +export function cloudSyncShownPhase( + state: Pick, +): CloudSyncPhase { + if (state.phase === "unlinked" && state.removedVault !== null) return "attention"; + if (state.phase === "disconnected" && state.errorKind === "signed-out") return "attention"; + return state.phase; +} + +/** + * True while Cloud needs the person: a run that stopped for them or failed + * (including a Cloud vault that went away and a device Cloud signed out), or + * files or the vault settings question waiting for a decision. The status + * bar shows for these even when it is turned off, so nothing that needs an + * answer goes silent; the phone shells key their Cloud row on the same set + * (the attention and error phases and the review hook). + */ +export function cloudSyncNeedsUser( + state: Pick< + CloudSyncStatusStore, + "phase" | "removedVault" | "errorKind" | "lastSummary" | "settingsConflict" + > = useCloudSyncStatusStore.getState(), +): boolean { + const shown = cloudSyncShownPhase(state); + return ( + shown === "attention" || + shown === "error" || + resolvableCloudConflictCount(state.lastSummary) > 0 || + state.settingsConflict !== null + ); +} + /** Conflicts the queue can actually resolve. Bootstrap conflicts are no longer * emitted by the coordinator, so only the durable pending queue counts. */ export function resolvableCloudConflictCount( diff --git a/packages/app-core/src/lib/commands.test.ts b/packages/app-core/src/lib/commands.test.ts index 6aca5d45d..7b909846b 100644 --- a/packages/app-core/src/lib/commands.test.ts +++ b/packages/app-core/src/lib/commands.test.ts @@ -594,3 +594,17 @@ describe('commands that open a note to read', () => { expect(openThisMonthMonthlyNote).toHaveBeenCalledWith({ readIfExisting: true }) }) }) + +describe('Toggle Status Bar (Piotr on Discord)', () => { + it('flips the setting and saves it with the other preferences', async () => { + const { buildCommands, useStore } = await loadCommands() + expect(useStore.getState().showStatusBar).toBe(true) + const toggle = buildCommands().find((c) => c.id === 'view.status-bar') + expect(toggle?.title).toBe('Toggle Status Bar') + await toggle?.run() + expect(useStore.getState().showStatusBar).toBe(false) + expect(JSON.parse(localStorage.getItem('zen:prefs:v2') ?? '{}').showStatusBar).toBe(false) + await buildCommands().find((c) => c.id === 'view.status-bar')?.run() + expect(useStore.getState().showStatusBar).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index a11713c3b..5f48c8ade 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -1571,6 +1571,15 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'View', run: () => getState().setDarkSidebar(!getState().darkSidebar) }, + { + // Settings, Appearance, Chrome, Status bar. Hidden, the bar still shows + // while Cloud needs the user (lib/status-bar-visibility.ts). + id: 'view.status-bar', + title: 'Toggle Status Bar', + category: 'View', + keywords: 'status bar footer bottom bar hide show cloud sync word count backlinks chrome', + run: () => getState().setShowStatusBar(!getState().showStatusBar) + }, { id: 'view.line-numbers.off', title: 'Line Numbers: Off', diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 44688ef4f..214f58e2a 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -219,7 +219,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ { title: 'When Cloud reports a problem', body: - 'When ZenNotes Cloud can\'t finish something, the status bar and Settings → Cloud say what happened and what you can do, in plain words: **Can\'t reach Cloud** when this device is offline or Cloud is unreachable, or **Signed out of Cloud** when this device was removed from your account on the website. Your notes stay on this device either way. Under the message, **Details** holds the technical line (Cloud\'s answer, its HTTP status and code); **Copy details** puts it on the clipboard, and **Report an Issue** opens the ZenNotes issue tracker on GitHub so you can paste it into a report.' + 'When ZenNotes Cloud can\'t finish something, the status bar and Settings → Cloud say what happened and what you can do, in plain words: **Can\'t reach Cloud** when this device is offline or Cloud is unreachable, or **Signed out of Cloud** when this device was removed from your account on the website. Your notes stay on this device either way. Under the message, **Details** holds the technical line (Cloud\'s answer, its HTTP status and code); **Copy details** puts it on the clipboard, and **Report an Issue** opens the ZenNotes issue tracker on GitHub so you can paste it into a report. The status bar shows these even when you have turned it off under Settings → Appearance → Chrome.' }, { title: 'Customize the look: themes vs. overrides', @@ -809,6 +809,11 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Save the active note', detail: 'Flush the current buffer to disk immediately.' }, + { + command: ':set [no]statusbar', + summary: 'Show or hide the status bar', + detail: 'The same switch as Settings, Appearance, Chrome, Status bar: `:set nostatusbar` hides the bar along the bottom, `:set statusbar` brings it back and `:set statusbar?` says which it is (`stb` for short). Hidden, the bar still shows while ZenNotes Cloud needs you.' + }, { command: ':harper [on|off]', summary: 'Grammar and spelling with Harper', @@ -1167,6 +1172,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ items: [ { label: 'Theme, mode, and variant', detail: 'Pick a theme family: Apple, Gruvbox, Catppuccin, GitHub, Solarized, One, Nord, Tokyo Night, Kanagawa (Wave / Dragon / Paper Ink (Custom) / Lotus), Rosé Pine (Rosé Pine / Moon / Dawn), or the monochrome, true-black (OLED-friendly) Black Metal, plus light or dark mode and the active flavor or contrast where the theme supports it.' }, { label: 'Window title bar', detail: 'Under Settings → Appearance → Chrome, turn off Window title bar to remove the main window title and controls on macOS, Windows, and Linux, including tiling desktops such as Hyprland. The sidebar and editor stay visible, and the change applies to open vault windows without a restart. Drag blank space in the tab strip, the sidebar header or the note list header to move the window, or use your window manager shortcuts. Press Cmd/Ctrl+, to restore the setting. The preference is saved as [appearance].show_window_title_bar in config.toml. In the browser app the same switch is called Title bar: it hides the bar with the open note’s title, in that browser.' }, + { label: 'Status bar', detail: 'Under Settings → Appearance → Chrome, Status bar shows or hides the bar along the bottom of the window: the ZenNotes Cloud status (Synced, Sync now, Set up) and, on desktop, the open note’s backlinks, words, characters, reading time and cursor position. It is on by default. Turned off, the bar stays hidden except while Cloud needs you: a sync that stopped or failed, files or vault settings waiting for review, a Cloud vault that went away, or a device that was signed out. Then it comes back until that is dealt with, so nothing that needs an answer goes silent. On Android the bar is the Cloud row at the bottom of the screen, and turning it off gives that row back to the note; the iPhone already shows that row only while Cloud needs you. Toggle it from the command palette (Toggle Status Bar) or, in Vim mode, with `:set nostatusbar` and `:set statusbar`. The preference is saved as [appearance].show_status_bar in config.toml.' }, { label: 'Keep running in the tray', detail: 'Off by default. Under Settings → Appearance → Window, turn it on and closing the last window hides it instead of ending the session: a ZenNotes icon in the system tray (the menu bar on macOS) stays, Cloud sync and the Quick capture hotkey keep working, and the icon\'s tooltip and menu show the sync state while a Cloud vault is linked. Click the icon, or choose Open ZenNotes in its menu, to bring the window back; on macOS the Dock icon does too. Quit from the icon\'s menu, with Quit ZenNotes in the command palette, or with Cmd+Q on macOS. On a desktop with no tray host the icon does not appear: open ZenNotes again and the window comes back. `Space b` (Hide Window to Tray in the palette) hides the window on purpose, which a tiling window manager cannot do with minimise. The setting belongs to this computer and is not part of config.toml. While ZenNotes runs it also checks for updates every six hours, not only at launch.' }, { label: 'Dark sidebar', detail: 'Tint the sidebar slightly darker than the canvas so the chrome reads as a distinct surface.' }, { label: 'Sidebar arrows', detail: 'Show or hide disclosure arrows for collapsible sidebar folders and sections.' }, diff --git a/packages/app-core/src/lib/status-bar-visibility.test.ts b/packages/app-core/src/lib/status-bar-visibility.test.ts new file mode 100644 index 000000000..507364436 --- /dev/null +++ b/packages/app-core/src/lib/status-bar-visibility.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { statusBarVisible } from './status-bar-visibility' + +const base = { + zenMode: false, + hasNote: true, + cloudShown: true, + showStatusBar: true, + cloudNeedsUser: false +} + +describe('statusBarVisible', () => { + it('shows by default over a note or a Cloud status', () => { + expect(statusBarVisible(base)).toBe(true) + expect(statusBarVisible({ ...base, hasNote: false })).toBe(true) + expect(statusBarVisible({ ...base, cloudShown: false })).toBe(true) + }) + + it('has nothing to say with no note and no Cloud', () => { + expect(statusBarVisible({ ...base, hasNote: false, cloudShown: false })).toBe(false) + }) + + it('turned off, it stays away until Cloud needs the user', () => { + expect(statusBarVisible({ ...base, showStatusBar: false })).toBe(false) + expect(statusBarVisible({ ...base, showStatusBar: false, cloudNeedsUser: true })).toBe(true) + expect( + statusBarVisible({ ...base, showStatusBar: false, cloudNeedsUser: true, hasNote: false }) + ).toBe(true) + }) + + it('Zen mode clears it whatever the setting or Cloud says', () => { + expect(statusBarVisible({ ...base, zenMode: true })).toBe(false) + expect(statusBarVisible({ ...base, zenMode: true, cloudNeedsUser: true })).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/status-bar-visibility.ts b/packages/app-core/src/lib/status-bar-visibility.ts new file mode 100644 index 000000000..e71c0be74 --- /dev/null +++ b/packages/app-core/src/lib/status-bar-visibility.ts @@ -0,0 +1,26 @@ +/** + * Whether the status bar along the bottom of the workspace is drawn. + * + * Zen mode always clears it. Otherwise it needs something to say: a note's + * stats, or a Cloud status. Turned off (Settings, Appearance, Chrome, Status + * bar; `show_status_bar` in config.toml), it stays away except while Cloud + * needs the person (`cloudSyncNeedsUser`): a failed or stopped run, files or + * the settings question waiting, a vault that went away, a device signed out. + * Those come back until they are dealt with, so turning the bar off never + * hides the one place that says sync has stopped. + * + * Phones (Piotr, on Discord) mostly see the Cloud half of the bar, and the + * bar not being drawn is what gives its row back: the shells size their Cloud + * row and lift the circle from the bar's `data-cloud-sync-*` hooks. + */ +export function statusBarVisible(input: { + zenMode: boolean + hasNote: boolean + cloudShown: boolean + showStatusBar: boolean + cloudNeedsUser: boolean +}): boolean { + if (input.zenMode) return false + if (!input.hasNote && !input.cloudShown) return false + return input.showStatusBar || input.cloudNeedsUser +} diff --git a/packages/app-core/src/lib/vim-status-bar-option.test.ts b/packages/app-core/src/lib/vim-status-bar-option.test.ts new file mode 100644 index 000000000..0b40d6990 --- /dev/null +++ b/packages/app-core/src/lib/vim-status-bar-option.test.ts @@ -0,0 +1,28 @@ +// @vitest-environment jsdom + +import { Vim } from '@replit/codemirror-vim' +import { beforeEach, describe, expect, it } from 'vitest' +import { useStore } from '../store' +import { defineStatusBarOption } from './vim-status-bar-option' + +beforeEach(() => { + useStore.setState({ showStatusBar: true }) +}) + +describe(':set statusbar', () => { + it('leaves a saved choice alone when the option is defined at launch', () => { + useStore.setState({ showStatusBar: false }) + defineStatusBarOption() + expect(useStore.getState().showStatusBar).toBe(false) + }) + + it('sets, clears and reports the setting', () => { + defineStatusBarOption() + Vim.setOption('statusbar', false) + expect(useStore.getState().showStatusBar).toBe(false) + expect(Vim.getOption('statusbar')).toBe(false) + Vim.setOption('stb', true) + expect(useStore.getState().showStatusBar).toBe(true) + expect(Vim.getOption('statusbar')).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/vim-status-bar-option.ts b/packages/app-core/src/lib/vim-status-bar-option.ts new file mode 100644 index 000000000..0cfae0815 --- /dev/null +++ b/packages/app-core/src/lib/vim-status-bar-option.ts @@ -0,0 +1,20 @@ +import { Vim } from '@replit/codemirror-vim' +import { useStore } from '../store' + +/** + * `:set statusbar` / `:set nostatusbar` / `:set statusbar?` (alias `stb`), the + * ex twin of Settings, Appearance, Chrome, Status bar, under the `:set` a Vim + * user reaches for. The palette command (`:view_status_bar`) toggles it too. + * + * No default value: codemirror-vim applies a truthy default through the + * callback the moment the option is defined, which turned the bar back on at + * every launch over a saved `show_status_bar = false`. + */ +export function defineStatusBarOption(): void { + Vim.defineOption('statusbar', undefined, 'boolean', ['stb'], (value?: boolean) => { + const state = useStore.getState() + if (value === undefined) return state.showStatusBar + if (state.showStatusBar !== !!value) state.setShowStatusBar(!!value) + return undefined + }) +} diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index c7c3171e9..d489c26ac 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -698,6 +698,9 @@ interface Prefs { /** Tint the sidebar surface a step darker than the main canvas. */ darkSidebar: boolean showWindowTitleBar: boolean + /** Show the bar along the bottom of the workspace (Cloud sync status and the + * note's stats). Off, it still shows while Cloud needs the user. */ + showStatusBar: boolean /** Show disclosure arrows for collapsible sidebar folders and sections. */ showSidebarChevrons: boolean /** Keys of collapsed folders in the sidebar tree. */ @@ -1193,6 +1196,7 @@ export const DEFAULT_PREFS: Prefs = { unifiedSidebar: true, darkSidebar: true, showWindowTitleBar: true, + showStatusBar: true, showSidebarChevrons: true, collapsedFolders: [], pinnedRefPath: null, @@ -1475,6 +1479,8 @@ function normalizePrefs(p: Partial): Prefs { typeof p.showWindowTitleBar === 'boolean' ? p.showWindowTitleBar : DEFAULT_PREFS.showWindowTitleBar, + showStatusBar: + typeof p.showStatusBar === 'boolean' ? p.showStatusBar : DEFAULT_PREFS.showStatusBar, darkSidebar: typeof p.darkSidebar === 'boolean' ? p.darkSidebar @@ -2521,6 +2527,7 @@ function collectPrefs(s: { unifiedSidebar: boolean darkSidebar: boolean showWindowTitleBar: boolean + showStatusBar: boolean showSidebarChevrons: boolean collapsedFolders: string[] pinnedRefPath: string | null @@ -2639,6 +2646,7 @@ function collectPrefs(s: { unifiedSidebar: s.unifiedSidebar, darkSidebar: s.darkSidebar, showWindowTitleBar: s.showWindowTitleBar, + showStatusBar: s.showStatusBar, showSidebarChevrons: s.showSidebarChevrons, collapsedFolders: s.collapsedFolders, pinnedRefPath: s.pinnedRefPath, @@ -3219,6 +3227,7 @@ interface Store { unifiedSidebar: boolean darkSidebar: boolean showWindowTitleBar: boolean + showStatusBar: boolean showSidebarChevrons: boolean /** Manual (drag-to-reorder) note order for `noteSortOrder: 'manual'`, keyed * by parent directory → ordered note paths. Persisted per vault (#224). */ @@ -3871,6 +3880,7 @@ interface Store { setUnifiedSidebar: (on: boolean) => void setDarkSidebar: (on: boolean) => void setShowWindowTitleBar: (on: boolean) => void + setShowStatusBar: (on: boolean) => void setShowSidebarChevrons: (on: boolean) => void toggleCollapseFolder: (key: string) => void setCollapsedFolders: (keys: string[]) => void @@ -6141,6 +6151,7 @@ export const useStore = create((set, get) => { unifiedSidebar: loadPrefs().unifiedSidebar, darkSidebar: loadPrefs().darkSidebar, showWindowTitleBar: loadPrefs().showWindowTitleBar, + showStatusBar: loadPrefs().showStatusBar, showSidebarChevrons: loadPrefs().showSidebarChevrons, collapsedFolders: DEFAULT_PREFS.collapsedFolders, pinnedRefPath: loadPrefs().pinnedRefPath, @@ -9772,6 +9783,10 @@ export const useStore = create((set, get) => { set({ showWindowTitleBar: on }) savePrefs(collectPrefs(get())) }, + setShowStatusBar: (on) => { + set({ showStatusBar: on }) + savePrefs(collectPrefs(get())) + }, setDarkSidebar: (on) => { set({ darkSidebar: on }) savePrefs(collectPrefs(get())) diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts index 01845a0bd..e73ee1379 100644 --- a/packages/bridge-contract/src/app-config.ts +++ b/packages/bridge-contract/src/app-config.ts @@ -73,6 +73,7 @@ export const PORTABLE_PREF_KEYS = [ 'themeTweaks', 'darkSidebar', 'showWindowTitleBar', + 'showStatusBar', 'showSidebarChevrons', 'contentAlign', 'noteCoverPlacement', diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index c7963a24a..c61502451 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -142,6 +142,7 @@ export const PORTABLE_DEFAULTS: Record = { themeTweaks: {}, darkSidebar: true, showWindowTitleBar: true, + showStatusBar: true, showSidebarChevrons: true, contentAlign: 'center', noteCoverPlacement: 'scroll', From ecb0445ccf38e61e7a37bebf47abeec9e72f17f3 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 28 Oct 2026 09:44:26 -0500 Subject: [PATCH 6/6] Fix(palette): a toggle from the command palette leaves a phone's keyboard down Found while verifying 42865ca7: on a phone, Toggle Status Bar from the command palette raised the keyboard. Every command that lands on the editor is followed by focusEditorNormalMode, so any setting or view toggle run from the palette (Light Sidebar, Line Numbers, Word Wrap, Live Preview, a theme) put the soft keyboard over half the note, with nothing to type. Two commands also focused the editor themselves: the four fold commands (runFoldCommand) and Copy Link Under Cursor. The palette now asks where the keyboard should go (lib/command-landing.ts), with the editor host's `openNotesToRead`, the same question the Search picks and palette closes ask. The rule defaults to leaving the focus where it was before the palette opened, and names the commands that should give the editor the keyboard, so a toggle added later cannot raise a phone's keyboard by being forgotten. On desktop every answer focuses the editor, exactly as before. On a phone, after a command run from the palette: - read, the note is left to read, keyboard down (e443b86f, unchanged): nav.back, nav.forward, nav.toggle-recent, tab.reopen, tab.next, tab.previous, tab.close, and note.daily.today, note.weekly.thisWeek, note.monthly.thisMonth and note.daily.rollover when that note already exists. - edit, the editor takes the keyboard, because typing or the focus is the point: note.new.quick, note.new.inbox, note.new.here, task.new, task.new.folder, database.new, drawing.new, embed.drawing.new; the four periodic-note commands when they create the note; template.create and template.insert; note.format (Format Markdown), editor.reflow-paragraph and editor.add-comment; nav.focus.editor and ref.focus; split.right, split.down and the four pane.focus commands. - stay, the focus goes back where it was: everything else. That covers every View, UI and App command (sidebar, status bar, line numbers, sort and group, panels, Tasks, Tags, Atlas, zoom, themes, Settings); the Editor toggles (word wrap, live preview, completed-task style, which-key hints, tabs, keep panels, undo history, auto reveal, quick note dates, PDF embeds); task state, folds, copies and exports; archive, trash, rename, favorite and cover; vault, Cloud, CLI and workflow commands. Enable and Disable Vim Mode stay too: the mode applies the next time the editor has the focus, and on a phone that is a tap into the text, with a hardware keyboard or without. The fold commands and Copy Link Under Cursor no longer focus the editor themselves on such a host. What it deliberately does not do: change desktop, the web app, or the commands a phone's own menu runs through runAppCommand (those never handed the focus over). Tests: commandLanding (an existing note is read even for a writing id, the edit list takes the keyboard, toggles and an unknown future command stay); every id on the edit list is a real command; the palette hands a toggle back through the close hand-off and still focuses for a writing command. app-core 3,641, shared-domain 2,164 and desktop 1,198 pass; typecheck clean (8/8). In the built app over CDP with isolated stores, on 2.78.0 and on this build, Light Sidebar, Line Numbers: Relative, Disable Word Wrap, Completed Tasks: Strikethrough, Fold All Headings and Lists, Disable Live Preview and Format Markdown each leave the editor focused on both (Toggle Status Bar too, on this build). --- packages/app-core/README.md | 9 +-- .../src/components/CommandPalette.tsx | 15 +++-- .../components/PaletteFocusHandoff.test.ts | 19 +++++- packages/app-core/src/editor.ts | 16 ++--- .../app-core/src/lib/command-landing.test.ts | 35 +++++++++++ packages/app-core/src/lib/command-landing.ts | 61 +++++++++++++++++++ packages/app-core/src/lib/commands.test.ts | 9 +++ packages/app-core/src/lib/commands.ts | 7 ++- 8 files changed, 150 insertions(+), 21 deletions(-) create mode 100644 packages/app-core/src/lib/command-landing.test.ts create mode 100644 packages/app-core/src/lib/command-landing.ts diff --git a/packages/app-core/README.md b/packages/app-core/README.md index 3f3e40dd2..4e9fa55ed 100644 --- a/packages/app-core/README.md +++ b/packages/app-core/README.md @@ -348,10 +348,11 @@ each time a command-palette command that only goes to a note already there finishes (Go Back, Go Forward, Switch to Previous Note, the tab commands, and today's daily, weekly or monthly note when it exists). When it returns true the note opens without taking the keyboard focus, so a soft keyboard stays down -until the user taps into the text. Closing any of those palettes, or the -template or drawing palette, without a pick leaves the focus where it was. -Commands that create a note to write in keep focusing the editor. Absent, -false or throwing, the editor takes the focus as on desktop. +until the user taps into the text. Commands whose point is typing (a new note, +Format Markdown, Reflow Paragraph) keep focusing the editor; every other +command (a toggle, a view, a theme), and closing any of those palettes or the +template or drawing palette without a pick, leaves the focus where it was. +Absent, false or throwing, the editor takes the focus as on desktop. Live preview reveals a line's Markdown source (a heading's `#`, `**`, `[[ ]]`) only on lines the user is editing: once an editor has taken the focus since its diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index daa250d25..066393582 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -6,6 +6,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react' import { useStore } from '../store' import { buildCommands, type Command } from '../lib/commands' +import { commandLanding } from '../lib/command-landing' import { loadRecentCommandIds, recordCommandUse, @@ -309,7 +310,7 @@ export function CommandPalette(): JSX.Element { } setOpen(false) // Asked before the run: afterwards today's note exists either way. - const toRead = cmd.opensToRead?.() === true + const landing = commandLanding(cmd) try { await cmd.run() // A command that opens a note (or otherwise lands on the editor) should @@ -322,8 +323,11 @@ export function CommandPalette(): JSX.Element { // 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. - // A command that only went to a note that was already there hands over - // like a search pick: on a phone the note is left to read. + // 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 + // else (a toggle, a view) leaves the focus where it was. On desktop all + // three focus the editor. if ( shouldRefocusEditorAfterCommand( useStore.getState(), @@ -332,8 +336,9 @@ export function CommandPalette(): JSX.Element { getPublishNoteRequest() !== null ) ) { - if (toRead) focusEditorAfterReadOpen() - else focusEditorNormalMode() + if (landing === 'read') focusEditorAfterReadOpen() + else if (landing === 'edit') focusEditorNormalMode() + else focusEditorAfterPaletteClose() } } catch (err) { console.error('command failed', cmd.id, err) diff --git a/packages/app-core/src/components/PaletteFocusHandoff.test.ts b/packages/app-core/src/components/PaletteFocusHandoff.test.ts index c762cf51a..8aedc3cf4 100644 --- a/packages/app-core/src/components/PaletteFocusHandoff.test.ts +++ b/packages/app-core/src/components/PaletteFocusHandoff.test.ts @@ -19,10 +19,11 @@ vi.mock('../lib/editor-focus', () => ({ focusEditorAfterPaletteClose })) -// The command palette lists whatever buildCommands gives it: two stand-ins, -// one that goes to a note already there and one that writes. +// The command palette lists whatever buildCommands gives it: stand-ins for a +// command that goes to a note already there, one that writes, and a toggle. const goBack = vi.hoisted(() => vi.fn()) const newNote = vi.hoisted(() => vi.fn()) +const toggle = vi.hoisted(() => vi.fn()) vi.mock('../lib/commands', () => ({ buildCommands: () => [ { @@ -32,7 +33,8 @@ vi.mock('../lib/commands', () => ({ run: goBack, opensToRead: () => true }, - { id: 'test.write', title: 'Write Something', category: 'Note', run: newNote } + { id: 'note.new.inbox', title: 'Write Something', category: 'Note', run: newNote }, + { id: 'view.status-bar', title: 'Toggle Something', category: 'View', run: toggle } ] })) @@ -62,6 +64,7 @@ beforeEach(() => { } goBack.mockClear() newNote.mockClear() + toggle.mockClear() // The refocus after a command only happens when the editor was the panel // and nothing else is open over it. useStore.setState({ @@ -127,6 +130,16 @@ describe('palette hand-offs', () => { expect(focusEditorAfterReadOpen).not.toHaveBeenCalled() }) + it('a toggle hands back like a close: focused on desktop, left where it was on a phone', async () => { + render(CommandPalette) + type('Toggle Something') + await key('Enter') + expect(toggle).toHaveBeenCalledTimes(1) + expect(focusEditorAfterPaletteClose).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).not.toHaveBeenCalled() + expect(focusEditorAfterReadOpen).not.toHaveBeenCalled() + }) + it('the template palette closes through the palette close hand-off', async () => { useStore.setState({ templatePaletteOpen: true }) render(TemplatePalette) diff --git a/packages/app-core/src/editor.ts b/packages/app-core/src/editor.ts index a7b7e91fd..cc9bec92b 100644 --- a/packages/app-core/src/editor.ts +++ b/packages/app-core/src/editor.ts @@ -37,13 +37,15 @@ export interface EditorHostOptions { /** * Asked as a pick in one of core's searches (the note search, Search in all * notes, the outline, the buffer switcher) lands, and as a command-palette - * command that only goes to a note already there (Go Back, Reopen Closed - * Tab, today's daily note when it exists) finishes: true opens the note to - * be read, without handing the editor the keyboard focus, so a soft - * keyboard stays down until the user taps into the text. Closing one of - * those palettes, or the template or drawing palette, without a pick then - * leaves the focus where it was too. Absent or false, the editor takes the - * focus, as on desktop. + * command finishes. True opens a note to be read without handing the editor + * the keyboard focus, so a soft keyboard stays down until the user taps + * into the text: a search pick, or a command that only goes to a note + * already there (Go Back, Reopen Closed Tab, today's daily note when it + * exists). A command whose point is typing (a new note, Format Markdown) + * still focuses the editor; any other command (a toggle, a view, a theme), + * and closing one of those palettes or the template or drawing palette + * without a pick, leaves the focus where it was. Absent or false, the + * editor takes the focus after all of them, as on desktop. */ readonly openNotesToRead?: () => boolean } diff --git a/packages/app-core/src/lib/command-landing.test.ts b/packages/app-core/src/lib/command-landing.test.ts new file mode 100644 index 000000000..95582b2fa --- /dev/null +++ b/packages/app-core/src/lib/command-landing.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { commandLanding } from './command-landing' + +describe('commandLanding (where a phone keyboard goes after a palette command)', () => { + it('reads a note the command only put on screen, before anything else', () => { + expect(commandLanding({ id: 'nav.back', opensToRead: () => true })).toBe('read') + // An existing daily note is read even though the id is a writing one. + expect(commandLanding({ id: 'note.daily.today', opensToRead: () => true })).toBe('read') + }) + + it('gives the editor the keyboard where typing or the focus is the point', () => { + expect(commandLanding({ id: 'note.daily.today', opensToRead: () => false })).toBe('edit') + const writing = ['note.new.inbox', 'task.new', 'note.format', 'editor.reflow-paragraph'] + for (const id of [...writing, 'nav.focus.editor', 'ref.focus']) { + expect(commandLanding({ id })).toBe('edit') + } + }) + + it('leaves the focus where it was for everything else, a new toggle included', () => { + const toggles = [ + 'view.status-bar', + 'view.dark-sidebar', + 'view.line-numbers.relative', + 'editor.word-wrap.toggle', + 'editor.vim.toggle', + 'editor.live-preview.toggle', + 'fold.all', + 'task.toggle-checkbox', + 'note.copy-wikilink', + 'ui.themes', + 'some.future-toggle' + ] + for (const id of toggles) expect(commandLanding({ id })).toBe('stay') + }) +}) diff --git a/packages/app-core/src/lib/command-landing.ts b/packages/app-core/src/lib/command-landing.ts new file mode 100644 index 000000000..d9478f35a --- /dev/null +++ b/packages/app-core/src/lib/command-landing.ts @@ -0,0 +1,61 @@ +/** + * Where the keyboard goes after a command-palette command, on a host that + * opens notes to read (a phone; the editor host's `openNotesToRead`). + * + * - `read`: the command only put a note that already existed on screen + * (`Command.opensToRead`): it is left to read, keyboard down. + * - `edit`: typing, or the editor's own focus, is the point of the command: + * the editor takes the focus and the keyboard comes up, as on desktop. + * - `stay`: everything else (a toggle, a view, a sort, a theme, a panel, a + * fold, task state, copy and export): the focus goes back where it was + * before the palette opened, so a keyboard that was down stays down. + * + * The rule defaults to `stay` and names the `edit` commands, so a new toggle + * cannot raise a phone's keyboard by being forgotten. Desktop ignores all of + * it: every command that lands on the editor focuses it, as it always has + * (the three hand-offs in lib/editor-focus.ts all focus the editor there). + */ +export type CommandLanding = 'read' | 'edit' | 'stay' + +/** The commands whose point is typing or the editor's focus. */ +export const EDITOR_FOCUS_COMMAND_IDS: ReadonlySet = new Set([ + // A new note, task, database or drawing is made to be written in. + 'note.new.quick', + 'note.new.inbox', + 'note.new.here', + 'task.new', + 'task.new.folder', + 'database.new', + 'drawing.new', + 'embed.drawing.new', + // Daily, weekly and monthly notes the command creates (one that already + // exists answers `opensToRead` first and is read). + 'note.daily.today', + 'note.daily.rollover', + 'note.weekly.thisWeek', + 'note.monthly.thisMonth', + // Writing into the open note: templates, formatting, a comment. + 'template.create', + 'template.insert', + 'note.format', + 'editor.reflow-paragraph', + 'editor.add-comment', + // Commands whose point is where the keyboard focus goes. + 'nav.focus.editor', + 'ref.focus', + 'split.right', + 'split.down', + 'pane.focus.left', + 'pane.focus.down', + 'pane.focus.up', + 'pane.focus.right' +]) + +/** Asked just before the command runs (afterwards a daily note exists either way). */ +export function commandLanding(command: { + id: string + opensToRead?: () => boolean +}): CommandLanding { + if (command.opensToRead?.() === true) return 'read' + return EDITOR_FOCUS_COMMAND_IDS.has(command.id) ? 'edit' : 'stay' +} diff --git a/packages/app-core/src/lib/commands.test.ts b/packages/app-core/src/lib/commands.test.ts index 7b909846b..137bb6843 100644 --- a/packages/app-core/src/lib/commands.test.ts +++ b/packages/app-core/src/lib/commands.test.ts @@ -608,3 +608,12 @@ describe('Toggle Status Bar (Piotr on Discord)', () => { expect(useStore.getState().showStatusBar).toBe(true) }) }) + +describe('commands that give a phone editor the keyboard', () => { + it('names only commands that exist', async () => { + const { buildCommands } = await loadCommands() + const { EDITOR_FOCUS_COMMAND_IDS } = await import('./command-landing') + const ids = new Set(buildCommands({ includeUnavailable: true }).map((c) => c.id)) + for (const id of EDITOR_FOCUS_COMMAND_IDS) expect(ids.has(id), id).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 5f48c8ade..7980ec4af 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -18,6 +18,7 @@ import { openLinkPopoverAtCursor } from './link-popover-open' import { confirmApp } from './confirm-requests' import { requestSettingsTarget } from './settings-navigation' import { hideWindowToTray, useTrayStore } from './tray-runtime' +import { hostOpensNotesToRead } from './editor-host' import { promptApp } from './prompt-requests' import { captureNavigationContext } from './navigation-context' import { buildMoveNotePrompt, moveNoteVocabulary, parseMoveNoteTarget } from './move-note' @@ -93,7 +94,8 @@ async function runFoldCommand(command: FoldCommand): Promise { const folding = await import('./cm-heading-fold') folding[command](view) } - view.focus() + // A fold is a view of the note, not typing: a phone keeps its keyboard down. + if (!hostOpensNotesToRead()) view.focus() } export interface Command { @@ -1188,7 +1190,8 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma const view = getState().editorViewRef if (!view) return copyLinkAtCursor(view) - view.focus() + // Copying is not typing: a phone keeps its keyboard down. + if (!hostOpensNotesToRead()) view.focus() } }, {