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/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/README.md b/packages/app-core/README.md index 7d471b59a..4e9fa55ed 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,23 @@ 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), 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. 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 +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/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/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 9f9ad5fa4..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 { 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,39 +967,41 @@ function App(): JSX.Element { void state.reopenLastClosedTab() return } + // 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) - focusEditorNormalMode() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.vaultTextSearchOpen) { setVaultTextSearchOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.commandPaletteOpen) { setCommandPaletteOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() return } if (e.key === 'Escape' && state.bufferPaletteOpen) { setBufferPaletteOpen(false) - focusEditorNormalMode() + 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) - focusEditorNormalMode() + focusEditorAfterPaletteClose() return } // ⌘1 — toggle sidebar diff --git a/packages/app-core/src/components/BufferPalette.tsx b/packages/app-core/src/components/BufferPalette.tsx index 6637a2987..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 { focusEditorNormalMode } 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) } - focusEditorNormalMode() + focusEditorAfterReadOpen() } const close = (): void => { setOpen(false) - focusEditorNormalMode() + 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..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, @@ -27,7 +28,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 +285,7 @@ export function CommandPalette(): JSX.Element { revertTheme() } setOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() } /* -------- Actions -------- */ @@ -304,6 +309,8 @@ export function CommandPalette(): JSX.Element { return } setOpen(false) + // Asked before the run: afterwards today's note exists either way. + const landing = commandLanding(cmd) try { await cmd.run() // A command that opens a note (or otherwise lands on the editor) should @@ -316,6 +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. + // 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(), @@ -323,8 +335,11 @@ export function CommandPalette(): JSX.Element { useCloudSyncStatusStore.getState().settingsConflictPromptOpen || getPublishNoteRequest() !== null ) - ) - 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/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/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 216f1ac8e..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, @@ -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' @@ -1773,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]) @@ -2101,6 +2104,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 +2540,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/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/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/OutlinePalette.tsx b/packages/app-core/src/components/OutlinePalette.tsx index 6f14be253..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 { focusEditorNormalMode } 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 } }) ) - focusEditorNormalMode() + focusEditorAfterReadOpen() } const close = (): void => { setOpen(false) - focusEditorNormalMode() + 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..8aedc3cf4 --- /dev/null +++ b/packages/app-core/src/components/PaletteFocusHandoff.test.ts @@ -0,0 +1,160 @@ +// @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: 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: () => [ + { + id: 'test.read', + title: 'Read Something', + category: 'Go', + run: goBack, + opensToRead: () => true + }, + { id: 'note.new.inbox', title: 'Write Something', category: 'Note', run: newNote }, + { id: 'view.status-bar', title: 'Toggle Something', category: 'View', run: toggle } + ] +})) + +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() + toggle.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('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) + 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/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/components/SearchPalette.test.ts b/packages/app-core/src/components/SearchPalette.test.ts index f3772ffb9..b0c2de49c 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 focusEditorAfterReadOpen = vi.hoisted(() => vi.fn()) +const focusEditorAfterPaletteClose = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ + focusEditorNormalMode, + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose +})) 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() + focusEditorAfterReadOpen.mockClear() + focusEditorAfterPaletteClose.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(focusEditorAfterReadOpen).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(focusEditorAfterPaletteClose).toHaveBeenCalledTimes(1) + expect(focusEditorNormalMode).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 1f6d1cd42..1ac93ad74 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 { + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose, + 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() + focusEditorAfterReadOpen() } // 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() + focusEditorAfterReadOpen() return } await openTagViewFor(tagTokens, freeText) @@ -174,7 +178,7 @@ export function SearchPalette(): JSX.Element { const close = (): void => { setSearchOpen(false) - focusEditorNormalMode() + 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 51d195100..e9ab44b7f 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 focusEditorAfterReadOpen = vi.hoisted(() => vi.fn()) +const focusEditorAfterPaletteClose = vi.hoisted(() => vi.fn()) +vi.mock('../lib/editor-focus', () => ({ + focusEditorNormalMode, + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose +})) 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() + focusEditorAfterReadOpen.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(focusEditorAfterReadOpen).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/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/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 0f634b465..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 { focusEditorNormalMode } 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' }) - focusEditorNormalMode() + focusEditorAfterReadOpen() } const close = (): void => { setOpen(false) - focusEditorNormalMode() + focusEditorAfterPaletteClose() } return ( diff --git a/packages/app-core/src/editor.ts b/packages/app-core/src/editor.ts index 4eb07cfe8..cc9bec92b 100644 --- a/packages/app-core/src/editor.ts +++ b/packages/app-core/src/editor.ts @@ -34,6 +34,20 @@ 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, and as a command-palette + * 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 } export interface EditorHostRegistration { 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/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 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 1a8970384..137bb6843 100644 --- a/packages/app-core/src/lib/commands.test.ts +++ b/packages/app-core/src/lib/commands.test.ts @@ -502,3 +502,118 @@ 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 }) + }) +}) + +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) + }) +}) + +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 1736d39b5..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' @@ -56,7 +57,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' @@ -87,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 { @@ -105,6 +113,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 +137,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 +215,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 +225,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 +239,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 +249,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 +710,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 +720,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 +733,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 +746,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) }, { @@ -1157,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() } }, { @@ -1178,6 +1212,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Tabs', shortcut: shortcut('vim.historyBack'), keywords: 'history previous', + opensToRead: () => true, run: () => getState().jumpToPreviousNote() }, { @@ -1186,6 +1221,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Tabs', shortcut: shortcut('vim.historyForward'), keywords: 'history next', + opensToRead: () => true, run: () => getState().jumpToNextNote() }, { @@ -1194,6 +1230,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() } ) @@ -1537,6 +1574,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/editor-focus.test.ts b/packages/app-core/src/lib/editor-focus.test.ts index 5b76808cc..0aab7b6ee 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 { + focusEditorAfterReadOpen, + focusEditorAfterPaletteClose, + focusEditorNormalMode +} from './editor-focus' +import { installNoteEditorHost } from './editor-host' +import { revealedSelectionRanges, sourceRevealExtension } from './cm-source-reveal' describe('focusEditorNormalMode', () => { beforeEach(() => { @@ -62,3 +70,96 @@ 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 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') + } + }) + focusEditorAfterReadOpen() + await vi.advanceTimersByTimeAsync(0) + expect(view!.hasFocus).toBe(true) + }) + + 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 }) + focusEditorAfterReadOpen() + 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([]) + focusEditorAfterReadOpen() + await vi.advanceTimersByTimeAsync(50) + expect(view!.hasFocus).toBe(false) + expect(revealedSelectionRanges(view!.state)).toEqual([]) + }) + + 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 }) + 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 935e76be4..0a45f0911 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,48 @@ export function focusEditorNormalMode( requestAnimationFrame(() => run(attempts)) } + +/** + * 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 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 focusEditorAfterReadOpen( + options: { attempts?: number; delayMs?: number } = {} +): void { + if (!hostOpensNotesToRead()) { + focusEditorNormalMode(options) + return + } + requestAnimationFrame(() => { + const view = useStore.getState().editorViewRef + if (!view) return + if (view.hasFocus) view.contentDOM.blur() + view.dispatch({ effects: readSourceRevealEffect() }) + }) +} + +/** + * 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 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 focusEditorAfterPaletteClose(): 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 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.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..d489c26ac 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. */ @@ -684,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. */ @@ -1179,6 +1196,7 @@ export const DEFAULT_PREFS: Prefs = { unifiedSidebar: true, darkSidebar: true, showWindowTitleBar: true, + showStatusBar: true, showSidebarChevrons: true, collapsedFolders: [], pinnedRefPath: null, @@ -1461,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 @@ -2507,6 +2527,7 @@ function collectPrefs(s: { unifiedSidebar: boolean darkSidebar: boolean showWindowTitleBar: boolean + showStatusBar: boolean showSidebarChevrons: boolean collapsedFolders: string[] pinnedRefPath: string | null @@ -2625,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, @@ -3205,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). */ @@ -3857,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 @@ -3882,9 +3906,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 +3962,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. */ @@ -6127,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, @@ -9758,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())) @@ -9926,7 +9955,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 +9976,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 +9986,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 +10171,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 +10189,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 +10214,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) => 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/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/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" 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',