feat: restore wikilink toggle button and declarative Settings API, bump to 0.2.4

Re-applies the changes held back on wikilink-and-settings-api after
confirming the "attestation failed" review result was caused by an
upstream bug in Obsidian's community review backend (it doesn't yet
handle a GitHub REST API breaking change to attestation responses),
not by anything in this repo or release. Combines that restored code
with the manifest.json attestation fix already on main.
This commit is contained in:
johnny1093 2026-07-17 10:26:11 -04:00
parent d1ffaf19f4
commit 2854493476
10 changed files with 207 additions and 46 deletions

View file

@ -1,6 +1,6 @@
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/user-attachments/assets/b278d61d-7ab0-431f-b911-2474d16381a7">
<source media="(prefers-color-scheme: light)" srcset="https://github.com/user-attachments/assets/996ae572-1f24-4b22-b972-b67215cb8fd2">
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/user-attachments/assets/94439f21-bf96-43fa-88e9-217bd96b11c8">
<source media="(prefers-color-scheme: light)" srcset="https://github.com/user-attachments/assets/f747ca4d-12be-4912-bb7a-b390f8806a71">
<img alt="Shows project promo image in light and dark mode">
</picture>
@ -15,6 +15,7 @@ When you select text in a note, a toolbar appears with one-click access to commo
- **Text Style** — set heading level (H1H6) or paragraph
- **Bold**, **Italic**, **Strikethrough**, **Highlight**
- **Link** — wrap selection in a Markdown link
- **Wikilink** - wrap selection in brackets
- **Inline Code** / **Code Block**
- **Comment** — wrap in Obsidian comment syntax (`%% ... %%`)
- **Lists** — bullet list, numbered list, or checkbox

View file

@ -1,8 +1,8 @@
{
"id": "text-formatting-toolbar",
"name": "Text Formatting Toolbar",
"version": "0.2.3",
"minAppVersion": "1.0.0",
"version": "0.2.4",
"minAppVersion": "1.13.0",
"description": "A floating formatting toolbar that appears above selected text in the editor.",
"author": "John Morabito",
"authorUrl": "https://github.com/jsmorabito",

View file

@ -1,6 +1,6 @@
{
"name": "text-toolbar",
"version": "0.2.3",
"version": "0.2.4",
"description": "A floating formatting toolbar for Obsidian",
"main": "main.js",
"scripts": {

View file

@ -6,6 +6,7 @@ import {
applyHeading,
toggleBlockquote,
toggleCodeBlock,
toggleWikilink,
wrapLink,
type SelectionInfo,
} from "../formatting";
@ -282,3 +283,60 @@ describe("wrapLink", () => {
expect(ch).toBe(3);
});
});
// ---------------------------------------------------------------------------
// toggleWikilink
// ---------------------------------------------------------------------------
describe("toggleWikilink — wrap", () => {
it("wraps plain text in double brackets", () => {
const r = toggleWikilink(sel("my note"));
expect(r.replacement).toBe("[[my note]]");
expect(r.newFrom).toEqual({ line: 0, ch: 2 });
expect(r.newTo).toEqual({ line: 0, ch: 9 });
});
it("wraps empty selection", () => {
const r = toggleWikilink(sel(""));
expect(r.replacement).toBe("[[]]");
expect(r.newFrom).toEqual({ line: 0, ch: 2 });
expect(r.newTo).toEqual({ line: 0, ch: 2 });
});
});
describe("toggleWikilink — unwrap selection-wrapped", () => {
it("unwraps [[note]] when selection contains the brackets", () => {
const r = toggleWikilink(sel("[[my note]]"));
expect(r.replacement).toBe("my note");
expect(r.newFrom).toEqual({ line: 0, ch: 2 });
expect(r.newTo).toEqual({ line: 0, ch: 9 });
});
it("does NOT unwrap when content is just the brackets (too short)", () => {
// "[[]]" length is 4, not > 2+2
const r = toggleWikilink(sel("[[]]"));
expect(r.replacement).toBe("[[[[]]]]");
});
});
describe("toggleWikilink — unwrap outside-selection", () => {
it("unwraps when brackets sit just outside the selection", () => {
// full line: [[my note]]
// selection: my note (ch 2..9)
const info: SelectionInfo = {
sel: "my note",
from: { line: 0, ch: 2 },
to: { line: 0, ch: 9 },
line: "[[my note]]",
};
const r = toggleWikilink(info);
expect(r.replacement).toBe("my note");
expect(r.newFrom).toEqual({ line: 0, ch: 0 });
expect(r.newTo).toEqual({ line: 0, ch: 7 });
});
it("wraps normally when brackets are NOT outside the selection", () => {
const r = toggleWikilink(sel("my note"));
expect(r.replacement).toBe("[[my note]]");
});
});

View file

@ -1,3 +1,4 @@
import { WIKILINK_ICON_ID } from "./icons";
import { TextToolbarSettings } from "./types";
export const TEXT_TOOLBAR_DEFAULT_COMMANDS: {
@ -12,6 +13,7 @@ export const TEXT_TOOLBAR_DEFAULT_COMMANDS: {
{ id: "strikethrough", icon: "strikethrough", name: "Strikethrough", description: "Toggle strikethrough formatting" },
{ id: "highlight", icon: "highlighter", name: "Highlight", description: "Toggle highlight formatting" },
{ id: "link", icon: "link-2", name: "Link", description: "Wrap selected text in a markdown link" },
{ id: "wikilink", icon: WIKILINK_ICON_ID,name: "Wikilink", description: "Toggle double brackets around selected text to link to a note" },
{ id: "code", icon: "code", name: "Inline Code", description: "Toggle inline code formatting" },
{ id: "code-block", icon: "code-2", name: "Code Block", description: "Wrap selection in a fenced code block" },
{ id: "comment", icon: "message-square",name: "Comment", description: "Wrap selection in an Obsidian comment (%% hidden from preview %%)" },

View file

@ -107,3 +107,51 @@ export function toggleCodeBlock(sel: string): string {
export function wrapLink(sel: string, fromCh: number): [string, number] {
return [`[${sel}]()`, fromCh + 1 + sel.length + 2];
}
/**
* Toggles a `[[double bracket]]` wikilink wrap for Obsidian note links.
* Same wrap/unwrap shape as toggleInlineFormat, but with distinct
* open/close markers instead of a single symmetric one.
*/
export function toggleWikilink(info: SelectionInfo): ToggleResult {
const { sel, from, to, line } = info;
const open = "[[";
const close = "]]";
// Case 1: selection itself is wrapped — unwrap
if (
from.line === to.line &&
sel.length > open.length + close.length &&
sel.startsWith(open) &&
sel.endsWith(close)
) {
const inner = sel.slice(open.length, sel.length - close.length);
return {
replacement: inner,
newFrom: { line: from.line, ch: from.ch + open.length },
newTo: { line: from.line, ch: from.ch + open.length + inner.length },
};
}
// Case 2: markers are just outside the selection — expand-select then unwrap
if (from.line === to.line && from.ch >= open.length && to.ch + close.length <= line.length) {
const before = line.substring(from.ch - open.length, from.ch);
const after = line.substring(to.ch, to.ch + close.length);
if (before === open && after === close) {
return {
replacement: sel,
newFrom: { line: from.line, ch: from.ch - open.length },
newTo: { line: from.line, ch: from.ch - open.length + sel.length },
};
}
}
// Case 3: wrap
return {
replacement: `${open}${sel}${close}`,
newFrom: { line: from.line, ch: from.ch + open.length },
newTo: from.line === to.line
? { line: from.line, ch: from.ch + open.length + sel.length }
: to,
};
}

14
src/icons.ts Normal file
View file

@ -0,0 +1,14 @@
/**
* Custom icons registered via Obsidian's `addIcon`, which renders content
* inside a fixed 100x100 viewBox so artwork authored on a 24x24 grid is
* wrapped in a scale transform to preserve its proportions.
*/
export const WIKILINK_ICON_ID = "tt-double-brackets";
export const WIKILINK_ICON_SVG = `<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" transform="scale(4.16667)">
<path d="M14 5H15.5C15.6326 5 15.7598 5.08194 15.8536 5.22781C15.9473 5.37367 16 5.5715 16 5.77778V18.2222C16 18.4285 15.9473 18.6263 15.8536 18.7722C15.7598 18.9181 15.6326 19 15.5 19H14"/>
<path d="M19 5H20.5C20.6326 5 20.7598 5.08194 20.8536 5.22781C20.9473 5.37367 21 5.5715 21 5.77778V18.2222C21 18.4285 20.9473 18.6263 20.8536 18.7722C20.7598 18.9181 20.6326 19 20.5 19H19"/>
<path d="M10 19H8.5C8.36739 19 8.24021 18.9181 8.14645 18.7722C8.05268 18.6263 8 18.4285 8 18.2222V5.77778C8 5.5715 8.05268 5.37367 8.14645 5.22781C8.24021 5.08194 8.36739 5 8.5 5H10"/>
<path d="M5 19H3.5C3.36739 19 3.24021 18.9181 3.14645 18.7722C3.05268 18.6263 3 18.4285 3 18.2222V5.77778C3 5.5715 3.05268 5.37367 3.14645 5.22781C3.24021 5.08194 3.36739 5 3.5 5H5"/>
</g>`;

View file

@ -1,5 +1,6 @@
import { App, Plugin, PluginSettingTab, Setting } from "obsidian";
import { addIcon, App, Plugin, PluginSettingTab, SettingDefinitionItem } from "obsidian";
import { DEFAULT_SETTINGS, TEXT_TOOLBAR_DEFAULT_COMMANDS } from "./constants";
import { WIKILINK_ICON_ID, WIKILINK_ICON_SVG } from "./icons";
import { TextToolbarAPI, TextToolbarExternalCommand, TextToolbarSettings } from "./types";
import TextToolbarManager from "./textToolbarManager";
@ -20,6 +21,7 @@ export default class TextToolbarPlugin extends Plugin {
};
public async onload(): Promise<void> {
addIcon(WIKILINK_ICON_ID, WIKILINK_ICON_SVG);
await this.loadSettings();
this.manager = new TextToolbarManager(this);
this.addSettingTab(new TextToolbarSettingTab(this.app, this));
@ -47,46 +49,45 @@ class TextToolbarSettingTab extends PluginSettingTab {
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
getSettingDefinitions(): SettingDefinitionItem[] {
return [
{
name: "Enable text toolbar",
desc: "Show a floating formatting toolbar when text is selected in the editor.",
control: { type: "toggle", key: "enabled" },
},
{
type: "group",
heading: "Default commands",
items: TEXT_TOOLBAR_DEFAULT_COMMANDS.map(cmd => ({
name: cmd.name,
desc: cmd.description,
control: { type: "toggle", key: `command:${cmd.id}` },
})),
},
];
}
new Setting(containerEl)
.setName("Enable text toolbar")
.setDesc("Show a floating formatting toolbar when text is selected in the editor.")
.addToggle(t =>
t
.setValue(this.plugin.settings.enabled)
.onChange(async v => {
this.plugin.settings.enabled = v;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Default commands")
.setHeading()
.setDesc("Toggle which built-in formatting buttons appear in the toolbar.");
for (const cmd of TEXT_TOOLBAR_DEFAULT_COMMANDS) {
new Setting(containerEl)
.setName(cmd.name)
.setDesc(cmd.description)
.addToggle(t =>
t
.setValue(!this.plugin.settings.hideCommands.includes(cmd.id))
.onChange(async v => {
if (v) {
this.plugin.settings.hideCommands = this.plugin.settings.hideCommands.filter(
id => id !== cmd.id
);
} else if (!this.plugin.settings.hideCommands.includes(cmd.id)) {
this.plugin.settings.hideCommands.push(cmd.id);
}
await this.plugin.saveSettings();
this.plugin.manager.refresh();
})
);
getControlValue(key: string): unknown {
if (key.startsWith("command:")) {
const id = key.slice("command:".length);
return !this.plugin.settings.hideCommands.includes(id);
}
return super.getControlValue(key);
}
async setControlValue(key: string, value: unknown): Promise<void> {
if (key.startsWith("command:")) {
const id = key.slice("command:".length);
if (value) {
this.plugin.settings.hideCommands = this.plugin.settings.hideCommands.filter(hid => hid !== id);
} else if (!this.plugin.settings.hideCommands.includes(id)) {
this.plugin.settings.hideCommands.push(id);
}
await this.plugin.saveSettings();
this.plugin.manager.refresh();
return;
}
await super.setControlValue(key, value);
}
}

View file

@ -8,6 +8,7 @@ import {
toggleBlockquote,
toggleCodeBlock,
toggleInlineFormat,
toggleWikilink,
wrapLink,
} from "./formatting";
import type TextToolbarPlugin from "./main";
@ -159,6 +160,7 @@ export default class TextToolbarManager {
case "code": this.toggleInlineFormat("`"); break;
case "comment": this.toggleInlineFormat("%%"); break;
case "link": this.execLink(); return;
case "wikilink": this.toggleWikilinkFormat(); break;
case "code-block": this.execCodeBlock(); return;
case "blockquote": this.execBlockquote(); return;
}
@ -243,6 +245,40 @@ export default class TextToolbarManager {
this.hide();
}
private toggleWikilinkFormat(): void {
const view = this.plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) return;
const editor = view.editor;
const sel = editor.getSelection();
if (!sel) return;
const from = editor.getCursor("from");
const to = editor.getCursor("to");
const line = editor.getLine(from.line);
const result = toggleWikilink({ sel, from, to, line });
// Case 2: brackets sit just outside the current selection — expand then replace
if (
from.line === to.line &&
from.ch >= 2 && to.ch + 2 <= line.length &&
line.substring(from.ch - 2, from.ch) === "[[" &&
line.substring(to.ch, to.ch + 2) === "]]" &&
result.replacement === sel
) {
editor.setSelection(
{ line: from.line, ch: from.ch - 2 },
{ line: to.line, ch: to.ch + 2 },
);
editor.replaceSelection(result.replacement);
editor.setSelection(result.newFrom, result.newTo);
return;
}
editor.replaceSelection(result.replacement);
editor.setSelection(result.newFrom, result.newTo);
}
private execCopy(): void {
const view = this.plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (view) {

View file

@ -9,5 +9,6 @@
"0.1.7": "1.0.0",
"0.1.8": "1.0.0",
"0.2.2": "1.0.0",
"0.2.3": "1.0.0"
"0.2.3": "1.0.0",
"0.2.4": "1.13.0"
}