Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ node_modules/
/playwright-report/
/test-results/
/output/playwright/
/.playwright-cli/
/.cache/
/tmp/

Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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.
Expand All @@ -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**` |
Expand All @@ -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.

Expand All @@ -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.

Expand Down
11 changes: 7 additions & 4 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Hover Bar

轻量、离线的 Obsidian 选区排版插件。划选文字后,就近完成排版、文字着色、背景着色和双链操作,让鼠标与触控板编辑更顺手。
轻量、离线的 Obsidian 选区排版插件,支持电脑、手机和平板。选中文字后,直接完成排版、文字着色、背景着色和双链操作。

[English](README.md)

Expand All @@ -14,7 +14,8 @@

## 功能

- 完成选区后弹出紧凑胶囊工具条,靠近文字并避开窗格边界。
- 桌面端完成选区后弹出紧凑胶囊工具条,靠近文字并避开窗格边界。
- 触摸屏使用底部工具条,按钮可横向滑动,增大点击区域,并适应软键盘空间。
- 加粗、斜体、删除线、行内代码和 Obsidian 标准高亮。
- 带名称的文字色与背景色色板,可分别移除颜色。
- 一键生成 `[[双链]]`,也可搜索并关联已有笔记。
Expand All @@ -26,11 +27,13 @@
## 使用方法

1. 在实时预览或源码模式中打开 Markdown 笔记。
2. 用鼠标或触控板选中一段普通文字。
2. 用鼠标或触控板选中一段普通文字;触摸屏上可长按文字并拖动选区手柄。
3. 点击格式按钮,或打开色板选择颜色。

点击外部或按 Esc 关闭。键盘用户可通过命令面板中的「显示选区工具条」打开工具条,也可在 Obsidian 的快捷键设置中自行绑定快捷键。

手机和平板上的工具条停靠在编辑区域底部,软键盘打开时位于键盘上方。左右滑动按钮行可访问全部操作,点击 **×** 关闭。色板和笔记搜索在按钮行上方展开,空间不足时可滚动面板。系统的复制、粘贴菜单仍可使用,也可通过命令面板中的「显示选区工具条」手动呼出。

| 操作 | 笔记中保存的内容示例 |
| --- | --- |
| 加粗 | `**重点**` |
Expand All @@ -42,7 +45,7 @@

## 兼容性与限制

需要桌面端 Obsidian **1.8.7 或更高版本**,支持实时预览和源码模式。阅读视图与移动端触摸选区不提供工具条。
需要 Obsidian **1.8.7 或更高版本**,支持桌面端、iOS 和 Android 的实时预览与源码模式。阅读视图不提供工具条。

格式操作支持同一逻辑行中的单一文本片段,包括标题、列表项和引用中的文字;请选择文字内容,不包含结构标记。复杂混合格式、部分 HTML 标签跨度、多选区、表格、代码块、数学公式和 YAML 不直接改写;不可用动作会说明原因。

Expand Down
4 changes: 2 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
50 changes: 40 additions & 10 deletions src/editor/controller.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
Expand Down Expand Up @@ -54,19 +57,31 @@ 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);
});
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);
}
Expand All @@ -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();
Expand All @@ -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();
}

Expand All @@ -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;
Expand Down Expand Up @@ -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(); }
},
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/zh-cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '重置偏好',
Expand Down
17 changes: 17 additions & 0 deletions src/ui/position.ts
Original file line number Diff line number Diff line change
@@ -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<VisualViewport, 'offsetLeft' | 'offsetTop' | 'width' | 'height'> | 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;
Expand Down
Loading
Loading