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 @@ - - + + Shows project promo image in light and dark mode @@ -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" }