From 285449347697f8c8184dea4e348f2dec66cf6cb5 Mon Sep 17 00:00:00 2001
From: johnny1093 <46250921+jsmorabito@users.noreply.github.com>
Date: Fri, 17 Jul 2026 10:26:11 -0400
Subject: [PATCH] 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.
---
README.md | 5 +-
manifest.json | 4 +-
package.json | 2 +-
src/__tests__/formatting.test.ts | 58 +++++++++++++++++++++++
src/constants.ts | 2 +
src/formatting.ts | 48 +++++++++++++++++++
src/icons.ts | 14 ++++++
src/main.ts | 81 ++++++++++++++++----------------
src/textToolbarManager.ts | 36 ++++++++++++++
versions.json | 3 +-
10 files changed, 207 insertions(+), 46 deletions(-)
create mode 100644 src/icons.ts
diff --git a/README.md b/README.md
index 1bd3429..17bfeec 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-
-
+
+
@@ -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 (H1–H6) 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
diff --git a/manifest.json b/manifest.json
index 53fca18..3e6646c 100644
--- a/manifest.json
+++ b/manifest.json
@@ -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",
diff --git a/package.json b/package.json
index 0e49be7..bfc8de5 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/__tests__/formatting.test.ts b/src/__tests__/formatting.test.ts
index 7ac4a1d..1d9d2bf 100644
--- a/src/__tests__/formatting.test.ts
+++ b/src/__tests__/formatting.test.ts
@@ -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]]");
+ });
+});
diff --git a/src/constants.ts b/src/constants.ts
index 8db50cf..ce7e1f5 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -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 %%)" },
diff --git a/src/formatting.ts b/src/formatting.ts
index a7441b2..3d58c68 100644
--- a/src/formatting.ts
+++ b/src/formatting.ts
@@ -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,
+ };
+}
diff --git a/src/icons.ts b/src/icons.ts
new file mode 100644
index 0000000..cd61ad7
--- /dev/null
+++ b/src/icons.ts
@@ -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 = `
+
+
+
+
+`;
diff --git a/src/main.ts b/src/main.ts
index a2769b9..e59866d 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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 {
+ 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 {
+ 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);
}
}
diff --git a/src/textToolbarManager.ts b/src/textToolbarManager.ts
index aa8ff6e..3a28e71 100644
--- a/src/textToolbarManager.ts
+++ b/src/textToolbarManager.ts
@@ -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) {
diff --git a/versions.json b/versions.json
index 1764c61..09aecb9 100644
--- a/versions.json
+++ b/versions.json
@@ -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"
}