diff --git a/.gitignore b/.gitignore index 551de76..2123a7b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ node_modules/ /playwright-report/ /test-results/ /output/playwright/ +/.playwright-cli/ /.cache/ /tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ffdb9..32a9c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ Notable changes to Hover Bar, grouped by release. +## 0.1.2 — 2026-10-26 + +### Added + +- Mobile support with automatic touch selection, a swipeable bottom toolbar, 44-pixel touch targets, and a close button. +- Scrollable color and note panels that adapt to the on-screen keyboard and visual viewport. + +### Changed + +- Allow installation on iOS and Android alongside desktop Obsidian. +- Update English and Chinese usage guides and automatic-toolbar preference text for touch screens. + +### Fixed + +- Keep the toolbar open when switching from note search to colors or more formatting on touch screens. +- Preserve the native copy/paste menu in note search without closing the picker. + ## 0.1.1 — 2026-10-26 ### Changed diff --git a/README.md b/README.md index 02b487b..7366272 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Hover Bar -A lightweight, offline selection toolbar for formatting text in Obsidian without leaving your mouse or trackpad. +A lightweight, offline selection toolbar for formatting text in Obsidian on desktop, phone, and tablet. [简体中文](README.zh-CN.md) @@ -14,7 +14,8 @@ The toolbar appears when you finish selecting text. Ordinary formatting stays in ## Features -- A compact floating toolbar near your selection, with viewport-aware placement. +- 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. @@ -26,11 +27,13 @@ The toolbar appears when you finish selecting text. Ordinary formatting stays in ## 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. +2. Select a plain-text phrase using your mouse or trackpad, or long-press and adjust the selection handles on a touch screen. 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**` | @@ -42,7 +45,7 @@ Click outside the toolbar or press Esc to close it. For keyboard access, use **S ## Compatibility and limitations -Requires desktop Obsidian **1.8.7 or later**, in Live Preview or Source mode. Reading view and mobile selection menus are not supported. +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. 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. @@ -66,7 +69,7 @@ When Hover Bar is available in the community directory: Open **Settings → Hover Bar** to control automatic display, Live Preview, Source mode, and automatic display after keyboard selection. -Use **Hover Bar: Show selection toolbar** in the command palette to open the toolbar manually. **Hover Bar: Toggle automatic toolbar** turns mouse-triggered display on or off. Assign shortcuts in **Settings → Hotkeys** if you prefer; no default shortcuts are added. +Use **Hover Bar: Show selection toolbar** in the command palette to open the toolbar manually. **Hover Bar: Toggle automatic toolbar** turns automatic display on or off. Assign shortcuts in **Settings → Hotkeys** if you prefer; no default shortcuts are added. 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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 2b3a720..57412b7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # Hover Bar -轻量、离线的 Obsidian 选区排版插件。划选文字后,就近完成排版、文字着色、背景着色和双链操作,让鼠标与触控板编辑更顺手。 +轻量、离线的 Obsidian 选区排版插件,支持电脑、手机和平板。选中文字后,直接完成排版、文字着色、背景着色和双链操作。 [English](README.md) @@ -14,7 +14,8 @@ ## 功能 -- 完成选区后弹出紧凑胶囊工具条,靠近文字并避开窗格边界。 +- 桌面端完成选区后弹出紧凑胶囊工具条,靠近文字并避开窗格边界。 +- 触摸屏使用底部工具条,按钮可横向滑动,增大点击区域,并适应软键盘空间。 - 加粗、斜体、删除线、行内代码和 Obsidian 标准高亮。 - 带名称的文字色与背景色色板,可分别移除颜色。 - 一键生成 `[[双链]]`,也可搜索并关联已有笔记。 @@ -26,11 +27,13 @@ ## 使用方法 1. 在实时预览或源码模式中打开 Markdown 笔记。 -2. 用鼠标或触控板选中一段普通文字。 +2. 用鼠标或触控板选中一段普通文字;触摸屏上可长按文字并拖动选区手柄。 3. 点击格式按钮,或打开色板选择颜色。 点击外部或按 Esc 关闭。键盘用户可通过命令面板中的「显示选区工具条」打开工具条,也可在 Obsidian 的快捷键设置中自行绑定快捷键。 +手机和平板上的工具条停靠在编辑区域底部,软键盘打开时位于键盘上方。左右滑动按钮行可访问全部操作,点击 **×** 关闭。色板和笔记搜索在按钮行上方展开,空间不足时可滚动面板。系统的复制、粘贴菜单仍可使用,也可通过命令面板中的「显示选区工具条」手动呼出。 + | 操作 | 笔记中保存的内容示例 | | --- | --- | | 加粗 | `**重点**` | @@ -42,7 +45,7 @@ ## 兼容性与限制 -需要桌面端 Obsidian **1.8.7 或更高版本**,支持实时预览和源码模式。阅读视图与移动端触摸选区不提供工具条。 +需要 Obsidian **1.8.7 或更高版本**,支持桌面端、iOS 和 Android 的实时预览与源码模式。阅读视图不提供工具条。 格式操作支持同一逻辑行中的单一文本片段,包括标题、列表项和引用中的文字;请选择文字内容,不包含结构标记。复杂混合格式、部分 HTML 标签跨度、多选区、表格、代码块、数学公式和 YAML 不直接改写;不可用动作会说明原因。 diff --git a/manifest.json b/manifest.json index 40adcf1..f559992 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ { "id": "hover-bar", "name": "Hover Bar", - "version": "0.1.1", + "version": "0.1.2", "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": true + "isDesktopOnly": false } diff --git a/package-lock.json b/package-lock.json index 610129d..9b12ab5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hover-bar", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hover-bar", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { "entities": "8.1.0" diff --git a/package.json b/package.json index 0cafc8f..9239754 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hover-bar", - "version": "0.1.1", + "version": "0.1.2", "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 5c31890..b099150 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, true); +assert.equal(manifest.isDesktopOnly, false, 'Hover Bar must remain available on mobile'); 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/controller.ts b/src/editor/controller.ts index 5f15cf6..0becd77 100644 --- a/src/editor/controller.ts +++ b/src/editor/controller.ts @@ -1,13 +1,13 @@ import type { EditorView, ViewUpdate } from '@codemirror/view'; import { undo, redo } from '@codemirror/commands'; -import { editorInfoField, editorLivePreviewField, type App, type TFile } from 'obsidian'; +import { editorInfoField, editorLivePreviewField, Platform, 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 } from '../ui/position'; +import { place, placeTouch, viewportBounds } from '../ui/position'; import { applyEdit, ownEdit } from './apply-edit'; import { capture, sameSelection, type SelectionSnapshot } from './context'; @@ -22,6 +22,9 @@ 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; @@ -54,6 +57,9 @@ export class EditorController { 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)); const checkFocus = () => queueMicrotask(() => { if (!this.destroyed && !this.view.hasFocus && !this.toolbar.ownsFocus()) this.close(false); @@ -61,12 +67,21 @@ export class EditorController { 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, 'contextmenu', () => this.close(false)); + 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); + }); this.listen(this.document, 'keydown', event => { if ((event as KeyboardEvent).key === 'Escape' && !this.toolbar.ownsFocus()) this.close(false); }); this.listen(this.window, 'blur', () => { this.stopDrag(); this.close(false); }); this.listen(this.window, 'resize', () => this.measure()); + if (this.window.visualViewport) { + this.listen(this.window.visualViewport, 'resize', () => this.measure()); + this.listen(this.window.visualViewport, 'scroll', () => this.measure()); + } this.listen(this.document, 'scroll', () => this.measure(), true); host.controllers.add(this); } @@ -77,11 +92,16 @@ export class EditorController { } private beginDrag(event: PointerEvent): void { - if (event.button !== 0 || event.pointerType === 'touch') return; + 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(); @@ -104,7 +124,8 @@ export class EditorController { this.close(false); const pointer = update.transactions.some(transaction => transaction.isUserEvent('select.pointer')); const keyboard = this.host.store.value.keyboard && update.transactions.some(transaction => transaction.isUserEvent('select')); - if (this.host.store.value.automatic && !this.dragging && (pointer || keyboard)) this.schedule(); + 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(); } else if (this.current && (update.geometryChanged || update.viewportChanged)) this.measure(); } @@ -125,7 +146,7 @@ export class EditorController { this.current = snapshot; this.focusRequested ||= manual; this.toolbar.update(snapshot.located.fragment); - this.toolbar.show(this.view.scrollDOM.clientWidth < 384); + this.toolbar.show(this.view.scrollDOM.clientWidth < 384, this.touch); this.observer.observe(this.toolbar.root); this.measure(); return true; @@ -236,16 +257,25 @@ export class EditorController { const pane = this.view.scrollDOM.getBoundingClientRect(); const size = this.toolbar.root.getBoundingClientRect(); if (!from || !to) return null; - const bounds = { left: Math.max(0, pane.left), right: Math.min(this.window.innerWidth, pane.right), top: Math.max(0, pane.top), bottom: Math.min(this.window.innerHeight, pane.bottom) }; + const screen = viewportBounds(this.window.innerWidth, this.window.innerHeight, this.window.visualViewport); + const bounds = { left: Math.max(screen.left, pane.left), right: Math.min(screen.right, pane.right), top: Math.max(screen.top, pane.top), bottom: Math.min(screen.bottom, pane.bottom) }; const head = this.current.anchor > this.current.head ? from : to; - if (head.top < bounds.top || head.bottom > bounds.bottom) return null; + 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)) }; - return place(anchor, bounds, size.width, size.height); + // 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 }; }, - write: position => { + write: result => { if (this.destroyed || !this.current) return; + if (result && this.touch && this.toolbar.constrain(result.bounds)) { this.measure(); 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')); } else { this.toolbar.positioned(position.left, position.top); if (focus) this.toolbar.focus(); } }, diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 9135673..e7c837a 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -15,7 +15,7 @@ export const en = { 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 or trackpad.', + automatic: 'Show toolbar automatically', automaticDesc: 'Show formatting actions after selecting text with a mouse, trackpad, or touch.', 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 34c308a..9e3b6a2 100644 --- a/src/i18n/zh-cn.ts +++ b/src/i18n/zh-cn.ts @@ -16,7 +16,7 @@ export const zhCN: Strings = { noNotes: '没有找到匹配的笔记,请调整搜索内容。', searchHint: '最多显示 50 篇笔记,可继续输入以缩小范围。', searching: '正在查找笔记…', searchError: '无法读取笔记列表,请重试。', close: '关闭', back: '返回', automatic: '自动显示工具条', - automaticDesc: '使用鼠标或触控板选中文字后显示排版操作。', livePreview: '在实时预览中启用', + automaticDesc: '使用鼠标、触控板或触摸屏选中文字后显示排版操作。', livePreview: '在实时预览中启用', livePreviewDesc: '在实时预览编辑模式中使用工具条。', sourceMode: '在源码模式中启用', sourceModeDesc: '在编辑 Markdown 源码时使用工具条。', keyboard: '键盘选择后自动显示', keyboardDesc: '使用键盘扩展选区时也自动显示工具条。', reset: '重置偏好', diff --git a/src/ui/position.ts b/src/ui/position.ts index 93b89c6..08d6a67 100644 --- a/src/ui/position.ts +++ b/src/ui/position.ts @@ -1,6 +1,23 @@ export interface Box { left: number; right: number; top: number; bottom: number } export interface Position { left: number; top: number } +export function viewportBounds(width: number, height: number, viewport?: Pick | null): Box { + return viewport + ? { left: viewport.offsetLeft, top: viewport.offsetTop, right: viewport.offsetLeft + viewport.width, bottom: viewport.offsetTop + viewport.height } + : { left: 0, top: 0, right: width, bottom: height }; +} + +// Touch selection handles and the native edit menu stay near the text; dock away from them. +export function placeTouch(anchor: Box | null, bounds: Box, width: number, height: number, gap = 8): Position | null { + if (width > 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.ts b/src/ui/toolbar.ts index 8249998..f7c6ba6 100644 --- a/src/ui/toolbar.ts +++ b/src/ui/toolbar.ts @@ -2,6 +2,8 @@ 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'; export interface NoteChoice { readonly path: string; readonly name: string; readonly disabled?: boolean } export interface ToolbarActions { @@ -31,6 +33,9 @@ export class Toolbar { private returnTo: HTMLButtonElement | null = null; private queryId = 0; 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'); @@ -39,20 +44,28 @@ 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; 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); this.bar.append(button); + this.controls.set(key, button); strip.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. if (event.button === 0 && event.composedPath().some(node => (node as Element).tagName === 'BUTTON' || (node as Element).getAttribute?.('role') === 'option')) event.preventDefault(); }); this.root.addEventListener('keydown', event => this.onKey(event)); + 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); } @@ -84,26 +97,40 @@ export class Toolbar { this.roving(); } - show(compact: boolean): void { - this.compact = compact; this.root.classList.toggle('is-compact', compact); + 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; 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.classList.remove('is-measuring'); } - focus(): void { this.focusable(this.bar)[0]?.focus(); } + focus(): void { this.focusable(this.bar)[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)) { - this.closePanel(false); button.focus(); + 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); @@ -114,6 +141,8 @@ export class Toolbar { private startPanel(key: TextKey, 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. + 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'); @@ -123,12 +152,13 @@ export class Toolbar { } 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.panel.hidden = true; this.panel.replaceChildren(); for (const button of this.controls.values()) button.setAttribute('aria-expanded', 'false'); - if (restoreFocus) returnTo?.focus(); + if (restoreFocus && focus) returnTo?.focus({ preventScroll: true }); if (this.visible) this.actions.resize(); } @@ -152,7 +182,8 @@ export class Toolbar { 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(); grid.querySelector('button')?.focus(); + this.panel.append(grid, reset); this.actions.resize(); + if (!this.touch || this.ownsFocus()) grid.querySelector('button')?.focus({ preventScroll: true }); } private more(button: HTMLButtonElement): void { @@ -165,7 +196,8 @@ export class Toolbar { item.addEventListener('click', () => { if (!disabled) { this.actions.format(key); this.closePanel(true); } }); this.panel.append(item); } - this.actions.resize(); this.focusable(this.panel)[0]?.focus(); + this.actions.resize(); + if (!this.touch || this.ownsFocus()) this.focusable(this.panel)[0]?.focus({ preventScroll: true }); } notePicker(button = this.controls.get('chooseNote')!): void { @@ -222,11 +254,11 @@ export class Toolbar { 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(); this.actions.resize(); + this.panel.append(input, list, hint); void search(); input.focus({ preventScroll: true }); this.actions.resize(); } private focusable(container: HTMLElement): HTMLButtonElement[] { - return [...container.querySelectorAll('button')].filter(button => 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 && button.getAttribute('aria-disabled') !== 'true' && !(this.compact && button.classList.contains('hover-bar-secondary') && button.getAttribute('aria-pressed') !== 'true')); } private roving(): void { const focusable = this.focusable(this.bar); diff --git a/src/ui/touch.ts b/src/ui/touch.ts new file mode 100644 index 0000000..c0e3b37 --- /dev/null +++ b/src/ui/touch.ts @@ -0,0 +1,27 @@ +/** 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 5bd6e5d..6256f99 100644 --- a/styles.css +++ b/styles.css @@ -26,6 +26,7 @@ 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; @@ -109,6 +110,33 @@ .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 02286f8..9d3edb1 100644 --- a/tests/controller.test.ts +++ b/tests/controller.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { App, 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 { locateFragment } from '../src/formatting/fragment'; @@ -16,21 +19,30 @@ 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(); - show() { this.visible = true; } + show = vi.fn(() => { this.visible = true; }); hide() { this.visible = false; } }, })); -interface Measurement { key: unknown; read(): Position | null; write(position: Position | null): void } +type Layout = { bounds: Box; position: Position | null } | null; +interface Measurement { key: unknown; read(): Layout; write(position: Layout): 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) { - const window = Object.assign(new EventTarget(), { innerWidth: 600, innerHeight: 500 }); +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; + 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, + requestAnimationFrame: (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; }, + cancelAnimationFrame: (id: number) => frames.delete(id), + }); const document = Object.assign(new EventTarget(), { defaultView: window }); const dom = Object.assign(new EventTarget(), { ownerDocument: document, isConnected: true }); const requests = new Map(); @@ -47,12 +59,14 @@ function setup(start: Box = { left: 100, right: 100, top: 100, bottom: 120 }, en source: 'word', located: locateFragment('word', 0, 4)!, }; const notify = vi.fn(); - const controller = new EditorController(view, { app: {} as App, store: new PreferencesStore({ load: async () => null, save: async () => {} }), t: key => key, controllers: new Set(), notify }); + const store = new PreferencesStore({ load: async () => null, save: async () => {} }); + const controller = new EditorController(view, { app: {} as App, store, t: key => key, controllers: new Set(), notify }); const flush = () => { + const callbacks = [...frames.values()]; frames.clear(); callbacks.forEach(callback => callback(0)); const pending = [...requests.values()]; requests.clear(); for (const request of pending) request.write(request.read()); }; - return { controller, toolbar: controller['toolbar'], flush, notify }; + return { controller, toolbar: controller['toolbar'], flush, notify, viewport, dom, document, store }; } beforeEach(() => { @@ -62,7 +76,7 @@ beforeEach(() => { disconnect() {} }); }); -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { vi.unstubAllGlobals(); Platform.isMobile = false; }); describe('selection toolbar focus and placement', () => { it('preserves a manual focus request when resize measurement replaces it, then focuses only once', () => { @@ -115,3 +129,97 @@ describe('selection toolbar focus and placement', () => { controller.destroy(); }); }); + +function select(userEvent?: string): ViewUpdate { + const state = EditorState.create({ doc: 'word' }); + const transaction = state.update({ selection: { anchor: 0, head: 4 }, userEvent }); + 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); + controller.show(false); flush(); + vi.mocked(toolbar.contains).mockReturnValue(true); + const menu = new Event('contextmenu', { cancelable: true }); document.dispatchEvent(menu); + expect(menu.defaultPrevented).toBe(false); expect(toolbar.visible).toBe(true); + controller.destroy(); + }); + it('still closes for a context menu outside the toolbar', () => { + const { controller, toolbar, flush, document } = setup(); + 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); + 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); + controller.destroy(); + }); + it('still honors automatic and hardware-keyboard preferences on mobile', async () => { + const { controller, toolbar, flush, dom, store } = setup(undefined, undefined, false, true); + await store.load(); + dom.dispatchEvent(new Event('keydown')); 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(); + expect(toolbar.visible).toBe(false); + 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('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); + 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); + 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); + 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); + controller.destroy(); + viewport.dispatchEvent(new Event('resize')); flush(); expect(toolbar.visible).toBe(false); + }); +}); diff --git a/tests/mobile-position.test.ts b/tests/mobile-position.test.ts new file mode 100644 index 0000000..80367b5 --- /dev/null +++ b/tests/mobile-position.test.ts @@ -0,0 +1,25 @@ +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/release.test.ts b/tests/release.test.ts index 3be4601..60e4a6f 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: true, description: 'Format selected text.' }); + json('manifest.json', { id: 'hover-bar', version: '0.1.0', minAppVersion: '1.8.7', isDesktopOnly: false, 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,6 +56,11 @@ 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 () => { + 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'); + }); it.each(['v0.1.0', '0.2.0'])('rejects a mismatched tag %s', async version => { process.argv[3] = version; await expect(check()).rejects.toThrow('Requested version must match the manifest'); diff --git a/tests/support/obsidian.ts b/tests/support/obsidian.ts index d0bb018..99c4467 100644 --- a/tests/support/obsidian.ts +++ b/tests/support/obsidian.ts @@ -8,3 +8,4 @@ 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/toolbar.test.ts b/tests/toolbar.test.ts new file mode 100644 index 0000000..6e106a1 --- /dev/null +++ b/tests/toolbar.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Toolbar } from '../src/ui/toolbar'; + +vi.mock('obsidian', async importOriginal => ({ + ...await importOriginal(), setIcon: vi.fn(), +})); + +function panelSession(focused: boolean) { + const input = {}; + const editor = {}; + const body = {}; + const document = { activeElement: focused ? input : editor }; + const button = { focus: vi.fn(() => { document.activeElement = button; }) }; + let attached = true; + // Model the browser boundary: removing a focused input moves focus to the body. + const panel = { + hidden: false, setAttribute: vi.fn(), + replaceChildren: () => { if (document.activeElement === input) document.activeElement = body; attached = false; }, + }; + 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, + queryAbort: null, queryId: 0, controls: new Map(), t: (key: string) => key, actions: { resize: vi.fn() }, + }); + return { toolbar, document, button, editor }; +} + +describe('toolbar panel focus handoff', () => { + it.each(['foreground', 'background', 'more'] as const)('retains ownership when switching a focused search to %s', key => { + const { toolbar, document, button } = panelSession(true); + expect(toolbar['startPanel'](key, button as unknown as HTMLButtonElement)).toBe(true); + expect(document.activeElement).toBe(button); + 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', () => { + 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 new file mode 100644 index 0000000..fe49250 --- /dev/null +++ b/tests/touch.test.ts @@ -0,0 +1,46 @@ +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 66fb794..830fe2e 100644 --- a/versions.json +++ b/versions.json @@ -1,4 +1,5 @@ { "0.1.0": "1.14.4", - "0.1.1": "1.8.7" + "0.1.1": "1.8.7", + "0.1.2": "1.8.7" }