From aa07f7c2bb0a8f1547d8bdaeb83ba4197a6dfce3 Mon Sep 17 00:00:00 2001 From: gua Date: Mon, 26 Oct 2026 21:11:27 +0800 Subject: [PATCH 1/2] fix(deps): upgrade transitive moment to 2.31.0 --- CHANGELOG.md | 6 ++++++ CONTRIBUTING.md | 2 ++ package-lock.json | 6 +++--- package.json | 5 +++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a9c51..3a3208b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Notable changes to Hover Bar, grouped by release. +## Unreleased + +### Fixed + +- Upgrade the Obsidian development SDK's transitive Moment dependency to 2.31.0 to fix CVE-2026-17495. + ## 0.1.2 — 2026-10-26 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69f37a0..809adac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,8 @@ For a production build, run `npm ci` followed by `npm run build`. These develope Obsidian API types are pinned to the minimum supported app version, currently 1.8.7, so type checking catches newer API usage. Verify editor changes in the minimum supported Obsidian app as well as running the automated checks. +The scoped npm override for `obsidian@1.8.7` replaces its pinned Moment dependency with 2.31.0 to fix [CVE-2026-17495](https://github.com/moment/moment/security/advisories/GHSA-4p3w-j4w9-5jqw). Keep this override while using that SDK; reassess it when upgrading the SDK. Moment is a development dependency and is not bundled into the plugin. + ## Report a problem Include the Obsidian and plugin versions, OS, editing mode, theme, a small synthetic Markdown example, reproduction steps, and expected versus actual results. Explain whether the problem reproduces with other community plugins disabled. Never attach private notes, vaults, credentials, or unredacted logs. diff --git a/package-lock.json b/package-lock.json index 9b12ab5..af95a34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2462,9 +2462,9 @@ } }, "node_modules/moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.31.0.tgz", + "integrity": "sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 9239754..7205c37 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,11 @@ "dependencies": { "entities": "8.1.0" }, + "overrides": { + "obsidian@1.8.7": { + "moment": "2.31.0" + } + }, "devDependencies": { "@codemirror/commands": "6.11.1", "@codemirror/language": "6.13.1", From d79d742dc96aadad59dcbf546b5eaa6116bb6e17 Mon Sep 17 00:00:00 2001 From: gua Date: Wed, 28 Oct 2026 15:34:05 +0800 Subject: [PATCH 2/2] feat(toolbar)!: improve selection reliability for 0.1.3 - add structured selection diagnostics and consistent action availability - enforce source and whole-line length limits before edits - harden session cancellation, focus restoration, and IME handling - expand regression coverage and update release metadata --- CHANGELOG.md | 18 ++- README.md | 14 +- README.zh-CN.md | 16 ++- manifest.json | 4 +- package-lock.json | 4 +- package.json | 2 +- scripts/check-release.mjs | 2 +- src/editor/apply-edit.ts | 26 ++-- src/editor/color-preview.ts | 4 +- src/editor/context.ts | 52 ++++--- src/editor/controller.ts | 175 ++++++++++++----------- src/editor/feedback.ts | 20 +++ src/editor/selection-actions.ts | 17 +++ src/editor/toolbar-model.ts | 37 +++++ src/formatting/markdown.ts | 10 +- src/formatting/types.ts | 3 +- src/i18n/en.ts | 18 ++- src/i18n/zh-cn.ts | 17 ++- src/links/wikilink.ts | 21 ++- src/main.ts | 12 +- src/ui/note-picker.ts | 81 +++++++++++ src/ui/position.ts | 11 -- src/ui/toolbar-types.ts | 21 +++ src/ui/toolbar.ts | 241 ++++++++++---------------------- src/ui/touch.ts | 27 ---- styles.css | 28 ---- tests/controller.test.ts | 197 ++++++++++++++++---------- tests/editor.test.ts | 2 +- tests/formatting.test.ts | 10 +- tests/mobile-position.test.ts | 25 ---- tests/note-picker.test.ts | 106 ++++++++++++++ tests/preferences.test.ts | 7 +- tests/release.test.ts | 8 +- tests/selection-actions.test.ts | 93 ++++++++++++ tests/selection.test.ts | 61 ++++++++ tests/support/obsidian.ts | 1 - tests/support/selection.ts | 41 ++++++ tests/toolbar.test.ts | 12 +- tests/touch.test.ts | 46 ------ versions.json | 3 +- 40 files changed, 940 insertions(+), 553 deletions(-) create mode 100644 src/editor/feedback.ts create mode 100644 src/editor/selection-actions.ts create mode 100644 src/editor/toolbar-model.ts create mode 100644 src/ui/note-picker.ts create mode 100644 src/ui/toolbar-types.ts delete mode 100644 src/ui/touch.ts delete mode 100644 tests/mobile-position.test.ts create mode 100644 tests/note-picker.test.ts create mode 100644 tests/selection-actions.test.ts create mode 100644 tests/selection.test.ts create mode 100644 tests/support/selection.ts delete mode 100644 tests/touch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3208b..897e675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,26 @@ Notable changes to Hover Bar, grouped by release. -## Unreleased +## 0.1.3 — 2026-10-28 + +### Added + +- Actionable selection diagnostics for editor availability, editing preferences, read-only state, text composition, multiple selections, line breaks, protected syntax, and length limits. +- Consistent action availability across the toolbar, palettes, and menus, with keyboard access to unavailable actions and their explanations. + +### Changed + +- Restrict Hover Bar to desktop Obsidian and remove touch selection, the mobile bottom toolbar, and on-screen keyboard layout support. +- Update English and Chinese usage guides and preference text for desktop use. ### Fixed +- Reject formatting and links before writing when the resulting full line would exceed the source limit. +- Apply the same length checks to quick-link availability and execution. +- Distinguish link output length limits from invalid link text, targets, and formatting conflicts. +- Ignore measurements and note queries from closed sessions without consuming a newer session's focus request. +- Return focus after Escape and successful links without requiring the closed selection to remain editable. +- Preserve text composition in note search, including confirmation keys delivered after composition ends, so they cannot prematurely insert a link. - Upgrade the Obsidian development SDK's transitive Moment dependency to 2.31.0 to fix CVE-2026-17495. ## 0.1.2 — 2026-10-26 diff --git a/README.md b/README.md index 7366272..f7a7d4d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Hover Bar -A lightweight, offline selection toolbar for formatting text in Obsidian on desktop, phone, and tablet. +A lightweight, offline selection toolbar for formatting text in desktop Obsidian. [简体中文](README.zh-CN.md) @@ -15,7 +15,6 @@ The toolbar appears when you finish selecting text. Ordinary formatting stays in ## Features - A compact floating toolbar near your selection on desktop, with viewport-aware placement. -- A bottom toolbar on touch screens, with swipeable actions, larger buttons, and keyboard-aware panels. - Bold, italic, strikethrough, inline code, and standard Obsidian highlights. - Named text and background color palettes, with controls to remove colors. - Quick `[[internal links]]` and a picker for existing notes. @@ -23,17 +22,16 @@ The toolbar appears when you finish selecting text. Ordinary formatting stays in - Light and dark theme support and keyboard navigation. - Chinese text when Obsidian uses a Chinese language setting; English for every other language. - Controls to disable automatic display while keeping a command to open the toolbar. +- Specific feedback for read-only editors, disabled modes, multiple selections, line breaks, formatting conflicts, and length limits. ## How to use 1. Open a Markdown note in Live Preview or Source mode. -2. Select a plain-text phrase using your mouse or trackpad, or long-press and adjust the selection handles on a touch screen. +2. Select a plain-text phrase using your mouse or trackpad. 3. Click a formatting button, or open a color palette and choose a color. Click outside the toolbar or press Esc to close it. For keyboard access, use **Show selection toolbar** in the command palette. You can assign your own hotkey in Obsidian's hotkey settings. -On phones and tablets, the toolbar docks near the bottom of the editor, above the on-screen keyboard when it is open. Swipe the action row horizontally to reach all buttons, or tap **×** to close it. Color palettes and note search expand above the row; scroll the panel when space is limited. The system's copy/paste menu remains available. You can also open Hover Bar using **Show selection toolbar** in the command palette. - | Action | Example saved in your note | | --- | --- | | Bold | `**Important**` | @@ -45,10 +43,12 @@ On phones and tablets, the toolbar docks near the bottom of the editor, above th ## Compatibility and limitations -Requires Obsidian **1.8.7 or later** on desktop, iOS, or Android, in Live Preview or Source mode. Reading view does not show the toolbar. +Requires desktop Obsidian **1.8.7 or later**, in Live Preview or Source mode. Mobile devices are not supported. Reading view does not show the toolbar. Use a single text fragment within one logical line, including text in a heading, list item, or blockquote. Keep structural markers outside your selection. Complex mixed formatting, partial HTML spans, multiple selections, tables, code blocks, math, and frontmatter will not be rewritten. Unsupported actions explain their limitation. +Automatic selection quietly skips unsupported content. Open the toolbar manually or activate an unavailable action to learn why. Length protection checks the original selection, its full line, and the resulting line before writing. Select less text or split an overly long paragraph when needed. + Custom colors are saved as inline HTML. They remain in the note after disabling the plugin, but their appearance depends on the renderer and theme. Export tools may strip styles. Source mode shows the underlying markup, and Live Preview may reveal it while editing. The internal-link action intentionally writes wikilinks. ## Installation @@ -73,6 +73,8 @@ Use **Hover Bar: Show selection toolbar** in the command palette to open the too The interface follows Obsidian's language setting: Chinese app languages use Simplified Chinese text, and every other language uses English. No separate language preference is needed. +After opening the toolbar manually, use arrow keys to browse actions, Enter or Space to activate, and Esc to close the panel and then the toolbar. Unavailable buttons remain accessible with arrow keys; activating one explains its limitation without editing the note. Enter used to finish text composition in note search does not insert a link. + ## Privacy Hover Bar works entirely offline, with no accounts, runtime network requests, telemetry, or access to files outside the vault. Its note picker uses vault file metadata, and formatting changes only the selection you explicitly act on. diff --git a/README.zh-CN.md b/README.zh-CN.md index 57412b7..0f9ec2c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # Hover Bar -轻量、离线的 Obsidian 选区排版插件,支持电脑、手机和平板。选中文字后,直接完成排版、文字着色、背景着色和双链操作。 +轻量、离线的 Obsidian 桌面端选区排版插件。选中文字后,直接完成排版、文字着色、背景着色和双链操作。 [English](README.md) @@ -8,14 +8,13 @@ 阅读笔记时标记重点,整理摘录时调整层次,或把一句话中的概念连接到已有笔记。选中文字后,工具条在附近出现,常用排版可以直接点击完成。 -常规格式优先使用 Markdown;选择自定义文字色或背景色时,自动写入简洁的行内 HTML,无需手写颜色标签。 +常规格式优先使用 Markdown;从色板选择文字色或背景色时,自动写入简洁的行内 HTML,无需手写颜色标签。 ![Obsidian 中选中文字上方的 Hover Bar 工具条](assets/demos/toolbar.png) ## 功能 - 桌面端完成选区后弹出紧凑胶囊工具条,靠近文字并避开窗格边界。 -- 触摸屏使用底部工具条,按钮可横向滑动,增大点击区域,并适应软键盘空间。 - 加粗、斜体、删除线、行内代码和 Obsidian 标准高亮。 - 带名称的文字色与背景色色板,可分别移除颜色。 - 一键生成 `[[双链]]`,也可搜索并关联已有笔记。 @@ -23,17 +22,16 @@ - 适配明暗主题,支持键盘操作。 - Obsidian 设置为中文时显示中文,其他任何语言均显示英文。 - 可关闭自动弹出,保留命令面板手动呼出入口。 +- 手动操作会区分只读、模式关闭、多选区、跨行、格式冲突和长度限制,并说明如何继续。 ## 使用方法 1. 在实时预览或源码模式中打开 Markdown 笔记。 -2. 用鼠标或触控板选中一段普通文字;触摸屏上可长按文字并拖动选区手柄。 +2. 用鼠标或触控板选中一段普通文字。 3. 点击格式按钮,或打开色板选择颜色。 点击外部或按 Esc 关闭。键盘用户可通过命令面板中的「显示选区工具条」打开工具条,也可在 Obsidian 的快捷键设置中自行绑定快捷键。 -手机和平板上的工具条停靠在编辑区域底部,软键盘打开时位于键盘上方。左右滑动按钮行可访问全部操作,点击 **×** 关闭。色板和笔记搜索在按钮行上方展开,空间不足时可滚动面板。系统的复制、粘贴菜单仍可使用,也可通过命令面板中的「显示选区工具条」手动呼出。 - | 操作 | 笔记中保存的内容示例 | | --- | --- | | 加粗 | `**重点**` | @@ -45,10 +43,12 @@ ## 兼容性与限制 -需要 Obsidian **1.8.7 或更高版本**,支持桌面端、iOS 和 Android 的实时预览与源码模式。阅读视图不提供工具条。 +需要桌面端 Obsidian **1.8.7 或更高版本**,支持实时预览与源码模式,不支持移动端。阅读视图不提供工具条。 格式操作支持同一逻辑行中的单一文本片段,包括标题、列表项和引用中的文字;请选择文字内容,不包含结构标记。复杂混合格式、部分 HTML 标签跨度、多选区、表格、代码块、数学公式和 YAML 不直接改写;不可用动作会说明原因。 +自动划选遇到不支持的内容时安静跳过。需要了解原因时,可通过命令面板手动呼出,或激活不可用动作查看说明。长度保护同时检查原始选区、所在行及修改后的整行;过长时请缩小选区或先拆分段落,不会先写入再中断操作。 + 颜色以行内 HTML 保存在笔记中。关闭插件不会移除内容,显示效果取决于主题与渲染器,部分导出工具可能移除样式。源码模式会显示标记,实时预览在编辑时也可能露出源码;双链按钮明确输出 wikilink。 ## 安装 @@ -73,6 +73,8 @@ Hover Bar 目前尚未上架社区插件市场,可从 [GitHub Releases](https: 界面跟随 Obsidian 的语言设置:中文及中文变体显示简体中文文案,其他任何语言显示英文,无需单独设置语言。 +手动打开工具条后,用方向键浏览操作,Enter 或 Space 激活,Esc 先关闭面板再关闭工具条。不可用按钮同样可以通过方向键访问并了解原因;激活它只显示说明,不修改正文。搜索笔记时,输入法组词的 Enter 不会提前插入链接。 + ## 隐私 插件完全离线,无账户、无运行时网络请求、无遥测、不访问 vault 之外的文件。笔记选择器仅使用 vault 文件元数据,排版仅修改用户明确操作的选区。 diff --git a/manifest.json b/manifest.json index f559992..d02fe7f 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ { "id": "hover-bar", "name": "Hover Bar", - "version": "0.1.2", + "version": "0.1.3", "minAppVersion": "1.8.7", "description": "Format selected text with a floating toolbar, text colors, highlights, and internal links.", "author": "gua", "authorUrl": "https://github.com/saltcoffee", - "isDesktopOnly": false + "isDesktopOnly": true } diff --git a/package-lock.json b/package-lock.json index af95a34..db15877 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hover-bar", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hover-bar", - "version": "0.1.2", + "version": "0.1.3", "license": "MIT", "dependencies": { "entities": "8.1.0" diff --git a/package.json b/package.json index 7205c37..78a02d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hover-bar", - "version": "0.1.2", + "version": "0.1.3", "description": "Format selected text with a floating toolbar, text colors, highlights, and internal links.", "type": "module", "private": true, diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index b099150..451f5f2 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -15,7 +15,7 @@ assert.equal(lock.version, manifest.version, 'Lockfile version must match the ma assert.equal(lock.packages?.['']?.version, manifest.version, 'Lockfile root package must match the manifest'); assert.equal(versions[manifest.version], manifest.minAppVersion); assert.equal(manifest.id, 'hover-bar'); -assert.equal(manifest.isDesktopOnly, false, 'Hover Bar must remain available on mobile'); +assert.equal(manifest.isDesktopOnly, true, 'Hover Bar must remain desktop-only'); assert.ok(manifest.description.length <= 250 && manifest.description.endsWith('.')); extractReleaseNotes(await readFile('CHANGELOG.md', 'utf8'), manifest.version); const requested = process.argv.indexOf('--version'); diff --git a/src/editor/apply-edit.ts b/src/editor/apply-edit.ts index 35ebe84..552cf85 100644 --- a/src/editor/apply-edit.ts +++ b/src/editor/apply-edit.ts @@ -2,20 +2,27 @@ import { Annotation, EditorSelection, Transaction } from '@codemirror/state'; import { isolateHistory } from '@codemirror/commands'; import type { EditorView } from '@codemirror/view'; import { editorInfoField } from 'obsidian'; -import type { EditPlan } from '../formatting/types'; -import { editableState, revisionField, type SelectionSnapshot } from './context'; +import { MAX_SOURCE, type EditPlan } from '../formatting/types'; +import { editableState, matchesSnapshot, type SelectionSnapshot } from './context'; export const ownEdit = Annotation.define(); +export function checkEditBudget(snapshot: Pick, plan: EditPlan): + { readonly kind: 'available' } | { readonly kind: 'unavailable'; readonly reason: 'tooLong'; readonly stage: 'fragmentOutput' | 'resultLine' } { + if (plan.insert.length > MAX_SOURCE) return { kind: 'unavailable', reason: 'tooLong', stage: 'fragmentOutput' }; + if (snapshot.lineLength - (plan.to - plan.from) + plan.insert.length > MAX_SOURCE) return { kind: 'unavailable', reason: 'tooLong', stage: 'resultLine' }; + return { kind: 'available' }; +} + export function applyEdit(view: EditorView, snapshot: SelectionSnapshot, plan: EditPlan): boolean { const { state } = view; - const info = state.field(editorInfoField, false); - const selection = state.selection.main; const from = snapshot.base + plan.from; const to = snapshot.base + plan.to; - if (!editableState(state) || info?.file !== snapshot.file || info.file.path !== snapshot.path || - state.field(revisionField) !== snapshot.revision || selection.anchor !== snapshot.anchor || selection.head !== snapshot.head || - state.doc.sliceString(from, to) !== plan.expected) return false; + if (!editableState(state) || view.composing || !matchesSnapshot(view, snapshot) || + plan.from < 0 || plan.to < plan.from || plan.to > snapshot.source.length || from < 0 || to > state.doc.length || + /[\r\n\0]/.test(plan.insert) || plan.insert === plan.expected || state.doc.sliceString(from, to) !== plan.expected) return false; + const line = state.doc.lineAt(from); + if (to > line.to || checkEditBudget({ lineLength: line.length }, plan).kind === 'unavailable') return false; const start = from + plan.contentFrom; const end = from + plan.contentTo; view.dispatch({ @@ -23,5 +30,8 @@ export function applyEdit(view: EditorView, snapshot: SelectionSnapshot, plan: E selection: EditorSelection.single(snapshot.anchor > snapshot.head ? end : start, snapshot.anchor > snapshot.head ? start : end), annotations: [ownEdit.of(true), isolateHistory.of('full'), Transaction.userEvent.of('input.hover-bar')], }); - return view.state.doc.sliceString(from, from + plan.insert.length) === plan.insert; + const actual = view.state.selection.main; + return view.state.field(editorInfoField, false)?.file === snapshot.file && snapshot.file.path === snapshot.path && + view.state.doc.sliceString(from, from + plan.insert.length) === plan.insert && view.state.selection.ranges.length === 1 && + actual.anchor === (snapshot.anchor > snapshot.head ? end : start) && actual.head === (snapshot.anchor > snapshot.head ? start : end); } diff --git a/src/editor/color-preview.ts b/src/editor/color-preview.ts index d163d68..fa692f4 100644 --- a/src/editor/color-preview.ts +++ b/src/editor/color-preview.ts @@ -3,7 +3,7 @@ import { editorInfoField, editorLivePreviewField, MarkdownView } from 'obsidian' import { PALETTE } from '../formatting/colors'; import { parseFragment } from '../formatting/fragment'; import { MAX_SOURCE } from '../formatting/types'; -import { protectedSyntax } from './context'; +import { inspectSyntax } from './context'; import type { Host } from './controller'; export function colorPreview(host: Host) { @@ -33,7 +33,7 @@ export function colorPreview(host: Host) { for (const match of line.text.matchAll(/[^<>]*<\/span>/g)) { const fragment = parseFragment(match[0]); const from = line.from + match.index; - if (!fragment || protectedSyntax(view.state, from, from + match[0].length)) continue; + if (!fragment || inspectSyntax(view.state, from, from + match[0].length) !== 'clear') continue; const classes = []; for (const kind of ['foreground', 'background'] as const) { const color = PALETTE.find(color => color[kind] === fragment[kind]); diff --git a/src/editor/context.ts b/src/editor/context.ts index 41f5200..211a758 100644 --- a/src/editor/context.ts +++ b/src/editor/context.ts @@ -10,47 +10,65 @@ export const revisionField = StateField.define({ create: () => 0, update export interface SelectionSnapshot { readonly file: TFile; readonly path: string; readonly revision: number; readonly anchor: number; readonly head: number; readonly base: number; - readonly source: string; readonly located: LocatedFragment; + readonly source: string; readonly lineLength: number; readonly located: LocatedFragment; } -export function protectedSyntax(state: EditorState, from: number, to: number): boolean { - if (!syntaxTreeAvailable(state, to)) return true; +export type SelectionRejection = + | { readonly kind: 'preferences'; readonly status: 'loadError' | 'schemaError' | 'conflict' } + | { readonly kind: 'noEditor' | 'readOnly' | 'composing' | 'multipleSelections' | 'emptySelection' | 'multipleLines' | 'syntaxPending' | 'protectedSyntax' | 'unsupportedFragment' } + | { readonly kind: 'modeDisabled'; readonly mode: 'livePreview' | 'source' } + | { readonly kind: 'tooLong'; readonly stage: 'selection' | 'line' }; +export type CaptureResult = { readonly kind: 'ready'; readonly snapshot: SelectionSnapshot } | { readonly kind: 'blocked'; readonly reason: SelectionRejection }; + +export function inspectSyntax(state: EditorState, from: number, to: number): 'clear' | 'pending' | 'protected' { + if (!syntaxTreeAvailable(state, to)) return 'pending'; let protectedRange = false; syntaxTree(state).iterate({ from, to, enter(node) { if (/frontmatter|yaml|codeblock|fencedcode|indentedcode|table|math|comment|hashtag|wikilink|internal-link|embed|horizontalrule|headermark/i.test(node.name)) protectedRange = true; } }); - return protectedRange; + return protectedRange ? 'protected' : 'clear'; } -export function capture(view: EditorView, app: App, store: PreferencesStore): SelectionSnapshot | null { - if (!store.enabled || view.composing || view.state.readOnly || !view.state.facet(EditorView.editable)) return null; +export function capture(view: EditorView, app: App, store: PreferencesStore): CaptureResult { + const blocked = (reason: SelectionRejection): CaptureResult => ({ kind: 'blocked', reason }); + if (!store.enabled) return blocked({ kind: 'preferences', status: store.status === 'schemaError' ? 'schemaError' : store.status === 'conflict' ? 'conflict' : 'loadError' }); const info = view.state.field(editorInfoField, false); - if (!info?.file || !info.editor || view.state.selection.ranges.length !== 1) return null; + if (!info?.file || !info.editor) return blocked({ kind: 'noEditor' }); const markdownView = app.workspace.getLeavesOfType('markdown').map(leaf => leaf.view) .find(candidate => candidate instanceof MarkdownView && candidate.editor === info.editor); - if (!(markdownView instanceof MarkdownView) || markdownView.getMode() !== 'source') return null; + if (!(markdownView instanceof MarkdownView) || markdownView.getMode() !== 'source') return blocked({ kind: 'noEditor' }); const live = view.state.field(editorLivePreviewField, false) ?? false; - if (!(live ? store.value.livePreview : store.value.sourceMode)) return null; + if (!(live ? store.value.livePreview : store.value.sourceMode)) return blocked({ kind: 'modeDisabled', mode: live ? 'livePreview' : 'source' }); + if (!editableState(view.state)) return blocked({ kind: 'readOnly' }); + if (view.composing) return blocked({ kind: 'composing' }); + if (view.state.selection.ranges.length !== 1) return blocked({ kind: 'multipleSelections' }); const selection = view.state.selection.main; - if (selection.empty || selection.to - selection.from > MAX_SOURCE) return null; + if (selection.empty) return blocked({ kind: 'emptySelection' }); + if (selection.to - selection.from > MAX_SOURCE) return blocked({ kind: 'tooLong', stage: 'selection' }); const line = view.state.doc.lineAt(selection.from); - if (selection.to > line.to || line.length > MAX_SOURCE) return null; - if (protectedSyntax(view.state, selection.from, selection.to)) return null; + if (selection.to > line.to) return blocked({ kind: 'multipleLines' }); + if (line.length > MAX_SOURCE) return blocked({ kind: 'tooLong', stage: 'line' }); + if (!view.state.doc.sliceString(selection.from, selection.to).trim()) return blocked({ kind: 'emptySelection' }); + const syntax = inspectSyntax(view.state, selection.from, selection.to); + if (syntax !== 'clear') return blocked({ kind: syntax === 'pending' ? 'syntaxPending' : 'protectedSyntax' }); const sourceLine = line.text; let prefix = /^(?: {0,3}>[ \t]*)*/.exec(sourceLine)![0].length; const list = /^(?:[ \t]{0,3}(?:[-+*]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?)/.exec(sourceLine.slice(prefix)); prefix += list?.[0].length ?? 0; prefix += /^#{1,6}[ \t]+/.exec(sourceLine.slice(prefix))?.[0].length ?? 0; - if (selection.from < line.from + prefix) return null; + if (selection.from < line.from + prefix) return blocked({ kind: 'protectedSyntax' }); const source = sourceLine.slice(prefix); const base = line.from + prefix; const located = locateFragment(source, selection.from - base, selection.to - base); - if (!located) return null; - return { file: info.file, path: info.file.path, revision: view.state.field(revisionField), anchor: selection.anchor, head: selection.head, base, source, located }; + if (!located) return blocked({ kind: 'unsupportedFragment' }); + return { kind: 'ready', snapshot: { file: info.file, path: info.file.path, revision: view.state.field(revisionField), anchor: selection.anchor, head: selection.head, base, source, lineLength: line.length, located } }; } -export function sameSelection(a: SelectionSnapshot, b: SelectionSnapshot): boolean { - return a.file === b.file && a.path === b.path && a.revision === b.revision && a.anchor === b.anchor && a.head === b.head; +export function matchesSnapshot(view: EditorView, snapshot: SelectionSnapshot): boolean { + const info = view.state.field(editorInfoField, false); + const selection = view.state.selection.main; + return info?.file === snapshot.file && info.file.path === snapshot.path && view.state.field(revisionField) === snapshot.revision && + view.state.selection.ranges.length === 1 && selection.anchor === snapshot.anchor && selection.head === snapshot.head; } export function editableState(state: EditorState): boolean { return !state.readOnly && state.facet(EditorView.editable); } diff --git a/src/editor/controller.ts b/src/editor/controller.ts index 0becd77..8206d5a 100644 --- a/src/editor/controller.ts +++ b/src/editor/controller.ts @@ -1,15 +1,21 @@ import type { EditorView, ViewUpdate } from '@codemirror/view'; import { undo, redo } from '@codemirror/commands'; -import { editorInfoField, editorLivePreviewField, Platform, type App, type TFile } from 'obsidian'; +import { editorInfoField, editorLivePreviewField, type App, type TFile } from 'obsidian'; import type { Translate } from '../i18n'; -import { formatFragment } from '../formatting/markdown'; import type { EditPlan, FormatAction } from '../formatting/types'; -import { linkPlan, safeLinkText } from '../links/wikilink'; import { PreferencesStore } from '../settings/store'; -import { Toolbar, type NoteChoice } from '../ui/toolbar'; -import { place, placeTouch, viewportBounds } from '../ui/position'; +import { Toolbar } from '../ui/toolbar'; +import type { NoteChoice } from '../ui/toolbar-types'; +import { place, viewportBounds } from '../ui/position'; import { applyEdit, ownEdit } from './apply-edit'; -import { capture, sameSelection, type SelectionSnapshot } from './context'; +import { capture, matchesSnapshot, type SelectionSnapshot } from './context'; +import { actionFeedback, selectionFeedback } from './feedback'; +import { formatSelection, linkSelection } from './selection-actions'; +import { toolbarModel } from './toolbar-model'; + +export type CloseReason = 'dismiss' | 'linkApplied' | 'stale' | 'outside' | 'windowBlur' | 'modeChanged' | 'noSpace' | 'failed' | 'destroy'; + +function selectionKey(snapshot: SelectionSnapshot): string { return `${snapshot.path}:${snapshot.revision}:${snapshot.anchor}:${snapshot.head}`; } export interface Host { app: App; store: PreferencesStore; t: Translate; @@ -22,9 +28,6 @@ export class EditorController { private current: SelectionSnapshot | null = null; private destroyed = false; private dragging = false; - private touch = Platform.isMobile; - private touchSelection = Platform.isMobile; - private keyboardInput = false; private applying = false; private focusRequested = false; private frame = 0; @@ -39,10 +42,10 @@ export class EditorController { this.document = view.dom.ownerDocument; this.window = this.document.defaultView!; this.toolbar = new Toolbar(this.document, host.t, { - available: (action, color) => !!this.current && formatFragment(this.current.located, this.current.source, action, color).kind !== 'unavailable', + explain: reason => { if (this.validate()) this.host.notify(reason); }, format: (action, color) => this.format(action, color), quickLink: () => this.quickLink(), notes: (query, signal) => this.notes(query, signal), selectNote: path => this.selectNote(path), - close: () => this.close(true), resize: () => this.measure(), + close: () => this.close('dismiss'), resize: () => this.measure(), }); this.observer = new ResizeObserver(() => this.measure()); this.listen(this.toolbar.root, 'keydown', event => { @@ -52,31 +55,25 @@ export class EditorController { if (lower !== 'z' && !(lower === 'y' && key.ctrlKey)) return; if (!this.validate()) return; key.preventDefault(); key.stopPropagation(); - this.close(true); + this.close('dismiss'); if (key.shiftKey || lower === 'y') redo(this.view); else undo(this.view); - this.view.focus(); + if (!this.view.hasFocus) this.view.focus(); }); this.listen(view.dom, 'pointerdown', event => this.beginDrag(event as PointerEvent)); - // Some mobile WebViews deliver native handle changes as plain `select` transactions. - this.listen(view.dom, 'touchstart', () => this.beginTouch()); - this.listen(view.dom, 'keydown', () => { this.keyboardInput = true; }); - this.listen(view.dom, 'compositionstart', () => this.close(false)); + this.listen(view.dom, 'compositionstart', () => this.close('stale')); const checkFocus = () => queueMicrotask(() => { - if (!this.destroyed && !this.view.hasFocus && !this.toolbar.ownsFocus()) this.close(false); + if (!this.destroyed && !this.view.hasFocus && !this.toolbar.ownsFocus()) this.close('stale'); }); this.listen(view.dom, 'focusout', checkFocus); this.listen(this.toolbar.root, 'focusout', checkFocus); - this.listen(this.document, 'pointerdown', event => { if (!this.toolbar.contains(event)) this.close(false); }, true); + this.listen(this.document, 'pointerdown', event => { if (!this.toolbar.contains(event)) this.close('outside'); }, true); this.listen(this.document, 'contextmenu', event => { - if (this.toolbar.contains(event)) return; - if (this.touchSelection && event.composedPath().includes(view.dom)) { - if (this.host.store.value.automatic) this.schedule(); - } else this.close(false); + if (!this.toolbar.contains(event)) this.close('outside'); }); this.listen(this.document, 'keydown', event => { - if ((event as KeyboardEvent).key === 'Escape' && !this.toolbar.ownsFocus()) this.close(false); + if ((event as KeyboardEvent).key === 'Escape' && !this.toolbar.ownsFocus()) this.close('stale'); }); - this.listen(this.window, 'blur', () => { this.stopDrag(); this.close(false); }); + this.listen(this.window, 'blur', () => { this.stopDrag(); this.close('windowBlur'); }); this.listen(this.window, 'resize', () => this.measure()); if (this.window.visualViewport) { this.listen(this.window.visualViewport, 'resize', () => this.measure()); @@ -92,16 +89,11 @@ export class EditorController { } private beginDrag(event: PointerEvent): void { - if (event.pointerType === 'touch') { this.beginTouch(); return; } - this.touch = Platform.isMobile; this.touchSelection = false; this.keyboardInput = false; if (event.button !== 0) return; this.dragging = true; this.dismissed = ''; this.window.addEventListener('pointerup', this.endDrag, true); this.window.addEventListener('pointercancel', this.endDrag, true); } - private beginTouch(): void { - this.stopDrag(); this.touch = true; this.touchSelection = true; this.keyboardInput = false; this.dismissed = ''; - } private endDrag = (event: Event): void => { const wasDragging = this.dragging; this.stopDrag(); @@ -116,16 +108,16 @@ export class EditorController { update(update: ViewUpdate): void { if (this.destroyed) return; if (update.startState.field(editorLivePreviewField, false) !== update.state.field(editorLivePreviewField, false)) { - this.close(false); return; + this.close('modeChanged'); return; } const own = update.transactions.some(transaction => transaction.annotation(ownEdit)); - if (update.docChanged && !own) { this.close(false); return; } + if (update.docChanged && !own) { this.close('stale'); return; } if (update.selectionSet && !own && !update.startState.selection.eq(update.state.selection)) { - this.close(false); + this.close('stale'); + this.dismissed = ''; const pointer = update.transactions.some(transaction => transaction.isUserEvent('select.pointer')); const keyboard = this.host.store.value.keyboard && update.transactions.some(transaction => transaction.isUserEvent('select')); - const touch = this.touchSelection && !this.keyboardInput && update.transactions.some(transaction => transaction.isUserEvent('select')); - if (this.host.store.value.automatic && !this.dragging && (pointer || keyboard || touch)) this.schedule(); + if (this.host.store.value.automatic && !this.dragging && (pointer || keyboard)) this.schedule(); } else if (this.current && (update.geometryChanged || update.viewportChanged)) this.measure(); } @@ -136,48 +128,55 @@ export class EditorController { show(manual: boolean): boolean { if (this.destroyed || (!manual && !this.view.hasFocus)) return false; - const snapshot = capture(this.view, this.host.app, this.host.store); - if (!snapshot) { if (manual) this.host.notify(this.host.t('unsupported')); return false; } - const key = `${snapshot.revision}:${snapshot.anchor}:${snapshot.head}`; + const result = capture(this.view, this.host.app, this.host.store); + if (result.kind === 'blocked') { + this.close('stale'); if (manual) this.host.notify(this.host.t(selectionFeedback(result.reason))); return false; + } + const snapshot = result.snapshot; + const key = selectionKey(snapshot); if (!manual && key === this.dismissed) return false; for (const controller of this.host.controllers) { - if (controller !== this && controller.document === this.document) controller.close(false); + if (controller !== this && controller.document === this.document) controller.close('outside'); } + const continuing = this.current?.file === snapshot.file && selectionKey(this.current) === key; + this.focusRequested = manual || (continuing && this.focusRequested); this.current = snapshot; - this.focusRequested ||= manual; - this.toolbar.update(snapshot.located.fragment); - this.toolbar.show(this.view.scrollDOM.clientWidth < 384, this.touch); + this.toolbar.update(toolbarModel(snapshot, this.host.t)); + this.toolbar.show(this.view.scrollDOM.clientWidth < 384); this.observer.observe(this.toolbar.root); this.measure(); return true; } - close(restoreFocus: boolean): void { - if (this.current) this.dismissed = `${this.current.revision}:${this.current.anchor}:${this.current.head}`; - const hadFocus = this.toolbar.ownsFocus(); - this.current = null; - this.focusRequested = false; - this.noteTargets.clear(); + close(reason: CloseReason): void { + const snapshot = this.current; + if (snapshot && reason === 'dismiss') this.dismissed = selectionKey(snapshot); + const returnFocus = (reason === 'dismiss' || reason === 'linkApplied') && this.toolbar.ownsFocus(); + this.current = null; this.focusRequested = false; this.noteTargets.clear(); if (this.frame) this.window.cancelAnimationFrame(this.frame); - this.frame = 0; - this.observer.disconnect(); this.toolbar.hide(); - if (restoreFocus && hadFocus && this.view.dom.isConnected && !this.destroyed) this.view.focus(); + this.frame = 0; this.observer.disconnect(); this.toolbar.hide(); + const file = this.view.state.field(editorInfoField, false)?.file; + if (returnFocus && snapshot && !this.destroyed && this.view.dom.isConnected && this.view.dom.ownerDocument === this.document && + file === snapshot.file && file.path === snapshot.path && !this.view.hasFocus) this.view.focus(); } private validate(): SelectionSnapshot | null { if (this.destroyed || !this.current) return null; - const fresh = capture(this.view, this.host.app, this.host.store); - if (!fresh || !sameSelection(this.current, fresh)) { - this.close(false); this.host.notify(this.host.t('stale')); return null; + if (!matchesSnapshot(this.view, this.current)) { + this.close('stale'); this.host.notify(this.host.t('stale')); return null; + } + const result = capture(this.view, this.host.app, this.host.store); + if (result.kind === 'blocked') { + this.close('stale'); this.host.notify(this.host.t(selectionFeedback(result.reason))); return null; } - return fresh; + return result.snapshot; } private format(action: FormatAction, color?: string): void { const snapshot = this.validate(); if (!snapshot) return; - const result = formatFragment(snapshot.located, snapshot.source, action, color); - if (result.kind === 'unavailable') this.host.notify(this.host.t('unsupported')); + const result = formatSelection(snapshot, action, color); + if (result.kind === 'unavailable') this.host.notify(this.host.t(actionFeedback(result.reason))); else if (result.kind === 'edit') this.commit(snapshot, result.plan, false); } @@ -186,13 +185,13 @@ export class EditorController { this.applying = true; try { if (!applyEdit(this.view, snapshot, plan)) { - this.close(false); this.host.notify(this.host.t('failed')); return; + this.close('failed'); this.host.notify(this.host.t('failed')); return; } - if (link) this.close(true); + if (link) this.close('linkApplied'); else { - this.current = capture(this.view, this.host.app, this.host.store); - if (this.current) { this.toolbar.update(this.current.located.fragment); this.measure(); } - else this.close(false); + const result = capture(this.view, this.host.app, this.host.store); + if (result.kind === 'ready') { this.current = result.snapshot; this.toolbar.update(toolbarModel(result.snapshot, this.host.t)); this.measure(); } + else this.close('stale'); } } finally { this.applying = false; } } @@ -201,7 +200,8 @@ export class EditorController { const snapshot = this.validate(); if (!snapshot) return; const text = snapshot.located.fragment.text; - if (!safeLinkText(text)) { this.host.notify(this.host.t('invalidLink')); return; } + const result = linkSelection(snapshot, text); + if (result.kind === 'unavailable') { this.host.notify(this.host.t(actionFeedback(result.reason))); return; } const matches = this.host.app.vault.getMarkdownFiles().filter(file => file.basename === text); if (matches.length > 1) { this.toolbar.notePicker(); return; } if (matches[0]) this.selectNote(matches[0].path, matches[0]); @@ -209,8 +209,7 @@ export class EditorController { if (this.host.app.metadataCache.getFirstLinkpathDest(text, snapshot.path)) { this.toolbar.notePicker(); return; } - const plan = linkPlan(snapshot.located, snapshot.source, text); - if (plan) this.commit(snapshot, plan, true); else this.host.notify(this.host.t('invalidLink')); + this.commit(snapshot, result.plan, true); } } @@ -218,40 +217,46 @@ export class EditorController { const snapshot = this.validate(); if (!snapshot) return; const file = this.host.app.vault.getFileByPath(path); - if (!file || file !== expected || file.extension !== 'md') { this.close(false); this.host.notify(this.host.t('stale')); return; } + if (!file || file !== expected || file.extension !== 'md') { this.close('stale'); this.host.notify(this.host.t('stale')); return; } const target = this.host.app.metadataCache.fileToLinktext(file, snapshot.path, true); - const plan = linkPlan(snapshot.located, snapshot.source, target); - if (!plan || this.host.app.metadataCache.getFirstLinkpathDest(target, snapshot.path) !== file) { + const result = linkSelection(snapshot, target); + if (result.kind === 'unavailable') { this.host.notify(this.host.t(actionFeedback(result.reason))); return; } + if (this.host.app.metadataCache.getFirstLinkpathDest(target, snapshot.path) !== file) { this.host.notify(this.host.t('invalidLink')); return; } - this.commit(snapshot, plan, true); + this.commit(snapshot, result.plan, true); } private async notes(query: string, signal: AbortSignal): Promise { const session = this.current; + if (!session) return []; const text = query.trim().toLocaleLowerCase(); const results: NoteChoice[] = []; - this.noteTargets.clear(); + const targets = new Map(); const files = this.host.app.vault.getMarkdownFiles(); for (let i = 0; i < files.length; i++) { if (signal.aborted || this.current !== session || this.destroyed) return []; const file = files[i]!; if (file.path.toLocaleLowerCase().includes(text)) { - this.noteTargets.set(file.path, file); - const target = this.host.app.metadataCache.fileToLinktext(file, session?.path ?? '', true); - results.push({ path: file.path, name: file.basename, disabled: !safeLinkText(target) }); + targets.set(file.path, file); + const target = this.host.app.metadataCache.fileToLinktext(file, session.path, true); + const result = linkSelection(session, target); + results.push({ path: file.path, name: file.basename, disabledReason: result.kind === 'unavailable' ? this.host.t(actionFeedback(result.reason)) : undefined }); } if (results.length === 50) break; if (i > 0 && i % 500 === 0) await new Promise(resolve => this.window.setTimeout(resolve, 0)); } + if (signal.aborted || this.current !== session || this.destroyed) return []; + this.noteTargets = targets; return results; } private measure(): void { if (!this.current || this.destroyed) return; + const session = this.current; this.view.requestMeasure({ key: this, read: () => { - if (!this.current || !this.toolbar.visible) return null; + if (this.current !== session || !this.toolbar.visible) return null; const from = this.view.coordsAtPos(Math.min(this.current.anchor, this.current.head), 1); const to = this.view.coordsAtPos(Math.max(this.current.anchor, this.current.head), -1); const pane = this.view.scrollDOM.getBoundingClientRect(); @@ -262,21 +267,13 @@ export class EditorController { const head = this.current.anchor > this.current.head ? from : to; const headVisible = head.top >= bounds.top && head.bottom <= bounds.bottom; const anchor = { left: head.left, right: head.right, top: Math.max(bounds.top, Math.min(from.top, to.top)), bottom: Math.min(bounds.bottom, Math.max(from.bottom, to.bottom)) }; - // Searching can open the keyboard and scroll the original selection out of view. - const searching = this.touch && this.toolbar.ownsFocus(); - const position = !headVisible && !searching ? null : this.touch - // Expanded touch panels act as bottom sheets in the keyboard-reduced viewport. - ? placeTouch(headVisible && !this.toolbar.expanded ? anchor : null, bounds, size.width, size.height) - : place(anchor, bounds, size.width, size.height); - return { bounds, position }; + return headVisible ? place(anchor, bounds, size.width, size.height) : null; }, - write: result => { - if (this.destroyed || !this.current) return; - if (result && this.touch && this.toolbar.constrain(result.bounds)) { this.measure(); return; } + write: position => { + if (this.destroyed || this.current !== session) return; const focus = this.focusRequested; this.focusRequested = false; - const position = result?.position; - if (!position) { this.close(false); if (focus) this.host.notify(this.host.t('noSpace')); } + if (!position) { this.close('noSpace'); if (focus) this.host.notify(this.host.t('noSpace')); } else { this.toolbar.positioned(position.left, position.top); if (focus) this.toolbar.focus(); } }, }); @@ -285,13 +282,13 @@ export class EditorController { matchesEditor(editor: unknown): boolean { return this.view.state.field(editorInfoField, false)?.editor === editor; } reconcile(): void { if (!this.current || this.destroyed) return; - const snapshot = capture(this.view, this.host.app, this.host.store); - if (!snapshot || !sameSelection(this.current, snapshot)) this.close(false); + const result = capture(this.view, this.host.app, this.host.store); + if (result.kind === 'blocked' || !matchesSnapshot(this.view, this.current)) this.close('stale'); else this.measure(); } destroy(): void { if (this.destroyed) return; - this.destroyed = true; this.stopDrag(); this.close(false); this.toolbar.destroy(); + this.destroyed = true; this.stopDrag(); this.close('destroy'); this.toolbar.destroy(); for (const dispose of this.disposers) dispose(); this.host.controllers.delete(this); } diff --git a/src/editor/feedback.ts b/src/editor/feedback.ts new file mode 100644 index 0000000..6ca6f03 --- /dev/null +++ b/src/editor/feedback.ts @@ -0,0 +1,20 @@ +import type { TextKey } from '../i18n'; +import type { FormatUnavailableReason } from '../formatting/types'; +import type { LinkUnavailableReason } from '../links/wikilink'; +import type { SelectionRejection } from './context'; + +export function selectionFeedback(reason: SelectionRejection): TextKey { + if (reason.kind === 'preferences') return reason.status === 'conflict' ? 'settingsConflict' : reason.status; + const keys = { + noEditor: 'noSelection', modeDisabled: 'modeDisabled', readOnly: 'readOnly', composing: 'composing', + multipleSelections: 'multipleSelections', emptySelection: 'emptySelection', tooLong: 'tooLong', + multipleLines: 'multipleLines', syntaxPending: 'syntaxPending', protectedSyntax: 'protectedSyntax', unsupportedFragment: 'unsupported', + } as const; + return keys[reason.kind]; +} + +export function actionFeedback(reason: FormatUnavailableReason | LinkUnavailableReason): TextKey { + if (reason === 'invalidParameter') return 'failed'; + if (reason === 'invalidText' || reason === 'invalidTarget') return 'invalidLink'; + return reason; +} diff --git a/src/editor/selection-actions.ts b/src/editor/selection-actions.ts new file mode 100644 index 0000000..15fe16f --- /dev/null +++ b/src/editor/selection-actions.ts @@ -0,0 +1,17 @@ +import { formatFragment } from '../formatting/markdown'; +import type { FormatAction, FormatResult } from '../formatting/types'; +import { linkPlan, type LinkResult } from '../links/wikilink'; +import { checkEditBudget } from './apply-edit'; +import type { SelectionSnapshot } from './context'; + +export function formatSelection(snapshot: SelectionSnapshot, action: FormatAction, color?: string): FormatResult { + const result = formatFragment(snapshot.located, snapshot.source, action, color); + if (result.kind === 'edit' && checkEditBudget(snapshot, result.plan).kind === 'unavailable') return { kind: 'unavailable', reason: 'tooLong' }; + return result; +} + +export function linkSelection(snapshot: SelectionSnapshot, target: string): LinkResult { + const result = linkPlan(snapshot.located, snapshot.source, target); + if (result.kind === 'edit' && checkEditBudget(snapshot, result.plan).kind === 'unavailable') return { kind: 'unavailable', reason: 'tooLong' }; + return result; +} diff --git a/src/editor/toolbar-model.ts b/src/editor/toolbar-model.ts new file mode 100644 index 0000000..d7cc631 --- /dev/null +++ b/src/editor/toolbar-model.ts @@ -0,0 +1,37 @@ +import type { Translate } from '../i18n'; +import { PALETTE } from '../formatting/colors'; +import type { FormatAction, FormatResult } from '../formatting/types'; +import { linkTextRejection, type LinkResult } from '../links/wikilink'; +import type { ActionView, ToolbarViewModel } from '../ui/toolbar-types'; +import type { SelectionSnapshot } from './context'; +import { actionFeedback } from './feedback'; +import { formatSelection, linkSelection } from './selection-actions'; + +export function toolbarModel(snapshot: SelectionSnapshot, t: Translate): ToolbarViewModel { + const fragment = snapshot.located.fragment; + const state = (result: FormatResult | LinkResult): ActionView => result.kind === 'unavailable' + ? { enabled: false, reason: t(actionFeedback(result.reason)) } : { enabled: true }; + const format = (action: FormatAction): ActionView => ({ + ...state(formatSelection(snapshot, action)), + pressed: action === 'code' ? fragment.code : fragment.marks.some(mark => mark === action), + }); + const palette = (kind: 'foreground' | 'background') => PALETTE.map(color => ({ + ...state(formatSelection(snapshot, kind, color[kind])), id: color.id, color: color[kind], + label: `${t(color.id)} · ${color[kind]}`, pressed: fragment[kind] === color[kind], + })); + const palettes = { foreground: palette('foreground'), background: palette('background') }; + const resets = { foreground: state(formatSelection(snapshot, 'foreground')), background: state(formatSelection(snapshot, 'background')) }; + const paletteState = (kind: 'foreground' | 'background'): ActionView => ({ + enabled: resets[kind].enabled || palettes[kind].some(color => color.enabled), + pressed: !!fragment[kind], reason: palettes[kind].find(color => !color.enabled)?.reason, + }); + const linkReason = linkTextRejection(fragment); + const chooseNote = linkReason ? { enabled: false, reason: t(actionFeedback(linkReason)) } : { enabled: true }; + return { + controls: { + bold: format('bold'), italic: format('italic'), strike: format('strike'), code: format('code'), highlight: format('highlight'), + foreground: paletteState('foreground'), background: paletteState('background'), clearColors: state(formatSelection(snapshot, 'clearColors')), + link: state(linkSelection(snapshot, fragment.text)), chooseNote, more: { enabled: true }, + }, palettes, resets, + }; +} diff --git a/src/formatting/markdown.ts b/src/formatting/markdown.ts index 47c9d7a..3fd6916 100644 --- a/src/formatting/markdown.ts +++ b/src/formatting/markdown.ts @@ -25,13 +25,13 @@ export function serialize(fragment: Fragment): { source: string; contentFrom: nu } export function formatFragment(located: LocatedFragment, source: string, action: FormatAction, color?: string): FormatResult { - if (!['bold', 'italic', 'strike', 'highlight', 'code', 'clearColors', 'foreground', 'background'].includes(action)) return { kind: 'unavailable' }; + if (!['bold', 'italic', 'strike', 'highlight', 'code', 'clearColors', 'foreground', 'background'].includes(action)) return { kind: 'unavailable', reason: 'invalidParameter' }; const original = located.fragment; - if (original.code && action !== 'code') return { kind: 'unavailable' }; - if (action === 'code' && (original.marks.length || original.foreground || original.background)) return { kind: 'unavailable' }; + if (original.code && action !== 'code') return { kind: 'unavailable', reason: 'codeOnly' }; + if (action === 'code' && (original.marks.length || original.foreground || original.background)) return { kind: 'unavailable', reason: 'plainOnly' }; let next: Fragment; if (action === 'foreground' || action === 'background') { - if (!isPaletteColor(action, color)) return { kind: 'unavailable' }; + if (!isPaletteColor(action, color)) return { kind: 'unavailable', reason: 'invalidParameter' }; if (original[action] === color) return { kind: 'noop' }; next = { ...original, [action]: color }; } else if (action === 'clearColors') { @@ -44,7 +44,7 @@ export function formatFragment(located: LocatedFragment, source: string, action: next = { ...original, marks }; } const serialized = serialize(next); - if (serialized.source.length > MAX_SOURCE) return { kind: 'unavailable' }; + if (serialized.source.length > MAX_SOURCE) return { kind: 'unavailable', reason: 'tooLong' }; const expected = source.slice(located.from, located.to); if (serialized.source === expected) return { kind: 'noop' }; return { kind: 'edit', plan: { from: located.from, to: located.to, expected, insert: serialized.source, contentFrom: serialized.contentFrom, contentTo: serialized.contentTo } }; diff --git a/src/formatting/types.ts b/src/formatting/types.ts index aeab3b4..8b88587 100644 --- a/src/formatting/types.ts +++ b/src/formatting/types.ts @@ -19,4 +19,5 @@ export interface EditPlan { readonly from: number; readonly to: number; readonly expected: string; readonly insert: string; readonly contentFrom: number; readonly contentTo: number; } -export type FormatResult = { kind: 'edit'; plan: EditPlan } | { kind: 'noop' } | { kind: 'unavailable' }; +export type FormatUnavailableReason = 'codeOnly' | 'plainOnly' | 'tooLong' | 'invalidParameter'; +export type FormatResult = { kind: 'edit'; plan: EditPlan } | { kind: 'noop' } | { kind: 'unavailable'; reason: FormatUnavailableReason }; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index e7c837a..39fe509 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -6,16 +6,24 @@ export const en = { red: 'Red', orange: 'Orange', yellow: 'Yellow', green: 'Green', teal: 'Teal', blue: 'Blue', purple: 'Purple', gray: 'Gray', showToolbar: 'Show selection toolbar', toggleAutomatic: 'Toggle automatic toolbar', automaticOn: 'Automatic toolbar enabled.', automaticOff: 'Automatic toolbar disabled.', - unsupported: 'Select a complete, single-line text fragment without mixed formatting.', + unsupported: 'Select a complete text or formatting fragment without partial or mixed formatting.', + modeDisabled: 'Hover Bar is disabled in this editing mode. Enable it in the plugin settings.', + readOnly: 'This note is not editable. Switch to an editable note.', + composing: 'Finish composing text, then select the text again.', + multipleSelections: 'Multiple selections are not supported. Keep one continuous selection.', + emptySelection: 'Select some non-whitespace text first.', + multipleLines: 'Formatting across lines is not supported. Select text within one logical line.', + syntaxPending: 'The formatting context is not available yet. Select the text again in a moment.', + protectedSyntax: 'The selection includes protected syntax or structural markers. Select ordinary text content only.', codeOnly: 'Inline code only supports removing its code formatting.', - plainOnly: 'Select unformatted text for this action.', tooLong: 'The text and its formatting must fit within 10,000 characters.', - noSelection: 'Select text in an enabled Markdown editor first.', stale: 'The text or note changed. Select the text again.', + plainOnly: 'Select unformatted text for this action.', tooLong: 'The text or formatting source exceeds the length limit. Select less text, or split an overly long paragraph first.', + noSelection: 'Select text in a Markdown note in editing mode first.', stale: 'The text or note changed. Select the text again.', noSpace: 'Not enough space for the toolbar. Resize or scroll the editor.', failed: 'The edit could not be applied. Select the text again.', invalidLink: 'This text cannot be represented as a safe internal link.', searchNotes: 'Search notes', noNotes: 'No matching notes. Try another search.', searching: 'Finding notes…', searchError: 'Notes could not be loaded. Try again.', - searchHint: 'Showing up to 50 notes. Keep typing to narrow the results.', close: 'Close', back: 'Back', - automatic: 'Show toolbar automatically', automaticDesc: 'Show formatting actions after selecting text with a mouse, trackpad, or touch.', + searchHint: 'Showing up to 50 notes. Keep typing to narrow the results.', back: 'Back', + automatic: 'Show toolbar automatically', automaticDesc: 'Show formatting actions after selecting text with a mouse or trackpad.', livePreview: 'Enable in Live Preview', livePreviewDesc: 'Allow the toolbar while editing in Live Preview.', sourceMode: 'Enable in Source mode', sourceModeDesc: 'Allow the toolbar while editing Markdown source.', keyboard: 'Show after keyboard selection', keyboardDesc: 'Also show automatically when extending a selection with the keyboard.', diff --git a/src/i18n/zh-cn.ts b/src/i18n/zh-cn.ts index 9e3b6a2..5e84649 100644 --- a/src/i18n/zh-cn.ts +++ b/src/i18n/zh-cn.ts @@ -8,15 +8,22 @@ export const zhCN: Strings = { red: '红色', orange: '橙色', yellow: '黄色', green: '绿色', teal: '青色', blue: '蓝色', purple: '紫色', gray: '灰色', showToolbar: '显示选区工具条', toggleAutomatic: '切换自动显示工具条', automaticOn: '已开启自动显示工具条。', automaticOff: '已关闭自动显示工具条。', - unsupported: '请选择完整的单行文字片段,避免混合格式。', codeOnly: '行内代码仅支持取消代码格式。', - plainOnly: '此操作需要选择未设置格式的文字。', tooLong: '文字及格式源码不能超过 10,000 个字符。', - noSelection: '请先在已启用的 Markdown 编辑器中选择文字。', stale: '文字或笔记已变化,请重新选择。', + unsupported: '请选择完整文字或完整格式片段,避免截断或混合格式。', codeOnly: '行内代码仅支持取消代码格式。', + modeDisabled: '当前编辑模式未启用 Hover Bar,请在插件设置中开启。', + readOnly: '当前笔记不可编辑,请切换到可编辑的笔记。', + composing: '请先完成输入法输入,再重新选择文字。', + multipleSelections: '暂不支持多个选区,请保留一个连续选区。', emptySelection: '请先选择非空白文字。', + multipleLines: '暂不支持跨行排版,请选择同一行中的文字。', + syntaxPending: '暂时无法确认这段内容的格式,请稍后重新选择。', + protectedSyntax: '当前选区包含受保护的语法或结构标记,请只选择普通文字内容。', + plainOnly: '此操作需要选择未设置格式的文字。', tooLong: '所选内容及格式源码超过长度限制,请缩小选区;所在行过长时请先拆分段落。', + noSelection: '请先在 Markdown 笔记的编辑模式中选择文字。', stale: '文字或笔记已变化,请重新选择。', noSpace: '当前可视区域不足以放置工具条,请调整窗格大小或滚动。', failed: '无法应用此修改,请重新选择文字。', invalidLink: '这段文字无法转换为有效的内部链接。', searchNotes: '搜索笔记', noNotes: '没有找到匹配的笔记,请调整搜索内容。', searchHint: '最多显示 50 篇笔记,可继续输入以缩小范围。', searching: '正在查找笔记…', searchError: '无法读取笔记列表,请重试。', - close: '关闭', back: '返回', automatic: '自动显示工具条', - automaticDesc: '使用鼠标、触控板或触摸屏选中文字后显示排版操作。', livePreview: '在实时预览中启用', + back: '返回', automatic: '自动显示工具条', + automaticDesc: '使用鼠标或触控板选中文字后显示排版操作。', livePreview: '在实时预览中启用', livePreviewDesc: '在实时预览编辑模式中使用工具条。', sourceMode: '在源码模式中启用', sourceModeDesc: '在编辑 Markdown 源码时使用工具条。', keyboard: '键盘选择后自动显示', keyboardDesc: '使用键盘扩展选区时也自动显示工具条。', reset: '重置偏好', diff --git a/src/links/wikilink.ts b/src/links/wikilink.ts index a002946..94d1cea 100644 --- a/src/links/wikilink.ts +++ b/src/links/wikilink.ts @@ -1,16 +1,27 @@ -import { MAX_SOURCE, type EditPlan, type LocatedFragment } from '../formatting/types'; +import { MAX_SOURCE, type EditPlan, type Fragment, type LocatedFragment } from '../formatting/types'; + +export type LinkUnavailableReason = 'codeOnly' | 'plainOnly' | 'invalidText' | 'invalidTarget' | 'tooLong'; +export type LinkResult = { readonly kind: 'edit'; readonly plan: EditPlan } | { readonly kind: 'unavailable'; readonly reason: LinkUnavailableReason }; export function safeLinkText(value: string): boolean { return !!value.trim() && value === value.trim() && !/[[\]|#^\r\n\0<>\\]/.test(value); } -export function linkPlan(located: LocatedFragment, source: string, target: string): EditPlan | null { +export function linkTextRejection(fragment: Fragment): LinkUnavailableReason | null { + if (fragment.code) return 'codeOnly'; + if (fragment.marks.length || fragment.foreground || fragment.background) return 'plainOnly'; + return safeLinkText(fragment.text) ? null : 'invalidText'; +} + +export function linkPlan(located: LocatedFragment, source: string, target: string): LinkResult { const fragment = located.fragment; - if (fragment.code || fragment.marks.length || fragment.foreground || fragment.background || !safeLinkText(target) || !safeLinkText(fragment.text)) return null; + const reason = linkTextRejection(fragment); + if (reason) return { kind: 'unavailable', reason }; + if (!safeLinkText(target)) return { kind: 'unavailable', reason: 'invalidTarget' }; const alias = fragment.text === target ? '' : `|${fragment.text}`; const prefix = `${fragment.leading}[[${target}${alias ? '|' : ''}`; const insert = `${fragment.leading}[[${target}${alias}]]${fragment.trailing}`; - if (insert.length > MAX_SOURCE) return null; + if (insert.length > MAX_SOURCE) return { kind: 'unavailable', reason: 'tooLong' }; const contentFrom = alias ? prefix.length : fragment.leading.length + 2; - return { from: located.from, to: located.to, expected: source.slice(located.from, located.to), insert, contentFrom, contentTo: contentFrom + (alias ? fragment.text.length : target.length) }; + return { kind: 'edit', plan: { from: located.from, to: located.to, expected: source.slice(located.from, located.to), insert, contentFrom, contentTo: contentFrom + (alias ? fragment.text.length : target.length) } }; } diff --git a/src/main.ts b/src/main.ts index c83f0d9..f5f4645 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { Plugin, Notice, getLanguage } from 'obsidian'; import { ViewPlugin } from '@codemirror/view'; import { translator } from './i18n'; -import { EditorController, type Host } from './editor/controller'; +import { EditorController, type CloseReason, type Host } from './editor/controller'; import { revisionField } from './editor/context'; import { colorPreview } from './editor/color-preview'; import { PreferencesStore } from './settings/store'; @@ -22,7 +22,7 @@ export default class HoverBarPlugin extends Plugin { this.addSettingTab(new HoverBarSettingsTab(this.app, this, this.store, this.t)); let lastStatus = this.store.status; this.register(this.store.subscribe(() => { - for (const controller of this.controllers) controller.close(false); + for (const controller of this.controllers) controller.close('modeChanged'); this.app.workspace.updateOptions(); if (lastStatus !== this.store.status && this.store.status !== 'ready') { new Notice(this.t(this.store.status === 'conflict' ? 'settingsConflict' : this.store.status)); @@ -34,7 +34,7 @@ export default class HoverBarPlugin extends Plugin { if (!controller) new Notice(this.t('noSelection')); else controller.show(true); } }); this.addCommand({ id: 'toggle-automatic-toolbar', name: this.t('toggleAutomatic'), callback: async () => { - if (!this.store.enabled) { new Notice(this.t(this.store.status === 'schemaError' ? 'schemaError' : 'loadError')); return; } + if (!this.store.enabled) { new Notice(this.t(this.store.status === 'conflict' ? 'settingsConflict' : this.store.status === 'schemaError' ? 'schemaError' : 'loadError')); return; } await this.store.set('automatic', !this.store.value.automatic); new Notice(this.t(this.store.value.automatic ? 'automaticOn' : 'automaticOff')); } }); @@ -42,12 +42,12 @@ export default class HoverBarPlugin extends Plugin { this.registerEvent(this.app.workspace.on('layout-change', () => { for (const controller of this.controllers) controller.reconcile(); })); - this.registerEvent(this.app.vault.on('rename', () => this.closeAll())); - this.registerEvent(this.app.vault.on('delete', () => this.closeAll())); + this.registerEvent(this.app.vault.on('rename', () => this.closeAll('stale'))); + this.registerEvent(this.app.vault.on('delete', () => this.closeAll('stale'))); if (!this.store.enabled) new Notice(this.t(this.store.status === 'schemaError' ? 'schemaError' : 'loadError')); } - private closeAll(): void { for (const controller of this.controllers) controller.close(false); } + private closeAll(reason: CloseReason = 'outside'): void { for (const controller of this.controllers) controller.close(reason); } override async onExternalSettingsChange(): Promise { if (this.store && !this.unloaded) await this.store.externalChange(); } override onunload(): void { this.unloaded = true; for (const controller of [...this.controllers]) controller.destroy(); } } diff --git a/src/ui/note-picker.ts b/src/ui/note-picker.ts new file mode 100644 index 0000000..d455bd3 --- /dev/null +++ b/src/ui/note-picker.ts @@ -0,0 +1,81 @@ +import type { Translate } from '../i18n'; +import type { NoteChoice, ToolbarActions } from './toolbar-types'; + +export class NotePicker { + private abort: AbortController | null = null; + private generation = 0; + private disposed = false; + + constructor(document: Document, panel: HTMLElement, t: Translate, actions: ToolbarActions) { + const input = document.createElement('input'); + input.type = 'search'; input.placeholder = t('searchNotes'); input.setAttribute('aria-label', t('searchNotes')); + input.className = 'hover-bar-search'; + const list = document.createElement('div'); list.className = 'hover-bar-notes'; + list.setAttribute('role', 'listbox'); list.setAttribute('aria-label', t('searchNotes')); + list.id = `hover-bar-notes-${Math.random().toString(36).slice(2)}`; + input.setAttribute('role', 'combobox'); input.setAttribute('aria-controls', list.id); + input.setAttribute('aria-expanded', 'true'); input.setAttribute('aria-autocomplete', 'list'); + const hint = document.createElement('div'); hint.className = 'hover-bar-hint'; hint.textContent = t('searchHint'); + let choices: readonly NoteChoice[] = []; let options: HTMLElement[] = []; let active = 0; + let composing = false; let composingEnterHeld = false; + const select = () => { + for (const [index, option] of options.entries()) option.setAttribute('aria-selected', String(index === active)); + const option = options[active]; + if (option) { input.setAttribute('aria-activedescendant', option.id); option.scrollIntoView({ block: 'nearest' }); } + else input.removeAttribute('aria-activedescendant'); + }; + const confirm = (choice: NoteChoice) => { + if (this.disposed) return; + if (choice.disabledReason) actions.explain(choice.disabledReason); else actions.selectNote(choice.path); + }; + const search = async () => { + if (this.disposed) return; + this.abort?.abort(); this.abort = new AbortController(); + const id = ++this.generation; + choices = []; options = []; input.removeAttribute('aria-activedescendant'); + list.setAttribute('aria-busy', 'true'); list.textContent = t('searching'); + try { + const results = await actions.notes(input.value, this.abort.signal); + if (this.disposed || id !== this.generation) return; + choices = results; + } catch { + if (this.disposed || id !== this.generation) return; + list.setAttribute('aria-busy', 'false'); list.textContent = t('searchError'); + const retry = document.createElement('button'); retry.type = 'button'; retry.textContent = t('retry'); + retry.addEventListener('click', () => { void search(); }); list.append(retry); actions.resize(); return; + } + list.setAttribute('aria-busy', 'false'); list.replaceChildren(); active = 0; + for (const [index, note] of choices.entries()) { + const row = document.createElement('div'); row.className = 'hover-bar-note'; row.id = `${list.id}-${index}`; + row.setAttribute('role', 'option'); row.setAttribute('aria-disabled', String(!!note.disabledReason)); + row.setAttribute('aria-label', [note.name, note.path, note.disabledReason].filter(Boolean).join(' · ')); + const name = document.createElement('strong'); name.textContent = note.name; + const path = document.createElement('small'); path.textContent = note.path; + row.append(name, path); row.addEventListener('click', () => confirm(note)); list.append(row); options.push(row); + } + if (!choices.length) { const empty = document.createElement('p'); empty.textContent = t('noNotes'); list.append(empty); } + select(); actions.resize(); + }; + input.addEventListener('compositionstart', () => { composing = true; }); + input.addEventListener('compositionend', () => { composing = false; }); + input.addEventListener('keyup', event => { if (event.key === 'Enter') composingEnterHeld = false; }); + input.addEventListener('input', () => { void search(); }); + input.addEventListener('keydown', event => { + // Some IMEs end composition before dispatching their confirmation keydown. + if (composing || event.isComposing || event.keyCode === 229) { + if (event.key === 'Enter') composingEnterHeld = true; + event.stopPropagation(); return; + } + if (event.key === 'Enter' && composingEnterHeld) { event.stopPropagation(); return; } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); event.stopPropagation(); + active = Math.max(0, Math.min(choices.length - 1, active + (event.key === 'ArrowDown' ? 1 : -1))); select(); + } else if (event.key === 'Enter') { + event.preventDefault(); event.stopPropagation(); const choice = choices[active]; if (choice) confirm(choice); + } + }); + panel.append(input, list, hint); void search(); input.focus({ preventScroll: true }); actions.resize(); + } + + cancel(): void { this.disposed = true; this.generation++; this.abort?.abort(); this.abort = null; } +} diff --git a/src/ui/position.ts b/src/ui/position.ts index 08d6a67..e2ffc9c 100644 --- a/src/ui/position.ts +++ b/src/ui/position.ts @@ -7,17 +7,6 @@ export function viewportBounds(width: number, height: number, viewport?: Pick bounds.right - bounds.left - gap * 2 || height > bounds.bottom - bounds.top - gap * 2) return null; - let top = bounds.bottom - height - gap; - if (anchor && top < anchor.bottom + 24) { - top = bounds.top + gap; - if (top + height > anchor.top - 24) return null; - } - return { left: bounds.left + (bounds.right - bounds.left - width) / 2, top }; -} - export function place(anchor: Box, bounds: Box, width: number, height: number, gap = 8): Position | null { const leftEdge = bounds.left + gap; const rightEdge = bounds.right - gap; diff --git a/src/ui/toolbar-types.ts b/src/ui/toolbar-types.ts new file mode 100644 index 0000000..4469f6c --- /dev/null +++ b/src/ui/toolbar-types.ts @@ -0,0 +1,21 @@ +// View intent IDs evolve at the UI boundary; the editor revalidates them as domain actions. +export type FormatId = 'bold' | 'italic' | 'strike' | 'code' | 'highlight' | 'foreground' | 'background' | 'clearColors'; +export type ToolbarId = FormatId | 'link' | 'chooseNote' | 'more'; +export type PaletteKind = 'foreground' | 'background'; +export interface ActionView { readonly enabled: boolean; readonly pressed?: boolean; readonly reason?: string } +export interface SwatchView extends ActionView { readonly id: string; readonly color: string; readonly label: string } +export interface ToolbarViewModel { + readonly controls: Readonly>; + readonly palettes: Readonly>; + readonly resets: Readonly>; +} +export interface NoteChoice { readonly path: string; readonly name: string; readonly disabledReason?: string } +export interface ToolbarActions { + format(action: FormatId, color?: string): void; + explain(reason: string): void; + quickLink(): void; + notes(query: string, signal: AbortSignal): Promise; + selectNote(path: string): void; + close(): void; + resize(): void; +} diff --git a/src/ui/toolbar.ts b/src/ui/toolbar.ts index f7c6ba6..fbd74e1 100644 --- a/src/ui/toolbar.ts +++ b/src/ui/toolbar.ts @@ -1,41 +1,25 @@ import { setIcon } from 'obsidian'; import type { Translate, TextKey } from '../i18n'; -import type { ColorKind, FormatAction, Fragment } from '../formatting/types'; -import { PALETTE } from '../formatting/colors'; -import type { Box } from './position'; -import { enableTouchActions } from './touch'; +import { NotePicker } from './note-picker'; +import type { ActionView, FormatId, PaletteKind, ToolbarActions, ToolbarId, ToolbarViewModel } from './toolbar-types'; -export interface NoteChoice { readonly path: string; readonly name: string; readonly disabled?: boolean } -export interface ToolbarActions { - available(action: FormatAction, color?: string): boolean; - format(action: FormatAction, color?: string): void; - quickLink(): void; - notes(query: string, signal: AbortSignal): Promise; - selectNote(path: string): void; - close(): void; - resize(): void; -} - -const buttons: readonly [TextKey, string][] = [ +const buttons: readonly [ToolbarId, string][] = [ ['bold', 'bold'], ['italic', 'italic'], ['strike', 'strikethrough'], ['code', 'code'], ['highlight', 'highlighter'], ['foreground', 'baseline'], ['background', 'paint-bucket'], ['link', 'link'], ['chooseNote', 'chevron-down'], ['more', 'ellipsis'], ]; +type PanelId = PaletteKind | 'more' | 'chooseNote'; export class Toolbar { readonly root: HTMLDivElement; readonly bar: HTMLDivElement; private panel: HTMLDivElement; - private controls = new Map(); - private fragment: Fragment | null = null; - private openPanel: TextKey | null = null; - private queryAbort: AbortController | null = null; + private controls = new Map(); + private model: ToolbarViewModel | null = null; + private openPanel: PanelId | null = null; private returnTo: HTMLButtonElement | null = null; - private queryId = 0; + private picker: NotePicker | null = null; private compact = false; - private touch = false; - private constraints = ''; - private dismiss: HTMLButtonElement; constructor(private readonly document: Document, private readonly t: Translate, private readonly actions: ToolbarActions) { this.root = document.createElement('div'); @@ -44,20 +28,15 @@ export class Toolbar { this.bar.setAttribute('role', 'toolbar'); this.bar.setAttribute('aria-label', t('toolbar')); this.panel = document.createElement('div'); this.panel.className = 'hover-bar-panel'; this.panel.hidden = true; this.root.append(this.bar, this.panel); - const strip = document.createElement('div'); strip.className = 'hover-bar-actions'; - this.bar.append(strip); for (const [key, icon] of buttons) { - const button = this.button(key, icon); - button.dataset.action = key; + const button = this.button(key, icon); button.dataset.action = key; if (['italic', 'code', 'highlight'].includes(key)) button.classList.add('hover-bar-secondary'); if (key === 'foreground' || key === 'link') button.classList.add('hover-bar-group-start'); button.addEventListener('click', () => this.activate(key, button)); - this.controls.set(key, button); strip.append(button); + this.controls.set(key, button); this.bar.append(button); } - this.dismiss = this.button('close', 'x'); this.dismiss.hidden = true; this.dismiss.tabIndex = -1; - this.dismiss.addEventListener('click', () => this.actions.close()); this.bar.append(this.dismiss); this.root.addEventListener('pointerdown', event => { - // Keep focus inside the toolbar until a button or note option handles its click. + // Retain the editor selection without changing native search input behavior. if (event.button === 0 && event.composedPath().some(node => (node as Element).tagName === 'BUTTON' || (node as Element).getAttribute?.('role') === 'option')) event.preventDefault(); }); @@ -65,30 +44,30 @@ export class Toolbar { this.root.addEventListener('contextmenu', event => { if (!event.composedPath().some(node => (node as Element).tagName === 'INPUT')) event.preventDefault(); }); - enableTouchActions(this.root); document.body.append(this.root); } private button(key: TextKey, icon?: string): HTMLButtonElement { const button = this.document.createElement('button'); - button.type = 'button'; button.className = 'hover-bar-button'; - button.setAttribute('aria-label', this.t(key)); + button.type = 'button'; button.className = 'hover-bar-button'; button.setAttribute('aria-label', this.t(key)); if (icon) setIcon(button, icon); else button.textContent = this.t(key); return button; } - update(fragment: Fragment): void { - this.fragment = fragment; + private state(button: HTMLButtonElement, state: ActionView, label: string): void { + button.setAttribute('aria-disabled', String(!state.enabled)); + button.setAttribute('aria-label', !state.enabled && state.reason ? `${label} · ${state.reason}` : label); + if (state.pressed !== undefined) button.setAttribute('aria-pressed', String(state.pressed)); + } + + private execute(state: ActionView, action: () => void): void { + if (!state.enabled) this.actions.explain(state.reason ?? this.t('failed')); else action(); + } + + update(model: ToolbarViewModel): void { + this.model = model; for (const [key, button] of this.controls) { - const plain = !fragment.code && !fragment.marks.length && !fragment.foreground && !fragment.background; - const requiresPlain = key === 'code' && !fragment.code || ['link', 'chooseNote'].includes(key); - const unsupported = ['bold', 'italic', 'strike', 'code', 'highlight'].includes(key) && !this.actions.available(key as FormatAction); - const reason = fragment.code && key !== 'code' ? 'codeOnly' : requiresPlain && !plain ? 'plainOnly' : unsupported ? 'tooLong' : null; - button.setAttribute('aria-disabled', String(reason !== null)); - const label = this.t(key); - button.setAttribute('aria-label', reason ? `${label} · ${this.t(reason)}` : label); - const active = key === 'code' ? fragment.code : key === 'foreground' || key === 'background' ? !!fragment[key] : fragment.marks.some(mark => mark === key); - if (['bold', 'italic', 'strike', 'code', 'highlight', 'foreground', 'background'].includes(key)) button.setAttribute('aria-pressed', String(active)); + this.state(button, model.controls[key], this.t(key)); if (['foreground', 'background', 'chooseNote', 'more'].includes(key)) { button.setAttribute('aria-haspopup', key === 'chooseNote' ? 'dialog' : 'true'); button.setAttribute('aria-expanded', String(this.openPanel === key)); @@ -97,174 +76,104 @@ export class Toolbar { this.roving(); } - show(compact: boolean, touch = false): void { - this.touch = touch; this.compact = compact && !touch; - this.root.classList.toggle('is-compact', this.compact); this.root.classList.toggle('is-touch', touch); - this.dismiss.hidden = !touch; + show(compact: boolean): void { + this.compact = compact; this.root.classList.toggle('is-compact', compact); this.root.hidden = false; this.root.classList.add('is-measuring'); this.roving(); } - constrain(bounds: Box): boolean { - const width = Math.max(0, bounds.right - bounds.left - 16); - const height = Math.max(0, bounds.bottom - bounds.top - 16); - const key = `${width}:${height}`; - if (key === this.constraints) return false; - this.constraints = key; - this.root.style.setProperty('--hover-bar-available-width', `${width}px`); - this.root.style.setProperty('--hover-bar-available-height', `${height}px`); - return true; - } positioned(left: number, top: number): void { - this.root.style.setProperty('--hover-bar-left', `${left}px`); - this.root.style.setProperty('--hover-bar-top', `${top}px`); + this.root.style.setProperty('--hover-bar-left', `${left}px`); this.root.style.setProperty('--hover-bar-top', `${top}px`); this.root.classList.remove('is-measuring'); } - focus(): void { this.focusable(this.bar)[0]?.focus({ preventScroll: true }); } + focus(): void { + const items = this.focusable(this.bar); + (items.find(item => item.getAttribute('aria-disabled') !== 'true') ?? items[0])?.focus({ preventScroll: true }); + } contains(event: Event): boolean { return event.composedPath().includes(this.root); } ownsFocus(): boolean { return this.root.contains(this.document.activeElement); } get visible(): boolean { return !this.root.hidden; } - get expanded(): boolean { return this.openPanel !== null; } hide(): void { this.closePanel(false); this.root.hidden = true; } destroy(): void { this.hide(); this.root.remove(); } - private activate(key: TextKey, button: HTMLButtonElement): void { - if (button.getAttribute('aria-disabled') === 'true') return; - if (this.openPanel && !['foreground', 'background', 'more', 'chooseNote'].includes(key)) { - const focus = !this.touch || this.ownsFocus(); - this.closePanel(false); if (focus) button.focus({ preventScroll: true }); - } - if (key === 'foreground' || key === 'background') this.palette(key, button); - else if (key === 'more') this.more(button); - else if (key === 'chooseNote') this.notePicker(button); - else if (key === 'link') this.actions.quickLink(); - else this.actions.format(key as FormatAction); + private activate(key: ToolbarId, button: HTMLButtonElement): void { + if (!this.model) return; + this.execute(this.model.controls[key], () => { + if (this.openPanel && !['foreground', 'background', 'more', 'chooseNote'].includes(key)) { + this.closePanel(false); button.focus({ preventScroll: true }); + } + if (key === 'foreground' || key === 'background') this.palette(key, button); + else if (key === 'more') this.more(button); + else if (key === 'chooseNote') this.notePicker(button); + else if (key === 'link') this.actions.quickLink(); + else this.actions.format(key as FormatId); + }); } - private startPanel(key: TextKey, button: HTMLButtonElement): boolean { + private startPanel(key: PanelId, button: HTMLButtonElement): boolean { if (this.openPanel === key) { this.closePanel(true); return false; } - // Move focus before removing the old search input so focusout never sees an unowned session. + // Transfer focus before removing a search input, so focusout retains session ownership. if (this.ownsFocus()) button.focus({ preventScroll: true }); this.closePanel(false); this.openPanel = key; this.returnTo = button; this.panel.hidden = false; this.panel.replaceChildren(); - this.panel.setAttribute('role', key === 'chooseNote' ? 'dialog' : 'group'); - this.panel.setAttribute('aria-label', this.t(key)); - if (this.fragment) this.update(this.fragment); + this.panel.setAttribute('role', key === 'chooseNote' ? 'dialog' : 'group'); this.panel.setAttribute('aria-label', this.t(key)); + if (this.model) this.update(this.model); return true; } closePanel(restoreFocus: boolean): void { - const focus = !this.touch || this.ownsFocus(); - this.queryAbort?.abort(); this.queryAbort = null; this.queryId++; - const returnTo = this.returnTo; - this.openPanel = null; this.returnTo = null; + this.picker?.cancel(); this.picker = null; + const returnTo = this.returnTo; this.openPanel = null; this.returnTo = null; this.panel.hidden = true; this.panel.replaceChildren(); for (const button of this.controls.values()) button.setAttribute('aria-expanded', 'false'); - if (restoreFocus && focus) returnTo?.focus({ preventScroll: true }); + if (restoreFocus && returnTo?.isConnected) returnTo.focus({ preventScroll: true }); if (this.visible) this.actions.resize(); } - private palette(kind: ColorKind, button: HTMLButtonElement): void { - if (!this.startPanel(kind, button)) return; + private palette(kind: PaletteKind, button: HTMLButtonElement): void { + if (!this.model || !this.startPanel(kind, button)) return; const grid = this.document.createElement('div'); grid.className = 'hover-bar-palette'; grid.setAttribute('role', 'group'); grid.setAttribute('aria-label', this.t(kind)); - for (const color of PALETTE) { - const swatch = this.button(color.id); - swatch.className = `hover-bar-swatch hover-bar-${kind}-${color.id}`; - const label = `${this.t(color.id)} · ${color[kind]}`; - const selected = this.fragment?.[kind] === color[kind]; - const disabled = !this.actions.available(kind, color[kind]); - swatch.setAttribute('aria-disabled', String(disabled)); - swatch.setAttribute('aria-label', disabled ? `${label} · ${this.t('tooLong')}` : label); - swatch.setAttribute('aria-pressed', String(selected)); - if (selected) setIcon(swatch, 'check'); - swatch.addEventListener('click', () => { if (!disabled) { this.actions.format(kind, color[kind]); this.closePanel(true); } }); + for (const color of this.model.palettes[kind]) { + const swatch = this.button(kind); swatch.textContent = ''; + swatch.className = `hover-bar-swatch hover-bar-${kind}-${color.id}`; this.state(swatch, color, color.label); + if (color.pressed) setIcon(swatch, 'check'); + swatch.addEventListener('click', () => this.execute(color, () => { this.actions.format(kind, color.color); this.closePanel(true); })); grid.append(swatch); } - const reset = this.button(kind === 'foreground' ? 'resetForeground' : 'resetBackground'); - reset.classList.add('hover-bar-menu-item'); - reset.addEventListener('click', () => { this.actions.format(kind); this.closePanel(true); }); - this.panel.append(grid, reset); this.actions.resize(); - if (!this.touch || this.ownsFocus()) grid.querySelector('button')?.focus({ preventScroll: true }); + const resetKey = kind === 'foreground' ? 'resetForeground' : 'resetBackground'; + const reset = this.button(resetKey); reset.classList.add('hover-bar-menu-item'); + const resetState = this.model.resets[kind]; this.state(reset, resetState, this.t(resetKey)); + reset.addEventListener('click', () => this.execute(resetState, () => { this.actions.format(kind); this.closePanel(true); })); + this.panel.append(grid, reset); this.actions.resize(); this.focusable(this.panel)[0]?.focus({ preventScroll: true }); } private more(button: HTMLButtonElement): void { - if (!this.startPanel('more', button)) return; - const keys: (FormatAction & TextKey)[] = this.compact ? ['italic', 'code', 'highlight', 'clearColors'] : ['clearColors']; + if (!this.model || !this.startPanel('more', button)) return; + const keys: FormatId[] = this.compact ? ['italic', 'code', 'highlight', 'clearColors'] : ['clearColors']; for (const key of keys) { const item = this.button(key); item.classList.add('hover-bar-menu-item'); - const disabled = !this.actions.available(key); - item.setAttribute('aria-disabled', String(disabled)); - item.addEventListener('click', () => { if (!disabled) { this.actions.format(key); this.closePanel(true); } }); + const state = this.model.controls[key]; this.state(item, state, this.t(key)); + item.addEventListener('click', () => this.execute(state, () => { this.actions.format(key); this.closePanel(true); })); this.panel.append(item); } - this.actions.resize(); - if (!this.touch || this.ownsFocus()) this.focusable(this.panel)[0]?.focus({ preventScroll: true }); + this.actions.resize(); this.focusable(this.panel)[0]?.focus({ preventScroll: true }); } notePicker(button = this.controls.get('chooseNote')!): void { if (!this.startPanel('chooseNote', button)) return; - const input = this.document.createElement('input'); - input.type = 'search'; input.placeholder = this.t('searchNotes'); input.setAttribute('aria-label', this.t('searchNotes')); - input.className = 'hover-bar-search'; - const list = this.document.createElement('div'); list.className = 'hover-bar-notes'; - list.setAttribute('role', 'listbox'); list.setAttribute('aria-label', this.t('searchNotes')); - list.id = `hover-bar-notes-${Math.random().toString(36).slice(2)}`; - input.setAttribute('role', 'combobox'); input.setAttribute('aria-controls', list.id); - input.setAttribute('aria-expanded', 'true'); input.setAttribute('aria-autocomplete', 'list'); - const hint = this.document.createElement('div'); hint.className = 'hover-bar-hint'; hint.textContent = this.t('searchHint'); - let choices: readonly NoteChoice[] = []; let active = 0; - const select = () => { - for (let i = 0; i < list.children.length; i++) list.children[i]?.setAttribute('aria-selected', String(i === active)); - const option = list.children[active]; - if (option) { input.setAttribute('aria-activedescendant', option.id); option.scrollIntoView({ block: 'nearest' }); } - else input.removeAttribute('aria-activedescendant'); - }; - const search = async () => { - this.queryAbort?.abort(); this.queryAbort = new AbortController(); - const id = ++this.queryId; - list.setAttribute('aria-busy', 'true'); list.textContent = this.t('searching'); - choices = []; input.removeAttribute('aria-activedescendant'); - try { choices = await this.actions.notes(input.value, this.queryAbort.signal); } - catch { - if (id !== this.queryId) return; - list.setAttribute('aria-busy', 'false'); list.textContent = this.t('searchError'); - const retry = this.button('retry'); retry.addEventListener('click', () => { void search(); }); - list.append(retry); this.actions.resize(); return; - } - if (id !== this.queryId) return; - list.setAttribute('aria-busy', 'false'); - list.replaceChildren(); active = 0; - for (const [index, note] of choices.entries()) { - const row = this.document.createElement('div'); row.className = 'hover-bar-note'; - row.id = `${list.id}-${index}`; row.setAttribute('role', 'option'); - row.setAttribute('aria-disabled', String(!!note.disabled)); - if (note.disabled) row.setAttribute('aria-label', `${note.name} · ${note.path} · ${this.t('invalidLink')}`); - const name = this.document.createElement('strong'); name.textContent = note.name; - const path = this.document.createElement('small'); path.textContent = note.path; - row.append(name, path); row.addEventListener('click', () => { if (!note.disabled) this.actions.selectNote(note.path); }); list.append(row); - } - if (!choices.length) { const empty = this.document.createElement('p'); empty.textContent = this.t('noNotes'); list.append(empty); } - select(); this.actions.resize(); - }; - input.addEventListener('input', () => { void search(); }); - input.addEventListener('keydown', event => { - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault(); event.stopPropagation(); - active = Math.max(0, Math.min(choices.length - 1, active + (event.key === 'ArrowDown' ? 1 : -1))); select(); - } else if (event.key === 'Enter') { - event.preventDefault(); const choice = choices[active]; if (choice && !choice.disabled) this.actions.selectNote(choice.path); - } - }); - this.panel.append(input, list, hint); void search(); input.focus({ preventScroll: true }); this.actions.resize(); + this.picker = new NotePicker(this.document, this.panel, this.t, this.actions); } private focusable(container: HTMLElement): HTMLButtonElement[] { - return [...container.querySelectorAll('button')].filter(button => !button.hidden && button.getAttribute('aria-disabled') !== 'true' && !(this.compact && button.classList.contains('hover-bar-secondary') && button.getAttribute('aria-pressed') !== 'true')); + return [...container.querySelectorAll('button')].filter(button => !button.hidden && + !(this.compact && button.classList.contains('hover-bar-secondary') && button.getAttribute('aria-pressed') !== 'true')); } private roving(): void { - const focusable = this.focusable(this.bar); - for (const button of this.controls.values()) button.tabIndex = button === focusable[0] ? 0 : -1; + const items = this.focusable(this.bar); + const selected = items.find(item => item === this.document.activeElement) ?? items.find(item => item.getAttribute('aria-disabled') !== 'true') ?? items[0]; + for (const button of this.controls.values()) button.tabIndex = button === selected ? 0 : -1; } private onKey(event: KeyboardEvent): void { + if (event.isComposing) return; if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); if (this.openPanel) this.closePanel(true); else this.actions.close(); diff --git a/src/ui/touch.ts b/src/ui/touch.ts deleted file mode 100644 index c0e3b37..0000000 --- a/src/ui/touch.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Keep the editor selection while activating buttons in both WebKit and Chromium. */ -export function enableTouchActions(root: HTMLElement): void { - let tap: { target: HTMLElement; id: number; x: number; y: number; moved: boolean } | null = null; - root.addEventListener('touchstart', event => { - tap = null; - const point = event.touches[0]; - if (!point || event.touches.length !== 1) return; - const target = event.composedPath().find(node => - (node as Element).tagName === 'BUTTON' || (node as Element).getAttribute?.('role') === 'option') as HTMLElement | undefined; - if (target) tap = { target, id: point.identifier, x: point.clientX, y: point.clientY, moved: false }; - }, { passive: true }); - root.addEventListener('touchmove', event => { - if (!tap) return; - const point = [...event.touches].find(point => point.identifier === tap!.id); - if (!point || Math.hypot(point.clientX - tap.x, point.clientY - tap.y) > 10) tap.moved = true; - }, { passive: true }); - root.addEventListener('touchcancel', () => { tap = null; }, { passive: true }); - root.addEventListener('touchend', event => { - const completed = tap; tap = null; - if (!completed || completed.moved || event.touches.length || !completed.target.isConnected) return; - const point = [...event.changedTouches].find(point => point.identifier === completed.id); - if (!point || Math.hypot(point.clientX - completed.x, point.clientY - completed.y) > 10) return; - // WebKit suppresses click after a cancelled pointerdown. Activate explicitly and - // cancel the compatibility click so Chromium does not apply a toggle twice. - event.preventDefault(); completed.target.click(); - }, { passive: false }); -} diff --git a/styles.css b/styles.css index 6256f99..5bd6e5d 100644 --- a/styles.css +++ b/styles.css @@ -26,7 +26,6 @@ background: var(--hover-bar-surface); box-shadow: 0 5px 18px #0003, 0 1px 3px #0002; } -.hover-bar-actions { display: flex; align-items: center; gap: 2px; } .hover-bar-layer button.hover-bar-button { display: inline-flex; align-items: center; @@ -110,33 +109,6 @@ .hover-bar-note strong { font-weight: 550; } .hover-bar-note small { overflow: hidden; color: var(--hover-bar-muted); white-space: nowrap; text-overflow: ellipsis; } .hover-bar-hint, .hover-bar-notes p { margin: 8px 4px 2px; color: var(--hover-bar-muted); font-size: 11px; } -/* Dock touch controls away from the native selection menu; keep every action reachable by swiping. */ -.hover-bar-layer.is-touch { - display: flex; - flex-direction: column-reverse; - width: min(480px, var(--hover-bar-available-width, calc(100vw - 16px))); - max-width: var(--hover-bar-available-width, calc(100vw - 16px)); - max-height: var(--hover-bar-available-height, calc(100vh - 16px)); - user-select: none; - -webkit-user-select: none; - -webkit-touch-callout: none; -} -.hover-bar-layer.is-touch .hover-bar { flex-shrink: 0; min-width: 0; padding-bottom: max(4px, env(safe-area-inset-bottom)); } -.hover-bar-layer.is-touch .hover-bar-actions { flex: 1; min-width: 0; overflow-x: auto; overscroll-behavior-x: contain; } -.hover-bar-layer.is-touch button.hover-bar-button { width: 44px; height: 44px; touch-action: manipulation; } -.hover-bar-layer.is-touch .hover-bar-actions .hover-bar-button { margin-left: 0; } -.hover-bar-layer.is-touch .hover-bar-panel { width: 100%; min-height: 0; margin: 0 0 6px; overflow-y: auto; overscroll-behavior: contain; } -.hover-bar-layer.is-touch button.hover-bar-swatch, -.hover-bar-layer.is-touch button.hover-bar-menu-item, -.hover-bar-layer.is-touch .hover-bar-search, -.hover-bar-layer.is-touch .hover-bar-note { min-height: 44px; touch-action: manipulation; } -.hover-bar-layer.is-touch button.hover-bar-menu-item { width: 100%; height: auto; } -.hover-bar-layer.is-touch .hover-bar-search { - font-size: 16px; - user-select: text; - -webkit-user-select: text; - -webkit-touch-callout: default; -} .hover-bar-foreground-red { --hover-bar-swatch: #e05252; } .hover-bar-foreground-orange { --hover-bar-swatch: #c66b2b; } .hover-bar-foreground-yellow { --hover-bar-swatch: #9a7b16; } diff --git a/tests/controller.test.ts b/tests/controller.test.ts index 9d3edb1..6282311 100644 --- a/tests/controller.test.ts +++ b/tests/controller.test.ts @@ -1,29 +1,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { App, TFile } from 'obsidian'; +import type { App, MarkdownFileInfo, TFile } from 'obsidian'; import type { EditorView } from '@codemirror/view'; import { EditorState } from '@codemirror/state'; import type { ViewUpdate } from '@codemirror/view'; -import { Platform } from './support/obsidian'; import { EditorController } from '../src/editor/controller'; -import type { SelectionSnapshot } from '../src/editor/context'; +import { editorInfoField } from './support/obsidian'; +import type { CaptureResult, SelectionSnapshot } from '../src/editor/context'; import { locateFragment } from '../src/formatting/fragment'; import { PreferencesStore } from '../src/settings/store'; import type { Box, Position } from '../src/ui/position'; -const scenario = vi.hoisted(() => ({ snapshot: undefined as SelectionSnapshot | undefined, resize: () => {} })); +const scenario = vi.hoisted(() => ({ snapshot: undefined as SelectionSnapshot | undefined, blocked: undefined as CaptureResult | undefined, matches: true, resize: () => {} })); vi.mock('../src/editor/context', async importOriginal => ({ ...await importOriginal(), - capture: () => scenario.snapshot, + capture: () => scenario.blocked ?? { kind: 'ready', snapshot: scenario.snapshot }, + matchesSnapshot: () => scenario.matches, })); vi.mock('../src/ui/toolbar', () => ({ Toolbar: class { root = Object.assign(new EventTarget(), { getBoundingClientRect: () => ({ width: 352, height: 42 }) }); visible = false; - expanded = false; update = vi.fn(); focus = vi.fn(); positioned = vi.fn(); - constrain = vi.fn(() => false); contains = vi.fn(); ownsFocus = vi.fn(() => false); destroy = vi.fn(); @@ -32,11 +31,9 @@ vi.mock('../src/ui/toolbar', () => ({ }, })); -type Layout = { bounds: Box; position: Position | null } | null; -interface Measurement { key: unknown; read(): Layout; write(position: Layout): void } +interface Measurement { key: unknown; read(): Position | null; write(position: Position | null): void } -function setup(start: Box = { left: 100, right: 100, top: 100, bottom: 120 }, end: Box = { left: 180, right: 180, top: 100, bottom: 120 }, reverse = false, mobile = false) { - Platform.isMobile = mobile; +function setup(start: Box = { left: 100, right: 100, top: 100, bottom: 120 }, end: Box = { left: 180, right: 180, top: 100, bottom: 120 }, reverse = false) { const frames = new Map(); let frameId = 0; const viewport = Object.assign(new EventTarget(), { offsetLeft: 0, offsetTop: 0, width: 600, height: 500 }); const window = Object.assign(new EventTarget(), { innerWidth: 600, innerHeight: 500, visualViewport: viewport, @@ -46,17 +43,19 @@ function setup(start: Box = { left: 100, right: 100, top: 100, bottom: 120 }, en const document = Object.assign(new EventTarget(), { defaultView: window }); const dom = Object.assign(new EventTarget(), { ownerDocument: document, isConnected: true }); const requests = new Map(); + const file = { path: 'test.md' } as TFile; + const state = EditorState.create({ doc: ' word', extensions: [editorInfoField.init(() => ({ file }) as MarkdownFileInfo)] }); const view = { - dom, hasFocus: true, + state, dom, hasFocus: true, focus: vi.fn(), scrollDOM: { clientWidth: 600, getBoundingClientRect: () => ({ left: 0, right: 600, top: 0, bottom: 500 }) }, coordsAtPos: (position: number) => position === 10 ? start : end, // CodeMirror replaces an earlier pending measurement with the same key. requestMeasure: (request: Measurement) => requests.set(request.key, request), } as unknown as EditorView; scenario.snapshot = { - file: { path: 'test.md' } as TFile, path: 'test.md', revision: 0, + file, path: 'test.md', revision: 0, anchor: reverse ? 14 : 10, head: reverse ? 10 : 14, base: 0, - source: 'word', located: locateFragment('word', 0, 4)!, + lineLength: 14, source: 'word', located: locateFragment('word', 0, 4)!, }; const notify = vi.fn(); const store = new PreferencesStore({ load: async () => null, save: async () => {} }); @@ -66,17 +65,18 @@ function setup(start: Box = { left: 100, right: 100, top: 100, bottom: 120 }, en const pending = [...requests.values()]; requests.clear(); for (const request of pending) request.write(request.read()); }; - return { controller, toolbar: controller['toolbar'], flush, notify, viewport, dom, document, store }; + return { controller, toolbar: controller['toolbar'], flush, requests, notify, viewport, dom, document, window, view, store }; } beforeEach(() => { + scenario.blocked = undefined; scenario.matches = true; vi.stubGlobal('ResizeObserver', class { constructor(callback: () => void) { scenario.resize = callback; } observe() {} disconnect() {} }); }); -afterEach(() => { vi.unstubAllGlobals(); Platform.isMobile = false; }); +afterEach(() => { vi.unstubAllGlobals(); }); describe('selection toolbar focus and placement', () => { it('preserves a manual focus request when resize measurement replaces it, then focuses only once', () => { @@ -103,7 +103,7 @@ describe('selection toolbar focus and placement', () => { }); it('discards a pending focus request when the selection is closed before layout', () => { const { controller, toolbar, flush } = setup(); - controller.show(true); controller.close(false); flush(); + controller.show(true); controller.close('stale'); flush(); expect(toolbar.focus).not.toHaveBeenCalled(); expect(toolbar.visible).toBe(false); controller.destroy(); @@ -136,9 +136,9 @@ function select(userEvent?: string): ViewUpdate { return { startState: state, state: transaction.state, transactions: [transaction], selectionSet: true, docChanged: false } as unknown as ViewUpdate; } -describe('touch selection lifecycle', () => { - it.each([false, true])('keeps native context menus inside the toolbar without closing the session (mobile=%s)', mobile => { - const { controller, toolbar, flush, document } = setup(undefined, undefined, false, mobile); +describe('desktop selection lifecycle', () => { + it('keeps native context menus inside the toolbar without closing the session', () => { + const { controller, toolbar, flush, document } = setup(); controller.show(false); flush(); vi.mocked(toolbar.contains).mockReturnValue(true); const menu = new Event('contextmenu', { cancelable: true }); document.dispatchEvent(menu); @@ -150,76 +150,131 @@ describe('touch selection lifecycle', () => { controller.show(false); flush(); document.dispatchEvent(new Event('contextmenu')); expect(toolbar.visible).toBe(false); controller.destroy(); }); - it('shows for native mobile select transactions without enabling keyboard selection', () => { - const { controller, toolbar, flush } = setup(undefined, undefined, false, true); - controller.update(select('select')); flush(); - expect(toolbar.visible).toBe(true); expect(toolbar.show).toHaveBeenCalledWith(false, true); + it('shows after a pointer selection without stealing editor focus', () => { + const { controller, toolbar, flush } = setup(); + controller.update(select('select.pointer')); flush(); + expect(toolbar.visible).toBe(true); expect(toolbar.show).toHaveBeenCalledWith(false); expect(toolbar.focus).not.toHaveBeenCalled(); controller.destroy(); }); - it('recognizes touch on desktop tablets and preserves native context menus', () => { - const { controller, toolbar, flush, dom, document } = setup(); - dom.dispatchEvent(Object.assign(new Event('pointerdown'), { pointerType: 'touch', button: 0 })); - controller.update(select('select')); flush(); - expect(toolbar.show).toHaveBeenCalledWith(false, true); - const menu = new Event('contextmenu', { cancelable: true }); - menu.composedPath = () => [dom, document]; document.dispatchEvent(menu); flush(); - expect(menu.defaultPrevented).toBe(false); expect(toolbar.visible).toBe(true); + it('waits for a mouse drag to finish before showing the toolbar', () => { + const { controller, toolbar, flush, dom, window } = setup(); + dom.dispatchEvent(Object.assign(new Event('pointerdown'), { button: 0 })); + controller.update(select('select.pointer')); flush(); expect(toolbar.visible).toBe(false); + window.dispatchEvent(new Event('pointerup')); flush(); + expect(toolbar.visible).toBe(true); expect(toolbar.focus).not.toHaveBeenCalled(); controller.destroy(); }); - it('still honors automatic and hardware-keyboard preferences on mobile', async () => { - const { controller, toolbar, flush, dom, store } = setup(undefined, undefined, false, true); + it('does not show after a cancelled mouse drag', () => { + const { controller, toolbar, flush, dom, window } = setup(); + dom.dispatchEvent(Object.assign(new Event('pointerdown'), { button: 0 })); + controller.update(select('select.pointer')); + window.dispatchEvent(new Event('pointercancel')); flush(); + expect(toolbar.visible).toBe(false); controller.destroy(); + }); + it('honors the keyboard selection preference', async () => { + const { controller, toolbar, flush, store } = setup(); await store.load(); - dom.dispatchEvent(new Event('keydown')); controller.update(select('select')); flush(); + controller.update(select('select')); flush(); expect(toolbar.visible).toBe(false); await store.set('keyboard', true); controller.update(select('select')); flush(); expect(toolbar.visible).toBe(true); - controller.close(false); await store.set('automatic', false); - dom.dispatchEvent(new Event('touchstart')); controller.update(select('select')); flush(); + controller.destroy(); + }); + it('keeps manual access when automatic display is disabled', async () => { + const { controller, toolbar, flush, dom, window, store } = setup(); + await store.load(); await store.set('automatic', false); await store.set('keyboard', true); + controller.update(select('select')); flush(); expect(toolbar.visible).toBe(false); + dom.dispatchEvent(Object.assign(new Event('pointerdown'), { button: 0 })); + controller.update(select('select.pointer')); window.dispatchEvent(new Event('pointerup')); flush(); expect(toolbar.visible).toBe(false); + controller.show(true); flush(); expect(toolbar.visible).toBe(true); + expect(toolbar.focus).toHaveBeenCalledTimes(1); controller.destroy(); }); - it('ignores programmatic selections and keeps desktop keyboard behavior unchanged', () => { - for (const mobile of [false, true]) { - const { controller, toolbar, flush } = setup(undefined, undefined, false, mobile); - controller.update(select()); flush(); expect(toolbar.visible).toBe(false); - if (!mobile) { controller.update(select('select')); flush(); expect(toolbar.visible).toBe(false); } - controller.destroy(); - } + it('ignores programmatic selections even with keyboard selection enabled', async () => { + const { controller, toolbar, flush, store } = setup(); + await store.load(); await store.set('keyboard', true); + controller.update(select()); flush(); expect(toolbar.visible).toBe(false); + controller.destroy(); }); - it('follows the visual viewport when the keyboard opens, pans and closes', () => { - const { controller, toolbar, viewport, flush } = setup(undefined, undefined, false, true); - controller.show(false); flush(); expect(toolbar.positioned).toHaveBeenLastCalledWith(124, 450); - viewport.height = 280; viewport.dispatchEvent(new Event('resize')); flush(); - expect(toolbar.positioned).toHaveBeenLastCalledWith(124, 230); - viewport.offsetTop = 20; viewport.dispatchEvent(new Event('scroll')); flush(); - expect(toolbar.positioned).toHaveBeenLastCalledWith(124, 250); - viewport.height = 480; viewport.dispatchEvent(new Event('resize')); flush(); - expect(toolbar.positioned).toHaveBeenLastCalledWith(124, 450); + it('uses the compact toolbar in a narrow desktop pane', () => { + const { controller, toolbar, flush, view } = setup(); + Object.defineProperty(view.scrollDOM, 'clientWidth', { value: 380 }); + controller.show(false); flush(); + expect(toolbar.visible).toBe(true); expect(toolbar.show).toHaveBeenCalledWith(true); controller.destroy(); }); - it('remeasures constrained touch panels without losing a pending manual focus request', () => { - const { controller, toolbar, flush } = setup(undefined, undefined, false, true); - vi.mocked(toolbar.constrain).mockReturnValueOnce(true).mockReturnValue(false); - controller.show(true); flush(); expect(toolbar.focus).not.toHaveBeenCalled(); - flush(); expect(toolbar.focus).toHaveBeenCalledTimes(1); + it('repositions on viewport changes and closes when the selection leaves view', () => { + const { controller, toolbar, viewport, flush } = setup(); + controller.show(false); flush(); expect(toolbar.positioned).toHaveBeenLastCalledWith(8, 50); + viewport.offsetTop = 80; viewport.height = 420; viewport.dispatchEvent(new Event('scroll')); flush(); + expect(toolbar.positioned).toHaveBeenLastCalledWith(8, 128); + viewport.height = 30; viewport.dispatchEvent(new Event('resize')); flush(); + expect(toolbar.visible).toBe(false); controller.destroy(); }); - it('keeps expanded panels docked when the visible selection leaves no room for a full palette', () => { - const { controller, toolbar, viewport, flush } = setup({ left: 100, right: 100, top: 100, bottom: 120 }, { left: 180, right: 180, top: 100, bottom: 120 }, false, true); - Object.defineProperty(toolbar, 'expanded', { value: true }); - vi.spyOn(toolbar.root, 'getBoundingClientRect').mockReturnValue({ width: 352, height: 230 } as DOMRect); - viewport.height = 300; controller.show(false); flush(); - expect(toolbar.visible).toBe(true); expect(toolbar.positioned).toHaveBeenLastCalledWith(124, 62); +}); + +describe('diagnosis, cancellation and focus return', () => { + it.each([ + [{ kind: 'readOnly' }, 'readOnly'], [{ kind: 'composing' }, 'composing'], + [{ kind: 'multipleSelections' }, 'multipleSelections'], [{ kind: 'emptySelection' }, 'emptySelection'], + [{ kind: 'multipleLines' }, 'multipleLines'], [{ kind: 'syntaxPending' }, 'syntaxPending'], + [{ kind: 'protectedSyntax' }, 'protectedSyntax'], [{ kind: 'unsupportedFragment' }, 'unsupported'], + [{ kind: 'tooLong', stage: 'line' }, 'tooLong'], [{ kind: 'modeDisabled', mode: 'source' }, 'modeDisabled'], + [{ kind: 'preferences', status: 'conflict' }, 'settingsConflict'], + ] as const)('keeps automatic rejection silent and reports one manual reason: %s', (reason, key) => { + const { controller, flush, notify, toolbar } = setup(); + scenario.blocked = { kind: 'blocked', reason }; + expect(controller.show(false)).toBe(false); flush(); expect(notify).not.toHaveBeenCalled(); + expect(controller.show(true)).toBe(false); flush(); + expect(notify).toHaveBeenCalledExactlyOnceWith(key); expect(toolbar.visible).toBe(false); controller.destroy(); }); - it('keeps a focused search panel usable when the keyboard covers the original text', () => { - const { controller, toolbar, viewport, flush } = setup({ left: 100, right: 100, top: 400, bottom: 420 }, { left: 180, right: 180, top: 400, bottom: 420 }, false, true); - controller.show(false); flush(); - vi.mocked(toolbar.ownsFocus).mockReturnValue(true); - viewport.height = 280; viewport.dispatchEvent(new Event('resize')); flush(); - expect(toolbar.visible).toBe(true); expect(toolbar.positioned).toHaveBeenLastCalledWith(124, 230); + it('prioritizes a stale identity over a new formatting restriction on execution', () => { + const { controller, flush, notify } = setup(); controller.show(true); flush(); + scenario.matches = false; scenario.blocked = { kind: 'blocked', reason: { kind: 'readOnly' } }; + controller['format']('bold'); expect(notify).toHaveBeenCalledExactlyOnceWith('stale'); + controller.destroy(); + }); + it('reports a current restriction when the active document and selection still match', () => { + const { controller, flush, notify } = setup(); controller.show(true); flush(); + scenario.blocked = { kind: 'blocked', reason: { kind: 'composing' } }; + controller['format']('bold'); expect(notify).toHaveBeenCalledExactlyOnceWith('composing'); controller.destroy(); + }); + it.each(['dismiss', 'linkApplied'] as const)('returns focus after clearing the write snapshot: %s', reason => { + const { controller, toolbar, flush, view } = setup(); controller.show(true); flush(); + vi.mocked(toolbar.ownsFocus).mockReturnValue(true); Object.defineProperty(view, 'hasFocus', { value: false }); + scenario.blocked = { kind: 'blocked', reason: { kind: 'protectedSyntax' } }; + controller.close(reason); + expect(controller['current']).toBeNull(); expect(view.focus).toHaveBeenCalledTimes(1); + controller.close(reason); expect(view.focus).toHaveBeenCalledTimes(1); controller.destroy(); + }); + it.each(['outside', 'windowBlur', 'modeChanged', 'failed', 'destroy'] as const)('does not steal focus for %s', reason => { + const { controller, toolbar, flush, view } = setup(); controller.show(true); flush(); + vi.mocked(toolbar.ownsFocus).mockReturnValue(true); Object.defineProperty(view, 'hasFocus', { value: false }); + controller.close(reason); expect(view.focus).not.toHaveBeenCalled(); controller.destroy(); + }); + it('does not restore focus to an editor whose file has changed or whose DOM is disconnected', () => { + for (const change of ['file', 'disconnected']) { + const { controller, toolbar, flush, view, dom } = setup(); controller.show(true); flush(); + vi.mocked(toolbar.ownsFocus).mockReturnValue(true); Object.defineProperty(view, 'hasFocus', { value: false }); + if (change === 'file') scenario.snapshot!.file.path = 'other.md'; else dom.isConnected = false; + controller.close('dismiss'); expect(view.focus).not.toHaveBeenCalled(); controller.destroy(); + } + }); + it('ignores an old measurement without consuming the new session focus request', () => { + const { controller, toolbar, requests, flush, notify } = setup(); controller.show(true); + const old = [...requests.values()][0]!; controller.close('stale'); + scenario.snapshot = { ...scenario.snapshot!, revision: 1 }; controller.show(true); + old.write(old.read()); expect(toolbar.visible).toBe(true); expect(notify).not.toHaveBeenCalled(); + flush(); expect(toolbar.focus).toHaveBeenCalledTimes(1); controller.destroy(); + }); + it('suppresses an explicitly dismissed selection until the user changes it', () => { + const { controller, toolbar, flush } = setup(); controller.show(false); flush(); controller.close('dismiss'); + expect(controller.show(false)).toBe(false); + controller.update(select('select.pointer')); flush(); expect(toolbar.visible).toBe(true); controller.destroy(); - viewport.dispatchEvent(new Event('resize')); flush(); expect(toolbar.visible).toBe(false); }); }); diff --git a/tests/editor.test.ts b/tests/editor.test.ts index 44d17bb..bed6ca1 100644 --- a/tests/editor.test.ts +++ b/tests/editor.test.ts @@ -16,7 +16,7 @@ function editor(reverse = false, reject = false) { extensions: [revisionField, history(), editorInfoField.init(() => ({ file }) as MarkdownFileInfo), ...(reject ? [EditorState.transactionFilter.of(() => [])] : [])] }); const view = { get state() { return state; }, dispatch(spec: TransactionSpec) { state = state.update(spec).state; } } as unknown as EditorView; const snapshot = (): SelectionSnapshot => ({ file, path: file.path, revision: state.field(revisionField), anchor: state.selection.main.anchor, - head: state.selection.main.head, base: 0, source: state.doc.toString(), located: locateFragment(state.doc.toString(), state.selection.main.from, state.selection.main.to)! }); + head: state.selection.main.head, base: 0, lineLength: state.doc.length, source: state.doc.toString(), located: locateFragment(state.doc.toString(), state.selection.main.from, state.selection.main.to)! }); return { view, snapshot, file }; } diff --git a/tests/formatting.test.ts b/tests/formatting.test.ts index 4a2dd05..5dd97da 100644 --- a/tests/formatting.test.ts +++ b/tests/formatting.test.ts @@ -102,9 +102,9 @@ describe('single-fragment formatting', () => { }); it('rejects arbitrary CSS and overlong output', () => { const located = locateFragment('word', 0, 4)!; - expect(formatFragment(located, 'word', 'foreground', 'url(https://example.com)')).toEqual({ kind: 'unavailable' }); + expect(formatFragment(located, 'word', 'foreground', 'url(https://example.com)')).toEqual({ kind: 'unavailable', reason: 'invalidParameter' }); const long = 'a'.repeat(10_000); - expect(formatFragment(locateFragment(long, 0, long.length)!, long, 'bold')).toEqual({ kind: 'unavailable' }); + expect(formatFragment(locateFragment(long, 0, long.length)!, long, 'bold')).toEqual({ kind: 'unavailable', reason: 'tooLong' }); }); it('requires the exact color attribute order and separators', () => { for (const style of ['color:#e05252background-color:#fff3a3', ';background-color:#fff3a3', 'color:#e05252;', 'background-color:#fff3a3;color:#e05252', 'color:#e05252;font-size:40px']) { @@ -117,12 +117,12 @@ describe('single-fragment formatting', () => { describe('internal links', () => { it('preserves display text and surrounding whitespace', () => { const source = ' term '; - expect(linkPlan(locateFragment(source, 0, source.length)!, source, 'folder/note')?.insert).toBe(' [[folder/note|term]] '); + expect(linkPlan(locateFragment(source, 0, source.length)!, source, 'folder/note')).toMatchObject({ kind: 'edit', plan: { insert: ' [[folder/note|term]] ' } }); }); it.each(['bad|alias', 'bad#heading', 'bad]target', 'bad\nline'])('rejects ambiguous link syntax: %s', target => { - expect(linkPlan(locateFragment('term', 0, 4)!, 'term', target)).toBeNull(); + expect(linkPlan(locateFragment('term', 0, 4)!, 'term', target)).toEqual({ kind: 'unavailable', reason: 'invalidTarget' }); }); it('does not drop formatting to make a link', () => { - expect(linkPlan(locateFragment('**term**', 0, 8)!, '**term**', 'target')).toBeNull(); + expect(linkPlan(locateFragment('**term**', 0, 8)!, '**term**', 'target')).toEqual({ kind: 'unavailable', reason: 'plainOnly' }); }); }); diff --git a/tests/mobile-position.test.ts b/tests/mobile-position.test.ts deleted file mode 100644 index 80367b5..0000000 --- a/tests/mobile-position.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { placeTouch, viewportBounds } from '../src/ui/position'; - -describe('mobile viewport placement', () => { - const bounds = { left: 0, right: 390, top: 0, bottom: 420 }; - it('uses the keyboard-reduced and panned visual viewport, with a layout fallback', () => { - expect(viewportBounds(390, 844, { offsetLeft: 12, offsetTop: 40, width: 360, height: 380 })) - .toEqual({ left: 12, right: 372, top: 40, bottom: 420 }); - expect(viewportBounds(390, 844, null)).toEqual({ left: 0, right: 390, top: 0, bottom: 844 }); - }); - it('docks near the bottom away from text selection handles', () => { - expect(placeTouch({ left: 0, right: 100, top: 100, bottom: 120 }, bounds, 374, 54)).toEqual({ left: 8, top: 358 }); - }); - it('uses space above a low selection and hides when neither side fits', () => { - expect(placeTouch({ left: 0, right: 100, top: 350, bottom: 370 }, bounds, 374, 200)).toEqual({ left: 8, top: 8 }); - expect(placeTouch({ left: 0, right: 100, top: 150, bottom: 300 }, bounds, 374, 200)).toBeNull(); - }); - it('retains focused search at the bottom even when its original selection is outside the viewport', () => { - expect(placeTouch(null, bounds, 374, 300)).toEqual({ left: 8, top: 112 }); - }); - it('rejects overflowing panels in narrow or short viewports', () => { - expect(placeTouch(null, bounds, 380, 54)).toBeNull(); - expect(placeTouch(null, bounds, 374, 410)).toBeNull(); - }); -}); diff --git a/tests/note-picker.test.ts b/tests/note-picker.test.ts new file mode 100644 index 0000000..322e039 --- /dev/null +++ b/tests/note-picker.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; +import { NotePicker } from '../src/ui/note-picker'; +import type { NoteChoice, ToolbarActions } from '../src/ui/toolbar-types'; + +// Only the DOM boundary is replaced. Requests, cancellation and composition use the real picker. +class Element extends EventTarget { + tagName: string; className = ''; id = ''; type = ''; placeholder = ''; value = ''; + children: Element[] = []; attributes = new Map(); private text = ''; + scrollIntoView = vi.fn(); focus = vi.fn(); + constructor(tag: string) { super(); this.tagName = tag.toUpperCase(); } + set textContent(value: string) { this.text = value; this.children = []; } + get textContent(): string { return this.text + this.children.map(child => child.textContent).join(''); } + setAttribute(key: string, value: string) { this.attributes.set(key, value); } + getAttribute(key: string) { return this.attributes.get(key) ?? null; } + removeAttribute(key: string) { this.attributes.delete(key); } + append(...children: Element[]) { this.children.push(...children); } + replaceChildren(...children: Element[]) { this.text = ''; this.children = children; } +} + +function deferred() { + let resolve!: (value: readonly NoteChoice[]) => void, reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +const settle = async () => { await Promise.resolve(); await Promise.resolve(); }; +function keyboard(input: Element, key: string, isComposing = false, type = 'keydown', keyCode = 0) { + const event = Object.assign(new Event(type, { cancelable: true }), { key, isComposing, keyCode }); input.dispatchEvent(event); return event; +} +function setup(notes: ToolbarActions['notes'] = async () => [{ path: 'target.md', name: 'Target' }]) { + const panel = new Element('div'); + const actions: ToolbarActions = { notes, format: vi.fn(), explain: vi.fn(), quickLink: vi.fn(), selectNote: vi.fn(), close: vi.fn(), resize: vi.fn() }; + const picker = new NotePicker({ createElement: (tag: string) => new Element(tag) } as unknown as Document, + panel as unknown as HTMLElement, key => key, actions); + return { picker, actions, panel, input: panel.children[0]!, list: panel.children[1]! }; +} + +describe('note picker request and input lifecycle', () => { + it('shows loading and a real empty state without treating its paragraph as a selected option', async () => { + const request = deferred(); const { input, list } = setup(() => request.promise); + expect(list.getAttribute('aria-busy')).toBe('true'); request.resolve([]); await settle(); + expect(list.getAttribute('aria-busy')).toBe('false'); expect(list.textContent).toBe('noNotes'); + expect(input.getAttribute('aria-activedescendant')).toBeNull(); + }); + it.each(['resolve', 'reject'] as const)('ignores an older query that later %ss', async completion => { + const first = deferred(), second = deferred(); const signals: AbortSignal[] = []; + const notes = vi.fn((_query: string, signal: AbortSignal) => { signals.push(signal); return signals.length === 1 ? first.promise : second.promise; }); + const { input, list, actions } = setup(notes); + input.value = 'new'; input.dispatchEvent(new Event('input')); + expect(signals[0]!.aborted).toBe(true); + second.resolve([{ path: 'new.md', name: 'New result' }]); await settle(); + const resizes = vi.mocked(actions.resize).mock.calls.length; + if (completion === 'resolve') first.resolve([{ path: 'old.md', name: 'Old result' }]); else first.reject(Error('Old failure')); + await settle(); expect(list.textContent).toBe('New resultnew.md'); + expect(vi.mocked(actions.resize).mock.calls.length).toBe(resizes); + keyboard(input, 'Enter'); expect(actions.selectNote).toHaveBeenCalledExactlyOnceWith('new.md'); + }); + it.each(['resolve', 'reject'] as const)('does not redraw a cancelled picker after a late %s', async completion => { + const request = deferred(); const { picker, list, actions } = setup(() => request.promise); + picker.cancel(); const resizes = vi.mocked(actions.resize).mock.calls.length; + if (completion === 'resolve') request.resolve([{ path: 'old.md', name: 'Old' }]); else request.reject(Error('Late failure')); + await settle(); expect(list.textContent).toBe('searching'); + expect(vi.mocked(actions.resize).mock.calls.length).toBe(resizes); expect(actions.selectNote).not.toHaveBeenCalled(); + }); + it('keeps the failed search retryable and replaces the error with new results', async () => { + const notes = vi.fn().mockRejectedValueOnce(Error('Read failure')).mockResolvedValueOnce([{ path: 'target.md', name: 'Target' }]); + const { list } = setup(notes); await settle(); + expect(list.getAttribute('aria-busy')).toBe('false'); expect(list.textContent).toBe('searchErrorretry'); + list.children[0]!.dispatchEvent(new Event('click')); await settle(); + expect(list.textContent).toBe('Targettarget.md'); expect(notes).toHaveBeenCalledTimes(2); + }); + it('does not consume composition keys or confirm until the composing Enter has been released', async () => { + const { input, actions } = setup(); await settle(); + input.dispatchEvent(new Event('compositionstart')); + expect(keyboard(input, 'Enter', true).defaultPrevented).toBe(false); + input.dispatchEvent(new Event('compositionend')); keyboard(input, 'Enter'); + expect(actions.selectNote).not.toHaveBeenCalled(); + keyboard(input, 'Enter', false, 'keyup'); keyboard(input, 'Enter'); + expect(actions.selectNote).toHaveBeenCalledExactlyOnceWith('target.md'); + }); + it('lets a new confirmation work after composition was completed without Enter', async () => { + const { input, actions } = setup(); await settle(); + input.dispatchEvent(new Event('compositionstart')); input.dispatchEvent(new Event('compositionend')); keyboard(input, 'Enter'); + expect(actions.selectNote).toHaveBeenCalledExactlyOnceWith('target.md'); + }); + it('does not confirm when composition ends before its IME Enter keydown', async () => { + const { input, actions } = setup(); await settle(); + input.dispatchEvent(new Event('compositionstart')); + input.dispatchEvent(new Event('compositionend')); + expect(keyboard(input, 'Enter', false, 'keydown', 229).defaultPrevented).toBe(false); + expect(keyboard(input, 'Enter').defaultPrevented).toBe(false); + expect(actions.selectNote).not.toHaveBeenCalled(); expect(actions.explain).not.toHaveBeenCalled(); + keyboard(input, 'Enter', false, 'keyup'); keyboard(input, 'Enter'); + expect(actions.selectNote).toHaveBeenCalledExactlyOnceWith('target.md'); + }); + it('leaves an IME navigation key alone before compositionstart is delivered', async () => { + const { input, actions } = setup(async () => [{ path: 'first.md', name: 'First' }, { path: 'second.md', name: 'Second' }]); await settle(); + expect(keyboard(input, 'ArrowDown', false, 'keydown', 229).defaultPrevented).toBe(false); + keyboard(input, 'Enter'); expect(actions.selectNote).toHaveBeenCalledExactlyOnceWith('first.md'); + }); + it('explains unavailable targets and leaves native text undo alone', async () => { + const { input, actions, list } = setup(async () => [{ path: 'target.md', name: 'Target', disabledReason: 'tooLong' }]); await settle(); + expect(list.children[0]!.getAttribute('aria-disabled')).toBe('true'); + keyboard(input, 'Enter'); expect(actions.explain).toHaveBeenCalledExactlyOnceWith('tooLong'); expect(actions.selectNote).not.toHaveBeenCalled(); + expect(keyboard(input, 'z').defaultPrevented).toBe(false); + }); +}); diff --git a/tests/preferences.test.ts b/tests/preferences.test.ts index 83cd53a..d091822 100644 --- a/tests/preferences.test.ts +++ b/tests/preferences.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { DEFAULTS, parsePreferences } from '../src/settings/model'; import { PreferencesStore } from '../src/settings/store'; import { stringsFor } from '../src/i18n'; -import { place } from '../src/ui/position'; +import { place, viewportBounds } from '../src/ui/position'; describe('preferences and localization', () => { it('loads first-run defaults without a false error', () => { @@ -31,6 +31,11 @@ describe('preferences and localization', () => { describe('toolbar placement', () => { const bounds = { left: 0, right: 400, top: 0, bottom: 600 }; + it('uses the visible viewport when available and falls back to window dimensions', () => { + expect(viewportBounds(1200, 800, { offsetLeft: 12, offsetTop: 40, width: 1000, height: 720 })) + .toEqual({ left: 12, right: 1012, top: 40, bottom: 760 }); + expect(viewportBounds(1200, 800, null)).toEqual({ left: 0, right: 1200, top: 0, bottom: 800 }); + }); it('flips below at the top, clamps horizontally and hides when offscreen', () => { expect(place({ left: 0, right: 20, top: 10, bottom: 30 }, bounds, 200, 40)).toEqual({ left: 8, top: 38 }); expect(place({ left: 380, right: 400, top: 300, bottom: 320 }, bounds, 200, 40)).toEqual({ left: 192, top: 252 }); diff --git a/tests/release.test.ts b/tests/release.test.ts index 60e4a6f..43ae4c5 100644 --- a/tests/release.test.ts +++ b/tests/release.test.ts @@ -38,7 +38,7 @@ beforeEach(() => { for (const file of ['main.js', 'styles.css', 'LICENSE', 'THIRD_PARTY_NOTICES.md', 'README.md', 'README.zh-CN.md', 'CONTRIBUTING.md']) { fixture.files.set(file, 'Synthetic release fixture.'); } - json('manifest.json', { id: 'hover-bar', version: '0.1.0', minAppVersion: '1.8.7', isDesktopOnly: false, description: 'Format selected text.' }); + json('manifest.json', { id: 'hover-bar', version: '0.1.0', minAppVersion: '1.8.7', isDesktopOnly: true, description: 'Format selected text.' }); json('package.json', { version: '0.1.0' }); json('package-lock.json', { version: '0.1.0', packages: { '': { version: '0.1.0' } } }); json('versions.json', { '0.1.0': '1.8.7' }); @@ -56,10 +56,10 @@ describe('release validation', () => { it('accepts complete installation files built from the exact version tag', async () => { await expect(check()).resolves.toBeDefined(); }); - it('rejects accidentally disabling mobile installation', async () => { + it.each([false, undefined])('rejects accidentally enabling mobile installation (isDesktopOnly=%s)', async isDesktopOnly => { const manifest = JSON.parse(fixture.files.get('manifest.json')!); - json('manifest.json', { ...manifest, isDesktopOnly: true }); - await expect(check()).rejects.toThrow('Hover Bar must remain available on mobile'); + json('manifest.json', { ...manifest, isDesktopOnly }); + await expect(check()).rejects.toThrow('Hover Bar must remain desktop-only'); }); it.each(['v0.1.0', '0.2.0'])('rejects a mismatched tag %s', async version => { process.argv[3] = version; diff --git a/tests/selection-actions.test.ts b/tests/selection-actions.test.ts new file mode 100644 index 0000000..c30e4fb --- /dev/null +++ b/tests/selection-actions.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { undo } from '@codemirror/commands'; +import { applyEdit } from '../src/editor/apply-edit'; +import { formatSelection, linkSelection } from '../src/editor/selection-actions'; +import { toolbarModel } from '../src/editor/toolbar-model'; +import { linkPlan } from '../src/links/wikilink'; +import { locateFragment } from '../src/formatting/fragment'; +import { formatFragment } from '../src/formatting/markdown'; +import { selectionFixture } from './support/selection'; + +describe('whole-line budget shared by display and commit', () => { + it.each([9997, 9999])('rejects an over-budget result before writing from a %s-unit line', async length => { + const fixture = await selectionFixture('a'.repeat(length), { from: 1, to: 2 }); + const captured = fixture.capture(); if (captured.kind !== 'ready') throw Error('No snapshot'); + const snapshot = captured.snapshot; + expect(formatSelection(snapshot, 'bold')).toEqual({ kind: 'unavailable', reason: 'tooLong' }); + expect(toolbarModel(snapshot, key => key).controls.bold).toMatchObject({ enabled: false, reason: 'tooLong' }); + const unguarded = formatFragment(snapshot.located, snapshot.source, 'bold'); + if (unguarded.kind !== 'edit') throw Error('Expected a fragment plan'); + expect(applyEdit(fixture.view, snapshot, unguarded.plan)).toBe(false); + expect(fixture.dispatches).toBe(0); expect(undo(fixture.view)).toBe(false); + }); + it('allows exactly 10,000 units, recaptures and can shorten the formatted line', async () => { + const fixture = await selectionFixture('a'.repeat(9996), { from: 1, to: 2, reverse: true }); + const before = fixture.capture(); if (before.kind !== 'ready') throw Error('No snapshot'); + const add = formatSelection(before.snapshot, 'bold'); if (add.kind !== 'edit') throw Error('No plan'); + expect(applyEdit(fixture.view, before.snapshot, add.plan)).toBe(true); expect(fixture.view.state.doc.length).toBe(10_000); + const after = fixture.capture(); if (after.kind !== 'ready') throw Error('Cannot continue'); + expect(after.snapshot.anchor).toBeGreaterThan(after.snapshot.head); + const remove = formatSelection(after.snapshot, 'bold'); if (remove.kind !== 'edit') throw Error('No inverse'); + expect(applyEdit(fixture.view, after.snapshot, remove.plan)).toBe(true); expect(fixture.view.state.doc.length).toBe(9996); + undo(fixture.view); expect(fixture.view.state.doc.length).toBe(10_000); + }); + it('counts structural prefixes in the budget and refuses malformed cross-line plans', async () => { + const fixture = await selectionFixture('# ' + 'a'.repeat(9994), { from: 3, to: 4 }); + const captured = fixture.capture(); if (captured.kind !== 'ready') throw Error('No snapshot'); + const result = formatSelection(captured.snapshot, 'bold'); if (result.kind !== 'edit') throw Error('No plan'); + expect(applyEdit(fixture.view, captured.snapshot, { ...result.plan, insert: 'a\nb' })).toBe(false); + expect(applyEdit(fixture.view, captured.snapshot, result.plan)).toBe(true); expect(fixture.view.state.doc.length).toBe(10_000); + }); + it('protects the final transaction from composition or a changed selection count', async () => { + const fixture = await selectionFixture(); const captured = fixture.capture(); if (captured.kind !== 'ready') throw Error('No snapshot'); + const result = formatSelection(captured.snapshot, 'bold'); if (result.kind !== 'edit') throw Error('No plan'); + Object.defineProperty(fixture.view, 'composing', { value: true }); + expect(applyEdit(fixture.view, captured.snapshot, result.plan)).toBe(false); expect(fixture.dispatches).toBe(0); + }); + it.each([ + { name: 'resulting line', source: 'a'.repeat(9999), from: 1, to: 2 }, + { name: 'fragment output', source: 'a'.repeat(9997), from: 0, to: 9997 }, + { name: 'structural prefix', source: '# ' + 'a'.repeat(9995), from: 2, to: 3 }, + ])('disables an over-budget quick link before activation: $name', async ({ source, from, to }) => { + const fixture = await selectionFixture(source, { from, to }); + const captured = fixture.capture(); if (captured.kind !== 'ready') throw Error('No snapshot'); + const snapshot = captured.snapshot, model = toolbarModel(snapshot, key => key); + expect(model.controls.link).toEqual({ enabled: false, reason: 'tooLong' }); + expect(linkSelection(snapshot, snapshot.located.fragment.text)).toEqual({ kind: 'unavailable', reason: 'tooLong' }); + // A note-picker target is still unknown; each actual candidate is checked separately. + expect(model.controls.chooseNote).toEqual({ enabled: true }); + expect(fixture.dispatches).toBe(0); expect(undo(fixture.view)).toBe(false); + }); + it.each([false, true])('allows a quick link at exactly 10,000 units (wholeSelection=%s)', async wholeSelection => { + const source = 'a'.repeat(9996); + const fixture = await selectionFixture(source, { from: wholeSelection ? 0 : 1, to: wholeSelection ? source.length : 2 }); + const captured = fixture.capture(); if (captured.kind !== 'ready') throw Error('No snapshot'); + const snapshot = captured.snapshot; + expect(toolbarModel(snapshot, key => key).controls.link).toEqual({ enabled: true }); + const result = linkSelection(snapshot, snapshot.located.fragment.text); if (result.kind !== 'edit') throw Error('No link plan'); + expect(applyEdit(fixture.view, snapshot, result.plan)).toBe(true); expect(fixture.view.state.doc.length).toBe(10_000); + expect(undo(fixture.view)).toBe(true); expect(fixture.view.state.doc.toString()).toBe(source); + }); +}); + +describe('link and action rejection reasons', () => { + it.each([['`word`', 'codeOnly'], ['**word**', 'plainOnly'], ['bad|name', 'invalidText']] as const)('reports the actual source restriction: %s', (source, reason) => { + const located = locateFragment(source, 0, source.length)!; + expect(linkPlan(located, source, 'target')).toEqual({ kind: 'unavailable', reason }); + }); + it('distinguishes invalid targets, fragment output and resulting-line limits', async () => { + const fixture = await selectionFixture('a'.repeat(9997)); const result = fixture.capture(); if (result.kind !== 'ready') throw Error('No snapshot'); + expect(linkSelection(result.snapshot, 'bad#target')).toEqual({ kind: 'unavailable', reason: 'invalidTarget' }); + expect(linkSelection(result.snapshot, result.snapshot.located.fragment.text)).toEqual({ kind: 'unavailable', reason: 'tooLong' }); + const short = await selectionFixture('a'.repeat(9999), { from: 1, to: 2 }); const selected = short.capture(); if (selected.kind !== 'ready') throw Error('No snapshot'); + expect(linkSelection(selected.snapshot, 'note')).toEqual({ kind: 'unavailable', reason: 'tooLong' }); + }); + it('does not label code conflicts as length failures or invalid links', async () => { + const fixture = await selectionFixture('`word`'); const result = fixture.capture(); if (result.kind !== 'ready') throw Error('No snapshot'); + const model = toolbarModel(result.snapshot, key => key); + expect(model.controls.bold).toMatchObject({ enabled: false, reason: 'codeOnly' }); + expect(model.controls.link).toMatchObject({ enabled: false, reason: 'codeOnly' }); + expect(model.controls.code).toMatchObject({ enabled: true, pressed: true }); + expect(model.resets.foreground).toMatchObject({ enabled: false, reason: 'codeOnly' }); + }); +}); diff --git a/tests/selection.test.ts b/tests/selection.test.ts new file mode 100644 index 0000000..9124064 --- /dev/null +++ b/tests/selection.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { EditorView } from '@codemirror/view'; +import { capture, inspectSyntax } from '../src/editor/context'; +import { selectionFixture } from './support/selection'; + +describe('selection diagnosis and precedence', () => { + it.each(['loadError', 'schemaError', 'conflict'] as const)('reports unavailable preferences: %s', async status => { + const fixture = await selectionFixture(); fixture.store.status = status; + expect(fixture.capture()).toEqual({ kind: 'blocked', reason: { kind: 'preferences', status } }); + expect(fixture.dispatches).toBe(0); + }); + it('continues with active in-memory preferences after a failed save', async () => { + const fixture = await selectionFixture(); fixture.store.status = 'saveError'; + expect(fixture.capture().kind).toBe('ready'); + }); + it('distinguishes a missing editor, reading view and a disabled editing mode', async () => { + const fixture = await selectionFixture(); + expect(capture(fixture.view, { workspace: { getLeavesOfType: () => [] } } as unknown as typeof fixture.app, fixture.store)) + .toMatchObject({ kind: 'blocked', reason: { kind: 'noEditor' } }); + expect((await selectionFixture('word', { mode: 'preview' })).capture()).toMatchObject({ reason: { kind: 'noEditor' } }); + fixture.store.value = { ...fixture.store.value, livePreview: false }; + expect(fixture.capture()).toMatchObject({ reason: { kind: 'modeDisabled', mode: 'livePreview' } }); + const source = await selectionFixture('word', { live: false }); source.store.value = { ...source.store.value, sourceMode: false }; + expect(source.capture()).toMatchObject({ reason: { kind: 'modeDisabled', mode: 'source' } }); + }); + it('prioritizes read-only state over composition and cross-line content', async () => { + const fixture = await selectionFixture('one\ntwo', { readOnly: true, composing: true }); + expect(fixture.capture()).toMatchObject({ reason: { kind: 'readOnly' } }); + expect((await selectionFixture('word', { editable: EditorView.editable.of(false) })).capture()).toMatchObject({ reason: { kind: 'readOnly' } }); + }); + it('rejects composition and multiple selections before an empty main selection', async () => { + expect((await selectionFixture('word', { composing: true })).capture()).toMatchObject({ reason: { kind: 'composing' } }); + expect((await selectionFixture('word', { multiple: true })).capture()).toMatchObject({ reason: { kind: 'multipleSelections' } }); + }); + it.each([{ text: 'word', from: 1, to: 1 }, { text: ' \t ', from: 0, to: 3 }])('rejects empty or whitespace selections: $text', async ({ text, from, to }) => { + expect((await selectionFixture(text, { from, to })).capture()).toMatchObject({ reason: { kind: 'emptySelection' } }); + }); + it('prioritizes selection length over real newlines and checks the entire input line', async () => { + expect((await selectionFixture('a'.repeat(10_000) + '\nb')).capture()).toMatchObject({ reason: { kind: 'tooLong', stage: 'selection' } }); + expect((await selectionFixture('a'.repeat(10_001), { from: 2, to: 3 })).capture()).toMatchObject({ reason: { kind: 'tooLong', stage: 'line' } }); + expect((await selectionFixture('one\ntwo')).capture()).toMatchObject({ reason: { kind: 'multipleLines' } }); + }); + it('separates pending parsing, known protected syntax, structural prefixes and unsupported fragments', async () => { + const pending = await selectionFixture('word', { pending: true }); + expect(inspectSyntax(pending.view.state, 0, 4)).toBe('pending'); + expect(pending.capture()).toMatchObject({ reason: { kind: 'syntaxPending' } }); + for (const [text, from, to] of [['```\nword\n```', 4, 8], ['# title', 0, 7], ['- item', 0, 6]] as const) { + expect((await selectionFixture(text, { from, to })).capture()).toMatchObject({ reason: { kind: 'protectedSyntax' } }); + } + for (const [text, from, to] of [['**word**', 2, 4], ['a **word**', 0, 10], ['😀', 0, 1]] as const) { + expect((await selectionFixture(text, { from, to })).capture()).toMatchObject({ reason: { kind: 'unsupportedFragment' } }); + } + }); + it.each([['# title', 2, 7], ['- [ ] item', 6, 10], ['> quote', 2, 7], ['中文 😀', 0, 5]] as const)('captures valid text including full input-line length: %s', async (text, from, to) => { + const fixture = await selectionFixture(text, { from, to, reverse: true }); + const result = fixture.capture(); expect(result.kind).toBe('ready'); + if (result.kind !== 'ready') throw Error('No snapshot'); + expect(result.snapshot.lineLength).toBe(text.length); expect(result.snapshot.anchor).toBe(to); + expect(result.snapshot.head).toBe(from); expect(fixture.dispatches).toBe(0); + }); +}); diff --git a/tests/support/obsidian.ts b/tests/support/obsidian.ts index 99c4467..d0bb018 100644 --- a/tests/support/obsidian.ts +++ b/tests/support/obsidian.ts @@ -8,4 +8,3 @@ export const editorInfoField = StateField.define({ }); export const editorLivePreviewField = StateField.define({ create: () => true, update: value => value }); export class MarkdownView {} -export const Platform = { isMobile: false }; diff --git a/tests/support/selection.ts b/tests/support/selection.ts new file mode 100644 index 0000000..b50044a --- /dev/null +++ b/tests/support/selection.ts @@ -0,0 +1,41 @@ +import { EditorSelection, EditorState, type Extension, type TransactionSpec } from '@codemirror/state'; +import type { EditorView } from '@codemirror/view'; +import { defineLanguageFacet, ensureSyntaxTree, Language, languageDataProp } from '@codemirror/language'; +import { history } from '@codemirror/commands'; +import { parser, Table } from '@lezer/markdown'; +import type { App, Editor, MarkdownFileInfo, TFile } from 'obsidian'; +import { capture, revisionField } from '../../src/editor/context'; +import { PreferencesStore } from '../../src/settings/store'; +import { editorInfoField, editorLivePreviewField, MarkdownView } from './obsidian'; + +interface Options { + from?: number; to?: number; reverse?: boolean; live?: boolean; mode?: 'source' | 'preview'; + readOnly?: boolean; editable?: Extension; composing?: boolean; pending?: boolean; multiple?: boolean; +} + +export async function selectionFixture(text = 'word', options: Options = {}) { + const file = { path: 'test.md', basename: 'test', extension: 'md' } as TFile; + const editor = {} as Editor; + const hostView = Object.assign(new MarkdownView(), { editor, getMode: () => options.mode ?? 'source' }); + const app = { workspace: { getLeavesOfType: () => [{ view: hostView }] } } as unknown as App; + const data = defineLanguageFacet(); + const language = new Language(data, parser.configure([Table, { props: [languageDataProp.add({ Document: data })] }])); + const from = options.from ?? 0, to = options.to ?? text.length; + let state = EditorState.create({ doc: text, + selection: options.multiple ? EditorSelection.create([EditorSelection.cursor(0), EditorSelection.range(2, 4)]) + : EditorSelection.single(options.reverse ? to : from, options.reverse ? from : to), + extensions: [revisionField, history(), editorInfoField.init(() => ({ file, editor }) as MarkdownFileInfo), + editorLivePreviewField.init(() => options.live ?? true), EditorState.allowMultipleSelections.of(true), + EditorState.readOnly.of(options.readOnly ?? false), ...(options.editable ? [options.editable] : []), + ...(!options.pending ? [language] : [])], + }); + if (!options.pending) ensureSyntaxTree(state, state.doc.length, 200); + let dispatches = 0; + const view = { + get state() { return state; }, composing: options.composing ?? false, + dispatch(spec: TransactionSpec) { dispatches++; state = state.update(spec).state; }, + } as unknown as EditorView; + const store = new PreferencesStore({ load: async () => null, save: async () => {} }); + await store.load(); + return { view, file, app, store, hostView, capture: () => capture(view, app, store), get dispatches() { return dispatches; } }; +} diff --git a/tests/toolbar.test.ts b/tests/toolbar.test.ts index 6e106a1..99d3447 100644 --- a/tests/toolbar.test.ts +++ b/tests/toolbar.test.ts @@ -10,7 +10,7 @@ function panelSession(focused: boolean) { const editor = {}; const body = {}; const document = { activeElement: focused ? input : editor }; - const button = { focus: vi.fn(() => { document.activeElement = button; }) }; + const button = { isConnected: true, focus: vi.fn(() => { document.activeElement = button; }) }; let attached = true; // Model the browser boundary: removing a focused input moves focus to the body. const panel = { @@ -20,7 +20,7 @@ function panelSession(focused: boolean) { const toolbar = Object.create(Toolbar.prototype) as Toolbar; Object.assign(toolbar, { document, root: { hidden: false, contains: (node: unknown) => node === button || (attached && node === input) }, - panel, openPanel: 'chooseNote', returnTo: null, touch: true, fragment: null, + panel, openPanel: 'chooseNote', returnTo: null, model: null, queryAbort: null, queryId: 0, controls: new Map(), t: (key: string) => key, actions: { resize: vi.fn() }, }); return { toolbar, document, button, editor }; @@ -34,7 +34,13 @@ describe('toolbar panel focus handoff', () => { expect(toolbar.ownsFocus()).toBe(true); expect(button.focus).toHaveBeenCalledWith({ preventScroll: true }); }); - it('does not steal editor focus when opening a touch panel without a focused search', () => { + it('restores focus to the panel trigger when closing with Escape', () => { + const { toolbar, document, button } = panelSession(true); + toolbar['startPanel']('foreground', button as unknown as HTMLButtonElement); + toolbar.closePanel(true); + expect(document.activeElement).toBe(button); expect(toolbar.ownsFocus()).toBe(true); + }); + it('does not move editor focus during panel setup', () => { const { toolbar, document, button, editor } = panelSession(false); toolbar['startPanel']('foreground', button as unknown as HTMLButtonElement); expect(document.activeElement).toBe(editor); expect(button.focus).not.toHaveBeenCalled(); diff --git a/tests/touch.test.ts b/tests/touch.test.ts deleted file mode 100644 index fe49250..0000000 --- a/tests/touch.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { enableTouchActions } from '../src/ui/touch'; - -function setup(role = 'BUTTON') { - const root = new EventTarget(); - const target = { tagName: role === 'option' ? 'DIV' : role, getAttribute: () => role === 'option' ? 'option' : null, isConnected: true, click: vi.fn() }; - enableTouchActions(root as HTMLElement); - const point = (x: number, y = 0, identifier = 1) => ({ clientX: x, clientY: y, identifier }); - const send = (type: string, touches = [point(0)], changedTouches = [point(0)]) => { - const event = Object.assign(new Event(type, { cancelable: true }), { touches, changedTouches }); - event.composedPath = () => [target as unknown as HTMLElement, root]; root.dispatchEvent(event); return event; - }; - return { target, point, send }; -} - -describe('touch activation without changing the editor selection', () => { - it.each(['BUTTON', 'option'])('activates a %s once and cancels the native compatibility click', role => { - const { target, send } = setup(role); - expect(send('touchstart').defaultPrevented).toBe(false); - expect(send('touchend', []).defaultPrevented).toBe(true); - send('touchend', []); expect(target.click).toHaveBeenCalledTimes(1); - }); - it('allows horizontal and vertical scrolling without activating the touched action', () => { - for (const direction of ['horizontal', 'vertical']) { - const { target, point, send } = setup(); - send('touchstart'); const to = direction === 'horizontal' ? point(40) : point(0, 40); - expect(send('touchmove', [to]).defaultPrevented).toBe(false); - expect(send('touchend', [], [to]).defaultPrevented).toBe(false); - expect(target.click).not.toHaveBeenCalled(); - } - }); - it('ignores cancellation, multiple fingers, disconnected actions, and input fields', () => { - for (const reason of ['cancel', 'multi', 'removed', 'input']) { - const { target, point, send } = setup(reason === 'input' ? 'INPUT' : 'BUTTON'); - send('touchstart'); - if (reason === 'cancel') send('touchcancel', []); - if (reason === 'multi') send('touchstart', [point(0), point(10, 10, 2)]); - if (reason === 'removed') target.isConnected = false; - send('touchend', []); expect(target.click).not.toHaveBeenCalled(); - } - }); - it('checks the release position even when no touchmove event was delivered', () => { - const { target, point, send } = setup(); - send('touchstart'); send('touchend', [], [point(30)]); expect(target.click).not.toHaveBeenCalled(); - }); -}); diff --git a/versions.json b/versions.json index 830fe2e..426034b 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,6 @@ { "0.1.0": "1.14.4", "0.1.1": "1.8.7", - "0.1.2": "1.8.7" + "0.1.2": "1.8.7", + "0.1.3": "1.8.7" }