v1.0.0 初始发布:代码块快捷插入插件

This commit is contained in:
旭说云原生 2026-07-14 16:20:29 +08:00
commit 73ec526625
7 changed files with 1351 additions and 0 deletions

24
.gitignore vendored Normal file
View file

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

21
LICENSE Normal file
View file

@ -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.

91
README.md Normal file
View file

@ -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)
## 作者
**旭说云原生**

538
main.js Normal file
View file

@ -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 =
"为需要的语言配置快捷键。点击输入框后按下组合键即可设置," +
"按 <code>Backspace</code> 或 <code>Esc</code> 清除。设置后自动检测冲突。";
// 配置列表
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 =
"<b>使用方法</b><br>" +
"1. <code>Ctrl+P</code> 打开命令面板,搜索「插入代码块」<br>" +
"2. 无选中文本时:插入空代码块,光标自动定位到块内<br>" +
"3. 有选中文本时:用 <code>```lang</code> 包裹选中的内容<br>" +
"4. 在上方「快捷键设置」区域,点击「+ 添加」为需要的语言配置快捷键<br>" +
"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;

504
main.ts Normal file
View file

@ -0,0 +1,504 @@
/*
* - main.ts (TypeScript )
*
* main.js TypeScript
* obsidian@types/nodeesbuild
* 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<string, string> = { 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<string, { name: string }>;
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<void> {
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<void> {
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<void> {
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 =
"为需要的语言配置快捷键。点击输入框后按下组合键即可设置," +
"按 <code>Backspace</code> 或 <code>Esc</code> 清除。设置后自动检测冲突。";
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 =
"<b>使用方法</b><br>" +
"1. <code>Ctrl+P</code> 打开命令面板,搜索「插入代码块」<br>" +
"2. 无选中文本时:插入空代码块,光标自动定位到块内<br>" +
"3. 有选中文本时:用 <code>```lang</code> 包裹选中的内容<br>" +
"4. 在上方「快捷键设置」区域,点击「+ 添加」为需要的语言配置快捷键<br>" +
"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();
})
);
}
}

9
manifest.json Normal file
View file

@ -0,0 +1,9 @@
{
"id": "quick-codeblock",
"name": "代码块快捷插入",
"version": "1.0.0",
"minAppVersion": "1.4.0",
"description": "快捷插入代码块并自动定位光标。支持自定义语言、选中文本包裹、快捷键配置与冲突检测。",
"author": "旭说云原生",
"isDesktopOnly": false
}

164
styles.css Normal file
View file

@ -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);
}