From 73ec5266250f7fe165da4e68cf924c29b34c3758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=AD=E8=AF=B4=E4=BA=91=E5=8E=9F=E7=94=9F?= Date: Tue, 14 Jul 2026 16:20:29 +0800 Subject: [PATCH] =?UTF-8?q?v1.0.0=20=E5=88=9D=E5=A7=8B=E5=8F=91=E5=B8=83?= =?UTF-8?q?=EF=BC=9A=E4=BB=A3=E7=A0=81=E5=9D=97=E5=BF=AB=E6=8D=B7=E6=8F=92?= =?UTF-8?q?=E5=85=A5=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 24 +++ LICENSE | 21 ++ README.md | 91 +++++++++ main.js | 538 ++++++++++++++++++++++++++++++++++++++++++++++++++ main.ts | 504 ++++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 9 + styles.css | 164 +++++++++++++++ 7 files changed, 1351 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 main.js create mode 100644 main.ts create mode 100644 manifest.json create mode 100644 styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ae3293 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Obsidian runtime data (用户本地设置,不入库) +data.json + +# Dependencies +node_modules/ + +# Logs +*.log +npm-debug.log* + +# OS +.DS_Store +Thumbs.db + +# Editor +.idea/ +.vscode/ +*.swp +*.swo + +# Build output (if using TypeScript compilation) +# 注意:本插件的 main.js 是手写纯 JS,需要入库 +# 如果改用 TS 编译流程,取消下面注释让 main.js 成为编译产物 +# main.js diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a23ec03 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 旭说云原生 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c8c943 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Quick CodeBlock / 代码块快捷插入 + +快捷插入 Markdown 代码块,光标自动定位到块内。支持自定义语言、选中文本一键包裹、快捷键配置与冲突检测。 + +替代 Templater 的代码块模板,无需 Templater 依赖。 + +## 功能特性 + +- **快捷插入代码块**:插入 ` ```lang ` 代码块,光标自动定位到块内空行,直接开始写代码 +- **选中文本包裹**:选中一段文字,触发命令,自动用代码块包裹 +- **自定义语言列表**:默认含 python / javascript / bash / shell / sql / json / yaml / go / rust / java / html / css,可自行增删 +- **语言选择弹窗**:不想记快捷键?用「选择语言」命令弹出列表点选 +- **快捷键自定义**:在插件设置面板里直接为需要的语言配置快捷键,无需去系统设置里翻 +- **快捷键冲突检测**:设置快捷键时即时检测是否与其他命令冲突,冲突显示警告并提供跳转入口 +- **智能换行**:光标前后有内容时自动处理换行,不会粘连到已有文字 + +## 安装 + +### 方式一:社区插件(审核通过后) + +Obsidian → 设置 → 第三方插件 → 社区插件 → 搜索「Quick CodeBlock」→ 安装 + +### 方式二:手动安装 + +1. 下载最新 Release 的 `main.js`、`manifest.json`、`styles.css` +2. 在 Obsidian 仓库下创建 `.obsidian/plugins/quick-codeblock/` 目录 +3. 将 3 个文件放入该目录 +4. Obsidian → 设置 → 第三方插件 → 关闭安全模式 → 找到「代码块快捷插入」→ 开启 + +## 使用方法 + +### 插入代码块 + +1. `Ctrl+P` 打开命令面板,搜索「插入代码块」 +2. 选择对应语言的命令(如「插入代码块(python)」) +3. 无选中文本时:插入空代码块,光标自动定位到块内 +4. 有选中文本时:用 ` ```lang ` 包裹选中的内容 + +### 配置快捷键 + +1. 打开插件设置(设置 → 第三方插件 → 代码块快捷插入 → 选项) +2. 在「快捷键设置」区域,默认有一项(python) +3. 点击「+ 添加快捷键配置」增加新项 +4. 每项可选择语言(dropdown)+ 设置快捷键 + 删除 +5. 点击快捷键输入框,按下组合键即可设置;按 `Backspace` 或 `Esc` 清除 +6. 设置后自动检测冲突,有冲突会显示红色警告和「前往修改」链接 + +### 替代 Templater + +如果你之前用 Templater 的代码块模板(如 `codepython.md`),本插件完全覆盖该功能: + +| | Templater | 本插件 | +|---|---|---| +| 依赖 | 需要 Templater 插件 | 无依赖 | +| 插入 ```python + 光标定位 | `tp.file.cursor()` | 自动计算行号定位 | +| 多语言 | 要写多个模板 | 一个插件搞定 | +| 选中文本包裹 | 不支持 | 支持 | +| 快捷键管理 | 不支持 | 内置配置 + 冲突检测 | + +## 设置说明 + +| 设置项 | 说明 | +|--------|------| +| 默认语言 | 主命令「插入代码块」使用的语言 | +| 语言列表 | 每行一个,为每个语言注册独立命令 | +| 保留选中内容缩进 | 选中文本包裹时是否保留原有缩进 | +| 快捷键设置 | 为需要的语言配置快捷键(默认一项,可自行添加) | + +## 命令列表 + +| 命令 | 作用 | +|------|------| +| 插入代码块(python) | 主命令,用默认语言 | +| 插入代码块(选择语言) | 弹出列表选择语言 | +| 插入代码块(javascript) | 每个预设语言各一个命令 | +| ... | 语言列表里的每个语言都会注册 | + +## 技术说明 + +- 纯 JavaScript 实现(`main.js`),无需编译,直接可用 +- `main.ts` 为 TypeScript 源码参考,供二次开发使用 +- 快捷键通过 `hotkeyManager.setHotkeys` 绑定,与 Obsidian 系统快捷键互通 +- 卸载插件时自动清除快捷键绑定,保持 `hotkeys.json` 干净 + +## 许可证 + +[MIT License](./LICENSE) + +## 作者 + +**旭说云原生** diff --git a/main.js b/main.js new file mode 100644 index 0000000..f2d9a18 --- /dev/null +++ b/main.js @@ -0,0 +1,538 @@ +/* + * 代码块快捷插入 - main.js + * 纯 JavaScript 实现,无需编译,直接放入插件目录即可运行 + * + * 功能: + * 1. 快捷插入代码块 ```lang ... ```,光标自动定位到块内 + * 2. 选中文本时,用代码块包裹选中的内容 + * 3. 自定义默认语言 + 常用语言列表 + * 4. 每个语言注册独立命令 + * 5. 「选择语言」命令弹出列表供选择 + * 6. 快捷键配置:默认一项(默认语言),用户可自行添加更多 + * 7. 设置快捷键后即时检测冲突,冲突时显示警告 + 跳转入口 + * + * 替代 Templater 的 codepython.md 模板,无需 Templater 依赖。 + */ +const { Plugin, Notice, PluginSettingTab, Setting, Modal } = require("obsidian"); + +const PLUGIN_ID = "quick-codeblock"; + +const DEFAULT_SETTINGS = { + defaultLanguage: "python", + languages: "python\njavascript\nbash\nshell\nsql\njson\nyaml\ngo\nrust\njava\nhtml\ncss", + keepIndent: true, + // 快捷键配置列表:用户主动添加,默认只有一项(默认语言) + // 每项: { lang: "python", hotkey: { modifiers, key } | null } + hotkeyConfigs: [{ lang: "python", hotkey: null }], +}; + +// ================================================================ +// 工具函数(快捷键格式化 / 冲突检测 / 跳转) +// ================================================================ + +function formatHotkey(hotkey) { + if (!hotkey) return ""; + const modMap = { Mod: "Ctrl", Ctrl: "Ctrl", Alt: "Alt", Shift: "Shift", Meta: "Win" }; + const mods = (hotkey.modifiers || []).slice().sort().map((m) => modMap[m] || m); + return mods.length > 0 ? `${mods.join(" + ")} + ${hotkey.key}` : hotkey.key; +} + +function getEffectiveHotkeys(hotkeyManager, commandId) { + try { + if (typeof hotkeyManager.getEffectiveHotkeys === "function") { + return hotkeyManager.getEffectiveHotkeys(commandId) || []; + } + const custom = hotkeyManager.getHotkeys ? hotkeyManager.getHotkeys(commandId) || [] : []; + const baked = hotkeyManager.getBakedHotkeys ? hotkeyManager.getBakedHotkeys(commandId) || [] : []; + return custom.length > 0 ? custom : baked; + } catch (e) { + return []; + } +} + +function findConflict(app, ownCommandId, hotkey) { + if (!hotkey) return null; + const targetCombo = formatHotkey(hotkey); + const hotkeyManager = app.hotkeyManager; + const commands = app.commands.commands; + for (const id of Object.keys(commands)) { + if (id === ownCommandId) continue; + const effective = getEffectiveHotkeys(hotkeyManager, id); + for (const h of effective) { + if (formatHotkey(h) === targetCombo) { + return { commandId: id, commandName: commands[id].name || id }; + } + } + } + return null; +} + +function openHotkeysSettings(app) { + let opened = false; + try { + if (app.setting && typeof app.setting.openTabById === "function") { + app.setting.openTabById("hotkeys"); + opened = true; + } + } catch (e) {} + if (!opened) { + try { + if (typeof app.openSettings === "function") { + app.openSettings(); + opened = true; + } + } catch (e) {} + } + if (!opened) { + try { + app.commands.executeCommandById("app:open-settings"); + opened = true; + } catch (e) {} + } + if (opened) { + setTimeout(() => { + try { + if (app.setting && typeof app.setting.openTabById === "function") { + app.setting.openTabById("hotkeys"); + } + } catch (e) {} + }, 200); + } + return opened; +} + +// ================================================================ +// 插件主类 +// ================================================================ +class QuickCodeBlockPlugin extends Plugin { + async onload() { + await this.loadSettings(); + this.registerCommands(); + this.addSettingTab(new CodeBlockSettingTab(this.app, this)); + } + + onunload() { + // 卸载时清除所有快捷键绑定 + for (const config of this.settings.hotkeyConfigs) { + const fullId = `${PLUGIN_ID}:insert-codeblock-${config.lang}`; + try { + this.app.hotkeyManager.removeHotkeys(fullId); + } catch (e) {} + } + } + + registerCommands() { + const def = this.settings.defaultLanguage; + + // 1. 主命令 + this.addCommand({ + id: "insert-codeblock-default", + name: `插入代码块(${def})`, + editorCallback: (editor) => this.insertCodeBlock(editor, def), + }); + + // 2. 选择语言命令 + this.addCommand({ + id: "insert-codeblock-pick", + name: "插入代码块(选择语言)", + editorCallback: (editor) => { + const langs = this.getLanguages(); + new LanguagePickerModal(this.app, langs, (lang) => { + this.insertCodeBlock(editor, lang); + }).open(); + }, + }); + + // 3. 为语言列表的每个语言注册命令(命令始终注册,快捷键按需绑定) + for (const lang of this.getLanguages()) { + this.addCommand({ + id: `insert-codeblock-${lang}`, + name: `插入代码块(${lang})`, + editorCallback: (editor) => this.insertCodeBlock(editor, lang), + }); + } + + // 4. 为 hotkeyConfigs 里的配置绑定快捷键 + for (const config of this.settings.hotkeyConfigs) { + if (config.hotkey) { + const fullId = `${PLUGIN_ID}:insert-codeblock-${config.lang}`; + try { + this.app.hotkeyManager.setHotkeys(fullId, [config.hotkey]); + } catch (e) { + console.error(`[quick-codeblock] 绑定 ${config.lang} 快捷键失败:`, e); + } + } + } + } + + // ================================================================ + // 核心:插入代码块 + // ================================================================ + insertCodeBlock(editor, lang) { + const selection = editor.getSelection(); + + if (selection) { + const content = this.settings.keepIndent ? selection : selection.trim(); + const block = "```" + lang + "\n" + content + "\n```"; + editor.replaceSelection(block); + return; + } + + const cursor = editor.getCursor(); + const line = editor.getLine(cursor.line); + const beforeCursor = line.substring(0, cursor.ch); + const afterCursor = line.substring(cursor.ch); + + let block = "```" + lang + "\n\n```"; + if (beforeCursor.trim() !== "") block = "\n" + block; + if (afterCursor.trim() !== "") block = block + "\n"; + + editor.replaceSelection(block); + + const newCursor = editor.getCursor(); + const targetLine = + afterCursor.trim() !== "" ? newCursor.line - 2 : newCursor.line - 1; + editor.setCursor({ line: targetLine, ch: 0 }); + } + + getLanguages() { + return this.settings.languages + .split(/[\n,,]/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + } + + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + // 兼容旧格式 hotkeys 对象 → 迁移到 hotkeyConfigs 数组 + if (!this.settings.hotkeyConfigs) { + if (this.settings.hotkeys && typeof this.settings.hotkeys === "object") { + this.settings.hotkeyConfigs = Object.entries(this.settings.hotkeys) + .filter(([_, h]) => h) + .map(([lang, hotkey]) => ({ lang, hotkey })); + } else { + this.settings.hotkeyConfigs = [{ lang: this.settings.defaultLanguage, hotkey: null }]; + } + } + } + + async saveSettings() { + await this.saveData(this.settings); + } +} + +// ================================================================ +// 语言选择 Modal +// ================================================================ +class LanguagePickerModal extends Modal { + constructor(app, languages, onChoose) { + super(app); + this.languages = languages; + this.onChoose = onChoose; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("qcb-picker"); + contentEl.createEl("h2", { text: "选择代码块语言" }); + + const grid = contentEl.createEl("div", { cls: "qcb-lang-grid" }); + for (const lang of this.languages) { + const item = grid.createEl("div", { cls: "qcb-lang-item", text: lang }); + item.addEventListener("click", () => { + this.onChoose(lang); + this.close(); + }); + } + } + + onClose() { + this.contentEl.empty(); + } +} + +// ================================================================ +// 设置面板 +// ================================================================ +class CodeBlockSettingTab extends PluginSettingTab { + display() { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl("h2", { text: "代码块快捷插入" }); + + // ---- 基础设置 ---- + new Setting(containerEl) + .setName("默认语言") + .setDesc("主命令「插入代码块」使用的语言") + .addText((text) => + text + .setPlaceholder("python") + .setValue(this.plugin.settings.defaultLanguage) + .onChange(async (value) => { + this.plugin.settings.defaultLanguage = value.trim(); + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("语言列表") + .setDesc("每行一个语言,会为每个语言注册独立命令。修改后重新打开设置面板可刷新下方选项。") + .addTextArea((text) => { + text + .setPlaceholder("python\njavascript\nbash\n...") + .setValue(this.plugin.settings.languages) + .onChange(async (value) => { + this.plugin.settings.languages = value; + await this.plugin.saveSettings(); + }); + text.inputEl.rows = 8; + text.inputEl.cols = 30; + }); + + new Setting(containerEl) + .setName("保留选中内容缩进") + .setDesc("选中文本包裹成代码块时,是否保留原有缩进") + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.keepIndent) + .onChange(async (value) => { + this.plugin.settings.keepIndent = value; + await this.plugin.saveSettings(); + }) + ); + + // ---- 快捷键设置区域 ---- + containerEl.createEl("hr", { cls: "qcb-divider" }); + containerEl.createEl("h3", { text: "快捷键设置" }); + + const desc = containerEl.createEl("p", { cls: "qcb-desc" }); + desc.innerHTML = + "为需要的语言配置快捷键。点击输入框后按下组合键即可设置," + + "按 BackspaceEsc 清除。设置后自动检测冲突。"; + + // 配置列表 + const configs = this.plugin.settings.hotkeyConfigs; + for (let i = 0; i < configs.length; i++) { + this.createHotkeyConfigItem(i); + } + + // 添加按钮 + new Setting(containerEl).addButton((btn) => + btn + .setButtonText("+ 添加快捷键配置") + .setCta() + .onClick(async () => { + this.plugin.settings.hotkeyConfigs.push({ + lang: this.plugin.settings.defaultLanguage, + hotkey: null, + }); + await this.plugin.saveSettings(); + this.display(); + }) + ); + + // ---- 更多入口 ---- + containerEl.createEl("hr", { cls: "qcb-divider" }); + + new Setting(containerEl) + .setName("更多快捷键设置") + .setDesc("打开 Obsidian 系统的快捷键设置页面,可查看 / 修改所有命令的快捷键") + .addButton((btn) => + btn + .setButtonText("打开 Obsidian 快捷键设置") + .onClick(() => { + const ok = openHotkeysSettings(this.app); + if (ok) { + new Notice("已打开快捷键设置,可在搜索框输入命令名查找", 6000); + } else { + new Notice("无法自动打开,请手动进入:设置 → 快捷键", 6000); + } + }) + ); + + // ---- 使用说明 ---- + const tip = containerEl.createEl("div", { cls: "qcb-tip" }); + tip.innerHTML = + "使用方法
" + + "1. Ctrl+P 打开命令面板,搜索「插入代码块」
" + + "2. 无选中文本时:插入空代码块,光标自动定位到块内
" + + "3. 有选中文本时:用 ```lang 包裹选中的内容
" + + "4. 在上方「快捷键设置」区域,点击「+ 添加」为需要的语言配置快捷键
" + + "5. 修改语言列表后,关闭再打开设置面板即可刷新选项"; + } + + // 创建单个快捷键配置项:语言选择 + 快捷键输入 + 冲突检测 + 删除 + createHotkeyConfigItem(index) { + const plugin = this.plugin; + const config = plugin.settings.hotkeyConfigs[index]; + const fullId = `${PLUGIN_ID}:insert-codeblock-${config.lang}`; + + const setting = new Setting(this.containerEl); + + // 语言选择 dropdown + const select = setting.controlEl.createEl("select", { cls: "qcb-lang-select" }); + const langs = plugin.getLanguages(); + // 确保当前 lang 在选项里(即使不在列表中) + if (!langs.includes(config.lang)) langs.unshift(config.lang); + for (const lang of langs) { + const option = select.createEl("option", { value: lang, text: lang }); + if (lang === config.lang) option.selected = true; + } + + // 快捷键输入框 + const inputEl = setting.controlEl.createEl("input", { + type: "text", + cls: "qcb-hotkey-input", + attr: { readonly: true, placeholder: "点击设置…" }, + }); + inputEl.value = formatHotkey(config.hotkey); + + // 冲突提示 + const warningEl = setting.descEl.createEl("div", { cls: "qcb-warning" }); + + const updateWarning = (hotkey) => { + warningEl.empty(); + warningEl.removeClass("has-conflict"); + if (!hotkey) return; + const conflict = findConflict(this.app, fullId, hotkey); + if (conflict) { + warningEl.addClass("has-conflict"); + warningEl.createEl("span", { text: "⚠️ 冲突:", cls: "qcb-conflict-label" }); + warningEl.createEl("span", { text: conflict.commandName, cls: "qcb-conflict-name" }); + warningEl.createEl("span", { text: " " }); + const link = warningEl.createEl("a", { + text: "前往修改 ›", + cls: "qcb-conflict-link", + attr: { href: "#" }, + }); + link.addEventListener("click", (ev) => { + ev.preventDefault(); + openHotkeysSettings(this.app); + new Notice(`请在快捷键设置中搜索「${conflict.commandName}」查看并修改`, 8000); + }); + } else { + warningEl.createEl("span", { text: "✓ 无冲突", cls: "qcb-ok" }); + } + }; + + updateWarning(config.hotkey); + + // 语言变更:清除旧绑定,绑定到新语言 + select.addEventListener("change", async () => { + // 清除旧语言的快捷键 + if (config.hotkey) { + try { + this.app.hotkeyManager.removeHotkeys(`${PLUGIN_ID}:insert-codeblock-${config.lang}`); + } catch (e) {} + } + config.lang = select.value; + // 绑定到新语言 + if (config.hotkey) { + const newFullId = `${PLUGIN_ID}:insert-codeblock-${config.lang}`; + try { + this.app.hotkeyManager.setHotkeys(newFullId, [config.hotkey]); + } catch (e) {} + } + await plugin.saveSettings(); + // 更新 fullId 和冲突检测 + fullId; // 注意:fullId 是 const,这里用闭包里的 config.lang 重新检测 + const currentFullId = `${PLUGIN_ID}:insert-codeblock-${config.lang}`; + warningEl.empty(); + if (config.hotkey) { + const conflict = findConflict(this.app, currentFullId, config.hotkey); + if (conflict) { + warningEl.addClass("has-conflict"); + warningEl.createEl("span", { text: "⚠️ 冲突:", cls: "qcb-conflict-label" }); + warningEl.createEl("span", { text: conflict.commandName, cls: "qcb-conflict-name" }); + } else { + warningEl.createEl("span", { text: "✓ 无冲突", cls: "qcb-ok" }); + } + } + }); + + // 录制状态 + inputEl.addEventListener("focus", () => inputEl.addClass("recording")); + inputEl.addEventListener("blur", () => inputEl.removeClass("recording")); + + // 捕获按键 + inputEl.addEventListener("keydown", async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const key = ev.key; + + // 清除 + if (key === "Backspace" || key === "Escape" || key === "Delete") { + if (config.hotkey) { + try { + this.app.hotkeyManager.removeHotkeys(`${PLUGIN_ID}:insert-codeblock-${config.lang}`); + } catch (e) {} + } + config.hotkey = null; + inputEl.value = ""; + await plugin.saveSettings(); + updateWarning(null); + new Notice(`已清除:${config.lang} 快捷键`, 2000); + return; + } + + if (["Control", "Alt", "Shift", "Meta", "Tab"].includes(key)) return; + + const modifiers = []; + if (ev.ctrlKey) modifiers.push("Mod"); + if (ev.altKey) modifiers.push("Alt"); + if (ev.shiftKey) modifiers.push("Shift"); + if (ev.metaKey) modifiers.push("Meta"); + + let displayKey = key; + if (key.length === 1) displayKey = key.toUpperCase(); + + const hotkey = { modifiers, key: displayKey }; + config.hotkey = hotkey; + inputEl.value = formatHotkey(hotkey); + + // 绑定 + const bindId = `${PLUGIN_ID}:insert-codeblock-${config.lang}`; + try { + this.app.hotkeyManager.setHotkeys(bindId, [hotkey]); + } catch (e) { + console.error(`[quick-codeblock] 设置 ${config.lang} 快捷键失败:`, e); + new Notice(`设置失败:${e.message}`, 5000); + return; + } + + await plugin.saveSettings(); + updateWarning(hotkey); + + const conflict = findConflict(this.app, bindId, hotkey); + if (conflict) { + new Notice( + `⚠️ 「${formatHotkey(hotkey)}」与「${conflict.commandName}」冲突,点击警告中的「前往修改」处理`, + 8000 + ); + } else { + new Notice(`已设置:${config.lang} → ${formatHotkey(hotkey)}`, 2000); + } + }); + + // 删除按钮 + setting.addExtraButton((btn) => + btn + .setIcon("trash") + .setTooltip("删除此配置") + .onClick(async () => { + if (config.hotkey) { + try { + this.app.hotkeyManager.removeHotkeys(`${PLUGIN_ID}:insert-codeblock-${config.lang}`); + } catch (e) {} + } + plugin.settings.hotkeyConfigs.splice(index, 1); + await plugin.saveSettings(); + this.display(); + }) + ); + } +} + +module.exports = QuickCodeBlockPlugin; diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..b431bb1 --- /dev/null +++ b/main.ts @@ -0,0 +1,504 @@ +/* + * 代码块快捷插入 - main.ts (TypeScript 源码参考) + * + * 这是 main.js 的 TypeScript 版本,供需要类型检查或二次开发的用户参考。 + * 编译方法(需要 obsidian、@types/node、esbuild): + * npm install obsidian @types/node esbuild + * esbuild main.ts --bundle --external:obsidian --format=cjs --outfile=main.js + * + * 已提供编译好的 main.js,通常无需自行编译。 + */ +import { App, Editor, Modal, Notice, Plugin, PluginSettingTab, Setting } from "obsidian"; + +interface Hotkey { + modifiers: string[]; + key: string; +} + +interface HotkeyConfig { + lang: string; + hotkey: Hotkey | null; +} + +interface ConflictInfo { + commandId: string; + commandName: string; +} + +interface PluginSettings { + defaultLanguage: string; + languages: string; + keepIndent: boolean; + hotkeyConfigs: HotkeyConfig[]; +} + +const PLUGIN_ID = "quick-codeblock"; + +const DEFAULT_SETTINGS: PluginSettings = { + defaultLanguage: "python", + languages: "python\njavascript\nbash\nshell\nsql\njson\nyaml\ngo\nrust\njava\nhtml\ncss", + keepIndent: true, + hotkeyConfigs: [{ lang: "python", hotkey: null }], +}; + +function formatHotkey(hotkey: Hotkey | null): string { + if (!hotkey) return ""; + const modMap: Record = { Mod: "Ctrl", Ctrl: "Ctrl", Alt: "Alt", Shift: "Shift", Meta: "Win" }; + const mods = (hotkey.modifiers || []).slice().sort().map((m) => modMap[m] || m); + return mods.length > 0 ? `${mods.join(" + ")} + ${hotkey.key}` : hotkey.key; +} + +function getEffectiveHotkeys(hotkeyManager: any, commandId: string): any[] { + try { + if (typeof hotkeyManager.getEffectiveHotkeys === "function") { + return hotkeyManager.getEffectiveHotkeys(commandId) || []; + } + const custom = hotkeyManager.getHotkeys ? hotkeyManager.getHotkeys(commandId) || [] : []; + const baked = hotkeyManager.getBakedHotkeys ? hotkeyManager.getBakedHotkeys(commandId) || [] : []; + return custom.length > 0 ? custom : baked; + } catch { + return []; + } +} + +function findConflict(app: App, ownCommandId: string, hotkey: Hotkey | null): ConflictInfo | null { + if (!hotkey) return null; + const targetCombo = formatHotkey(hotkey); + const hotkeyManager = (app as any).hotkeyManager; + const commands = (app as any).commands.commands as Record; + for (const id of Object.keys(commands)) { + if (id === ownCommandId) continue; + const effective = getEffectiveHotkeys(hotkeyManager, id); + for (const h of effective) { + if (formatHotkey(h) === targetCombo) { + return { commandId: id, commandName: commands[id].name || id }; + } + } + } + return null; +} + +function openHotkeysSettings(app: App): boolean { + let opened = false; + try { + const setting = (app as any).setting; + if (setting && typeof setting.openTabById === "function") { + setting.openTabById("hotkeys"); + opened = true; + } + } catch {} + if (!opened) { + try { + (app as any).openSettings(); + opened = true; + } catch {} + } + if (!opened) { + try { + app.commands.executeCommandById("app:open-settings" as any); + opened = true; + } catch {} + } + if (opened) { + setTimeout(() => { + try { + const setting = (app as any).setting; + if (setting && typeof setting.openTabById === "function") { + setting.openTabById("hotkeys"); + } + } catch {} + }, 200); + } + return opened; +} + +export default class QuickCodeBlockPlugin extends Plugin { + settings!: PluginSettings; + + async onload(): Promise { + await this.loadSettings(); + this.registerCommands(); + this.addSettingTab(new CodeBlockSettingTab(this.app, this)); + } + + onunload(): void { + for (const config of this.settings.hotkeyConfigs) { + const fullId = `${PLUGIN_ID}:insert-codeblock-${config.lang}`; + try { + (this.app as any).hotkeyManager.removeHotkeys(fullId); + } catch {} + } + } + + private registerCommands(): void { + const def = this.settings.defaultLanguage; + this.addCommand({ + id: "insert-codeblock-default", + name: `插入代码块(${def})`, + editorCallback: (editor: Editor) => this.insertCodeBlock(editor, def), + }); + + this.addCommand({ + id: "insert-codeblock-pick", + name: "插入代码块(选择语言)", + editorCallback: (editor: Editor) => { + const langs = this.getLanguages(); + new LanguagePickerModal(this.app, langs, (lang: string) => { + this.insertCodeBlock(editor, lang); + }).open(); + }, + }); + + for (const lang of this.getLanguages()) { + this.addCommand({ + id: `insert-codeblock-${lang}`, + name: `插入代码块(${lang})`, + editorCallback: (editor: Editor) => this.insertCodeBlock(editor, lang), + }); + } + + for (const config of this.settings.hotkeyConfigs) { + if (config.hotkey) { + const fullId = `${PLUGIN_ID}:insert-codeblock-${config.lang}`; + try { + (this.app as any).hotkeyManager.setHotkeys(fullId, [config.hotkey]); + } catch (e) { + console.error(`[quick-codeblock] 绑定 ${config.lang} 快捷键失败:`, e); + } + } + } + } + + insertCodeBlock(editor: Editor, lang: string): void { + const selection = editor.getSelection(); + if (selection) { + const content = this.settings.keepIndent ? selection : selection.trim(); + const block = "```" + lang + "\n" + content + "\n```"; + editor.replaceSelection(block); + return; + } + + const cursor = editor.getCursor(); + const line = editor.getLine(cursor.line); + const beforeCursor = line.substring(0, cursor.ch); + const afterCursor = line.substring(cursor.ch); + + let block = "```" + lang + "\n\n```"; + if (beforeCursor.trim() !== "") block = "\n" + block; + if (afterCursor.trim() !== "") block = block + "\n"; + + editor.replaceSelection(block); + + const newCursor = editor.getCursor(); + const targetLine = afterCursor.trim() !== "" ? newCursor.line - 2 : newCursor.line - 1; + editor.setCursor({ line: targetLine, ch: 0 }); + } + + private getLanguages(): string[] { + return this.settings.languages + .split(/[\n,,]/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + } + + async loadSettings(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + if (!this.settings.hotkeyConfigs) { + if ((this.settings as any).hotkeys && typeof (this.settings as any).hotkeys === "object") { + this.settings.hotkeyConfigs = Object.entries((this.settings as any).hotkeys) + .filter(([_, h]: [string, any]) => h) + .map(([lang, hotkey]: [string, any]) => ({ lang, hotkey })); + } else { + this.settings.hotkeyConfigs = [{ lang: this.settings.defaultLanguage, hotkey: null }]; + } + } + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } +} + +class LanguagePickerModal extends Modal { + private languages: string[]; + private onChoose: (lang: string) => void; + + constructor(app: App, languages: string[], onChoose: (lang: string) => void) { + super(app); + this.languages = languages; + this.onChoose = onChoose; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("qcb-picker"); + contentEl.createEl("h2", { text: "选择代码块语言" }); + const grid = contentEl.createEl("div", { cls: "qcb-lang-grid" }); + for (const lang of this.languages) { + const item = grid.createEl("div", { cls: "qcb-lang-item", text: lang }); + item.addEventListener("click", () => { + this.onChoose(lang); + this.close(); + }); + } + } + + onClose(): void { + this.contentEl.empty(); + } +} + +class CodeBlockSettingTab extends PluginSettingTab { + plugin: QuickCodeBlockPlugin; + + constructor(app: App, plugin: QuickCodeBlockPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + containerEl.createEl("h2", { text: "代码块快捷插入" }); + + new Setting(containerEl) + .setName("默认语言") + .setDesc("主命令「插入代码块」使用的语言") + .addText((text) => + text + .setPlaceholder("python") + .setValue(this.plugin.settings.defaultLanguage) + .onChange(async (value) => { + this.plugin.settings.defaultLanguage = value.trim(); + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("语言列表") + .setDesc("每行一个语言,会为每个语言注册独立命令。修改后重新打开设置面板可刷新下方选项。") + .addTextArea((text) => { + text + .setPlaceholder("python\njavascript\nbash\n...") + .setValue(this.plugin.settings.languages) + .onChange(async (value) => { + this.plugin.settings.languages = value; + await this.plugin.saveSettings(); + }); + text.inputEl.rows = 8; + text.inputEl.cols = 30; + }); + + new Setting(containerEl) + .setName("保留选中内容缩进") + .setDesc("选中文本包裹成代码块时,是否保留原有缩进") + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.keepIndent) + .onChange(async (value) => { + this.plugin.settings.keepIndent = value; + await this.plugin.saveSettings(); + }) + ); + + containerEl.createEl("hr", { cls: "qcb-divider" }); + containerEl.createEl("h3", { text: "快捷键设置" }); + + const desc = containerEl.createEl("p", { cls: "qcb-desc" }); + desc.innerHTML = + "为需要的语言配置快捷键。点击输入框后按下组合键即可设置," + + "按 BackspaceEsc 清除。设置后自动检测冲突。"; + + const configs = this.plugin.settings.hotkeyConfigs; + for (let i = 0; i < configs.length; i++) { + this.createHotkeyConfigItem(i); + } + + new Setting(containerEl).addButton((btn) => + btn + .setButtonText("+ 添加快捷键配置") + .setCta() + .onClick(async () => { + this.plugin.settings.hotkeyConfigs.push({ + lang: this.plugin.settings.defaultLanguage, + hotkey: null, + }); + await this.plugin.saveSettings(); + this.display(); + }) + ); + + containerEl.createEl("hr", { cls: "qcb-divider" }); + + new Setting(containerEl) + .setName("更多快捷键设置") + .setDesc("打开 Obsidian 系统的快捷键设置页面,可查看 / 修改所有命令的快捷键") + .addButton((btn) => + btn + .setButtonText("打开 Obsidian 快捷键设置") + .onClick(() => { + const ok = openHotkeysSettings(this.app); + if (ok) { + new Notice("已打开快捷键设置,可在搜索框输入命令名查找", 6000); + } else { + new Notice("无法自动打开,请手动进入:设置 → 快捷键", 6000); + } + }) + ); + + const tip = containerEl.createEl("div", { cls: "qcb-tip" }); + tip.innerHTML = + "使用方法
" + + "1. Ctrl+P 打开命令面板,搜索「插入代码块」
" + + "2. 无选中文本时:插入空代码块,光标自动定位到块内
" + + "3. 有选中文本时:用 ```lang 包裹选中的内容
" + + "4. 在上方「快捷键设置」区域,点击「+ 添加」为需要的语言配置快捷键
" + + "5. 修改语言列表后,关闭再打开设置面板即可刷新选项"; + } + + private createHotkeyConfigItem(index: number): void { + const plugin = this.plugin; + const config = plugin.settings.hotkeyConfigs[index]; + + const setting = new Setting(this.containerEl); + + const select = setting.controlEl.createEl("select", { cls: "qcb-lang-select" }); + const langs = plugin.getLanguages(); + if (!langs.includes(config.lang)) langs.unshift(config.lang); + for (const lang of langs) { + const option = select.createEl("option", { value: lang, text: lang }); + if (lang === config.lang) option.selected = true; + } + + const inputEl = setting.controlEl.createEl("input", { + type: "text", + cls: "qcb-hotkey-input", + attr: { readonly: true, placeholder: "点击设置…" }, + }); + inputEl.value = formatHotkey(config.hotkey); + + const warningEl = setting.descEl.createEl("div", { cls: "qcb-warning" }); + + const updateWarning = (hotkey: Hotkey | null, lang: string) => { + warningEl.empty(); + warningEl.removeClass("has-conflict"); + if (!hotkey) return; + const currentFullId = `${PLUGIN_ID}:insert-codeblock-${lang}`; + const conflict = findConflict(this.app, currentFullId, hotkey); + if (conflict) { + warningEl.addClass("has-conflict"); + warningEl.createEl("span", { text: "⚠️ 冲突:", cls: "qcb-conflict-label" }); + warningEl.createEl("span", { text: conflict.commandName, cls: "qcb-conflict-name" }); + warningEl.createEl("span", { text: " " }); + const link = warningEl.createEl("a", { + text: "前往修改 ›", + cls: "qcb-conflict-link", + attr: { href: "#" }, + }); + link.addEventListener("click", (ev: MouseEvent) => { + ev.preventDefault(); + openHotkeysSettings(this.app); + new Notice(`请在快捷键设置中搜索「${conflict.commandName}」查看并修改`, 8000); + }); + } else { + warningEl.createEl("span", { text: "✓ 无冲突", cls: "qcb-ok" }); + } + }; + + updateWarning(config.hotkey, config.lang); + + select.addEventListener("change", async () => { + if (config.hotkey) { + try { + (this.app as any).hotkeyManager.removeHotkeys(`${PLUGIN_ID}:insert-codeblock-${config.lang}`); + } catch {} + } + config.lang = select.value; + if (config.hotkey) { + const newFullId = `${PLUGIN_ID}:insert-codeblock-${config.lang}`; + try { + (this.app as any).hotkeyManager.setHotkeys(newFullId, [config.hotkey]); + } catch {} + } + await plugin.saveSettings(); + updateWarning(config.hotkey, config.lang); + }); + + inputEl.addEventListener("focus", () => inputEl.addClass("recording")); + inputEl.addEventListener("blur", () => inputEl.removeClass("recording")); + + inputEl.addEventListener("keydown", async (ev: KeyboardEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + const key = ev.key; + + if (key === "Backspace" || key === "Escape" || key === "Delete") { + if (config.hotkey) { + try { + (this.app as any).hotkeyManager.removeHotkeys(`${PLUGIN_ID}:insert-codeblock-${config.lang}`); + } catch {} + } + config.hotkey = null; + inputEl.value = ""; + await plugin.saveSettings(); + updateWarning(null, config.lang); + new Notice(`已清除:${config.lang} 快捷键`, 2000); + return; + } + + if (["Control", "Alt", "Shift", "Meta", "Tab"].includes(key)) return; + + const modifiers: string[] = []; + if (ev.ctrlKey) modifiers.push("Mod"); + if (ev.altKey) modifiers.push("Alt"); + if (ev.shiftKey) modifiers.push("Shift"); + if (ev.metaKey) modifiers.push("Meta"); + + let displayKey = key; + if (key.length === 1) displayKey = key.toUpperCase(); + + const hotkey: Hotkey = { modifiers, key: displayKey }; + config.hotkey = hotkey; + inputEl.value = formatHotkey(hotkey); + + const bindId = `${PLUGIN_ID}:insert-codeblock-${config.lang}`; + try { + (this.app as any).hotkeyManager.setHotkeys(bindId, [hotkey]); + } catch (e: any) { + console.error(`[quick-codeblock] 设置 ${config.lang} 快捷键失败:`, e); + new Notice(`设置失败:${e.message}`, 5000); + return; + } + + await plugin.saveSettings(); + updateWarning(hotkey, config.lang); + + const conflict = findConflict(this.app, bindId, hotkey); + if (conflict) { + new Notice( + `⚠️ 「${formatHotkey(hotkey)}」与「${conflict.commandName}」冲突,点击警告中的「前往修改」处理`, + 8000 + ); + } else { + new Notice(`已设置:${config.lang} → ${formatHotkey(hotkey)}`, 2000); + } + }); + + setting.addExtraButton((btn) => + btn + .setIcon("trash") + .setTooltip("删除此配置") + .onClick(async () => { + if (config.hotkey) { + try { + (this.app as any).hotkeyManager.removeHotkeys(`${PLUGIN_ID}:insert-codeblock-${config.lang}`); + } catch {} + } + plugin.settings.hotkeyConfigs.splice(index, 1); + await plugin.saveSettings(); + this.display(); + }) + ); + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..7326d37 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "quick-codeblock", + "name": "代码块快捷插入", + "version": "1.0.0", + "minAppVersion": "1.4.0", + "description": "快捷插入代码块并自动定位光标。支持自定义语言、选中文本包裹、快捷键配置与冲突检测。", + "author": "旭说云原生", + "isDesktopOnly": false +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..62b45c8 --- /dev/null +++ b/styles.css @@ -0,0 +1,164 @@ +/* 代码块快捷插入 - 样式 */ + +/* 语言选择 Modal */ +.qcb-picker .qcb-lang-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 8px; + margin-top: 12px; +} + +.qcb-picker .qcb-lang-item { + padding: 10px 12px; + text-align: center; + font-family: var(--font-monospace, monospace); + font-size: 0.92em; + background: var(--background-modifier-form-field, #f5f5f5); + border: 1px solid var(--background-modifier-border, #ddd); + border-radius: 6px; + cursor: pointer; + transition: all 0.12s; + color: var(--text-normal, currentColor); +} + +.qcb-picker .qcb-lang-item:hover { + border-color: var(--interactive-accent, #7c3aed); + background: var(--interactive-accent-hover, rgba(124, 58, 237, 0.1)); + transform: translateY(-1px); +} + +.qcb-picker .qcb-lang-item:active { + transform: translateY(0); +} + +/* 分隔线与描述 */ +.qcb-divider { + margin: 24px 0 16px 0; + border: none; + border-top: 1px solid var(--background-modifier-border, #ddd); +} + +.qcb-desc { + color: var(--text-muted, #999); + font-size: 0.9em; + margin-bottom: 16px; +} + +.qcb-desc code { + background: var(--background-modifier-border, #e0e0e0); + border-radius: 3px; + padding: 1px 5px; + font-size: 0.85em; +} + +/* 语言选择 dropdown */ +.qcb-lang-select { + padding: 6px 8px; + font-family: var(--font-monospace, monospace); + font-size: 0.88em; + background: var(--background-modifier-form-field, #fff); + border: 1px solid var(--background-modifier-border, #ddd); + border-radius: 4px; + color: var(--text-normal, currentColor); + cursor: pointer; + margin-right: 8px; +} + +.qcb-lang-select:hover { + border-color: var(--interactive-accent, #7c3aed); +} + +.qcb-lang-select:focus { + outline: none; + border-color: var(--interactive-accent, #7c3aed); +} + +/* 快捷键输入框 */ +.qcb-hotkey-input { + width: 130px; + padding: 6px 10px; + text-align: center; + font-family: var(--font-monospace, monospace); + font-size: 0.88em; + background: var(--background-modifier-form-field, #fff); + border: 1px solid var(--background-modifier-border, #ddd); + border-radius: 4px; + color: var(--text-normal, currentColor); + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.qcb-hotkey-input:hover { + border-color: var(--interactive-accent, #7c3aed); +} + +.qcb-hotkey-input.recording { + border-color: var(--interactive-accent, #7c3aed); + box-shadow: 0 0 0 2px var(--interactive-accent-hover, rgba(124, 58, 237, 0.25)); +} + +.qcb-hotkey-input::placeholder { + color: var(--text-faint, #bbb); +} + +/* 冲突提示 */ +.qcb-warning { + margin-top: 6px; + font-size: 0.85em; + min-height: 18px; +} + +.qcb-warning.has-conflict { + padding: 4px 8px; + border-radius: 4px; + background: rgba(224, 82, 82, 0.1); + border-left: 3px solid var(--text-error, #e05252); +} + +.qcb-conflict-label { + color: var(--text-error, #e05252); + font-weight: 600; +} + +.qcb-conflict-name { + color: var(--text-normal, currentColor); + font-weight: 500; +} + +.qcb-conflict-link { + color: var(--interactive-accent, #7c3aed); + cursor: pointer; + text-decoration: none; + margin-left: 6px; +} + +.qcb-conflict-link:hover { + text-decoration: underline; +} + +.qcb-ok { + color: var(--text-success, #4caf50); +} + +/* 使用说明 */ +.qcb-tip { + margin-top: 16px; + padding: 12px 16px; + background: var(--background-secondary, #f9f9f9); + border-radius: 6px; + border-left: 3px solid var(--interactive-accent, #7c3aed); + color: var(--text-muted, #666); + font-size: 0.88em; + line-height: 1.7; +} + +.qcb-tip code { + background: var(--background-modifier-border, #e0e0e0); + border-radius: 3px; + padding: 1px 5px; + font-size: 0.85em; +} + +.qcb-tip b { + color: var(--text-normal, currentColor); +}