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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@

Notable changes to Hover Bar, grouped by release.

## 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

### Added
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 8 additions & 6 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 on desktop, phone, and tablet.
A lightweight, offline selection toolbar for formatting text in desktop Obsidian.

[简体中文](README.zh-CN.md)

Expand All @@ -15,25 +15,23 @@ 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.
- Undo and redo through the editor's normal history.
- 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**` |
Expand All @@ -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
Expand All @@ -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.
Expand Down
16 changes: 9 additions & 7 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,37 @@
# Hover Bar

轻量、离线的 Obsidian 选区排版插件,支持电脑、手机和平板。选中文字后,直接完成排版、文字着色、背景着色和双链操作。
轻量、离线的 Obsidian 桌面端选区排版插件。选中文字后,直接完成排版、文字着色、背景着色和双链操作。

[English](README.md)

## 如何使用

阅读笔记时标记重点,整理摘录时调整层次,或把一句话中的概念连接到已有笔记。选中文字后,工具条在附近出现,常用排版可以直接点击完成。

常规格式优先使用 Markdown;选择自定义文字色或背景色时,自动写入简洁的行内 HTML,无需手写颜色标签。
常规格式优先使用 Markdown;从色板选择文字色或背景色时,自动写入简洁的行内 HTML,无需手写颜色标签。

![Obsidian 中选中文字上方的 Hover Bar 工具条](assets/demos/toolbar.png)

## 功能

- 桌面端完成选区后弹出紧凑胶囊工具条,靠近文字并避开窗格边界。
- 触摸屏使用底部工具条,按钮可横向滑动,增大点击区域,并适应软键盘空间。
- 加粗、斜体、删除线、行内代码和 Obsidian 标准高亮。
- 带名称的文字色与背景色色板,可分别移除颜色。
- 一键生成 `[[双链]]`,也可搜索并关联已有笔记。
- 使用编辑器原生撤销和重做,连续改色不叠套标签。
- 适配明暗主题,支持键盘操作。
- Obsidian 设置为中文时显示中文,其他任何语言均显示英文。
- 可关闭自动弹出,保留命令面板手动呼出入口。
- 手动操作会区分只读、模式关闭、多选区、跨行、格式冲突和长度限制,并说明如何继续。

## 使用方法

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

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

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

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

## 兼容性与限制

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

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

自动划选遇到不支持的内容时安静跳过。需要了解原因时,可通过命令面板手动呼出,或激活不可用动作查看说明。长度保护同时检查原始选区、所在行及修改后的整行;过长时请缩小选区或先拆分段落,不会先写入再中断操作。

颜色以行内 HTML 保存在笔记中。关闭插件不会移除内容,显示效果取决于主题与渲染器,部分导出工具可能移除样式。源码模式会显示标记,实时预览在编辑时也可能露出源码;双链按钮明确输出 wikilink。

## 安装
Expand All @@ -73,6 +73,8 @@ Hover Bar 目前尚未上架社区插件市场,可从 [GitHub Releases](https:

界面跟随 Obsidian 的语言设置:中文及中文变体显示简体中文文案,其他任何语言显示英文,无需单独设置语言。

手动打开工具条后,用方向键浏览操作,Enter 或 Space 激活,Esc 先关闭面板再关闭工具条。不可用按钮同样可以通过方向键访问并了解原因;激活它只显示说明,不修改正文。搜索笔记时,输入法组词的 Enter 不会提前插入链接。

## 隐私

插件完全离线,无账户、无运行时网络请求、无遥测、不访问 vault 之外的文件。笔记选择器仅使用 vault 文件元数据,排版仅修改用户明确操作的选区。
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.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
}
10 changes: 5 additions & 5 deletions package-lock.json

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

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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",
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, 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');
Expand Down
26 changes: 18 additions & 8 deletions src/editor/apply-edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,36 @@ 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<boolean>();

export function checkEditBudget(snapshot: Pick<SelectionSnapshot, 'lineLength'>, 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({
changes: { from, to, insert: plan.insert },
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);
}
4 changes: 2 additions & 2 deletions src/editor/color-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -33,7 +33,7 @@ export function colorPreview(host: Host) {
for (const match of line.text.matchAll(/<span style="[^"]+">[^<>]*<\/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]);
Expand Down
Loading
Loading