Complete the basic functions of the plug-in

This commit is contained in:
2025-09-01 17:07:20 +08:00
parent 7daee1aa86
commit fe0e85b2c8
7 changed files with 3280 additions and 0 deletions

267
main.ts Normal file
View File

@@ -0,0 +1,267 @@
import TosClient from "@volcengine/tos-sdk";
import { App, Editor, MarkdownView, Notice, Plugin, PluginSettingTab, Setting, TFile } from "obsidian";
interface TosPicbedPluginSettings {
secretId: string;
secretKey: string;
bucket: string;
region: string;
prefix: string;
usePublicUrl: boolean; // 桶公有读 => 稳定直链;私有读 => 预签名
}
const DEFAULT_SETTINGS: TosPicbedPluginSettings = {
secretId: "",
secretKey: "",
bucket: "",
region: "",
prefix: "",
usePublicUrl: true,
};
class TosUploader {
private client: TosClient;
constructor(private settings: TosPicbedPluginSettings) {
const s = settings;
if (!s.secretId || !s.secretKey) throw new Error("SecretId/SecretKey 为空");
if (!s.bucket || !s.region) throw new Error("Bucket/Region 为空");
this.client = new TosClient({
accessKeyId: s.secretId,
accessKeySecret: s.secretKey,
region: s.region,
});
}
private normPrefix() {
const p = this.settings.prefix?.replace(/^\/+|\/+$/g, "");
return p ? `${p}/` : "";
}
async uploadFile(file: File): Promise<{ url: string; key: string }> {
const ext = file.name.includes(".") ? file.name.split(".").pop() : "";
const name = ext ? `${Date.now()}.${ext}` : `${Date.now()}`;
const key = `${this.normPrefix()}${name}`;
await this.client.putObject({
bucket: this.settings.bucket,
key,
body: file,
contentType: file.type || undefined,
});
const url = await this.getUrl(key);
return { url, key };
}
async deleteByKey(key: string): Promise<void> {
await this.client.deleteObject({ bucket: this.settings.bucket, key });
}
private async getUrl(key: string): Promise<string> {
if (this.settings.usePublicUrl) {
const safeKey = key
.split("/")
.map(encodeURIComponent)
.join("/");
return `https://${this.settings.bucket}.tos-${this.settings.region}.volces.com/${safeKey}`;
}
return this.client.getPreSignedUrl({ bucket: this.settings.bucket, key });
}
static parseKeyFromUrlOrKey(input: string): string {
try {
const u = new URL(input);
return decodeURIComponent(u.pathname.replace(/^\/+/, ""));
} catch {
return input.replace(/^\/+/, "");
}
}
}
class TosPicbedSettingTab extends PluginSettingTab {
constructor(app: App, private plugin: TosPicbedPlugin) { super(app, plugin); }
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl).setName("访问令牌Secret Id").addText(t =>
t.setPlaceholder("AK...").setValue(this.plugin.settings.secretId).onChange(async v => {
this.plugin.settings.secretId = v.trim(); await this.plugin.saveSettings(); this.plugin.reinitUploaderIfReady();
})
);
new Setting(containerEl).setName("访问密钥Secret Key").addText(t =>
t.setPlaceholder("SK...").setValue(this.plugin.settings.secretKey).onChange(async v => {
this.plugin.settings.secretKey = v.trim(); await this.plugin.saveSettings(); this.plugin.reinitUploaderIfReady();
})
);
new Setting(containerEl).setName("存储桶Bucket").addText(t =>
t.setPlaceholder("example-bucket").setValue(this.plugin.settings.bucket).onChange(async v => {
this.plugin.settings.bucket = v.trim(); await this.plugin.saveSettings(); this.plugin.reinitUploaderIfReady();
})
);
new Setting(containerEl).setName("地域Region").addText(t =>
t.setPlaceholder("cn-beijing").setValue(this.plugin.settings.region).onChange(async v => {
this.plugin.settings.region = v.trim(); await this.plugin.saveSettings(); this.plugin.reinitUploaderIfReady();
})
);
new Setting(containerEl).setName("前缀Prefix").addText(t =>
t.setPlaceholder("/").setValue(this.plugin.settings.prefix).onChange(async v => {
this.plugin.settings.prefix = v.trim().replace(/^\/+|\/+$/g, ""); await this.plugin.saveSettings();
})
);
new Setting(containerEl).setName("是否使用公共链接(存储桶公有读)").addToggle(t =>
t.setValue(this.plugin.settings.usePublicUrl).onChange(async v => {
this.plugin.settings.usePublicUrl = v; await this.plugin.saveSettings();
})
);
}
}
export default class TosPicbedPlugin extends Plugin {
settings: TosPicbedPluginSettings;
private uploader: TosUploader | null = null;
async onload() {
await this.loadSettings();
this.reinitUploaderIfReady();
// 粘贴:占位令牌 -> 上传 -> 替换为图片 Markdown
this.registerEvent(
this.app.workspace.on("editor-paste", async (evt: ClipboardEvent, editor: Editor, mdView: MarkdownView) => {
if (!this.uploader) return;
const items = Array.from(evt.clipboardData?.items || []);
const imgs = items.filter(i => i.kind === "file" && i.type.startsWith("image/"));
if (imgs.length === 0) return;
evt.preventDefault(); // 避免 Obsidian 生成本地 ![[...]]
const activeFile = mdView.file;
if (!activeFile) return;
for (const it of imgs) {
const f = it.getAsFile();
if (!f) continue;
// —— 使用不会被渲染为图片的占位令牌 ——
const token = `{{TOS_UPLOADING:${Date.now()}-${Math.random().toString(36).slice(2, 8)}}}`;
const pos = editor.getCursor();
editor.replaceRange(token, pos);
try {
const { url } = await this.uploader.uploadFile(f);
let doc = editor.getValue();
const idx = doc.indexOf(token);
if (idx !== -1) {
const from = editor.offsetToPos(idx);
const to = editor.offsetToPos(idx + token.length);
const finalMd = `![](${url})`;
// 如果此时光标恰好在占位符末尾,让它在替换后仍然保持在图片 Markdown 末尾
const cur = editor.getCursor();
const wasAfterToken = (cur.line === to.line && cur.ch === to.ch);
editor.replaceRange(finalMd, from, to);
if (wasAfterToken) {
const afterPos = editor.offsetToPos(idx + finalMd.length);
editor.setCursor(afterPos);
}
}
// 清理“Pasted image ...”残留:逐个正则命中做区间删除,避免 setValue
let searchDoc = editor.getValue();
const imgRe = /!\[\[Pasted image.*?\.(?:png|jpe?g|gif|webp|svg)\]\]/gi;
let m: RegExpExecArray | null;
while ((m = imgRe.exec(searchDoc)) !== null) {
const start = editor.offsetToPos(m.index);
const end = editor.offsetToPos(m.index + m[0].length);
editor.replaceRange("", start, end);
// 文本已变更,重新获取并重置游标位置
searchDoc = editor.getValue();
imgRe.lastIndex = 0;
}
new Notice("图片上传成功");
} catch (e: any) {
let doc = editor.getValue();
if (doc.includes(token)) {
const after = doc.replace(token, "");
editor.setValue(after);
editor.setCursor(editor.offsetToPos(after.length));
}
new Notice("图片上传失败: " + (e?.message || e));
}
}
})
);
// 右键菜单:删除当前行图片
this.registerEvent(
this.app.workspace.on("editor-menu", (menu, editor) => {
const cur = editor.getCursor();
const line = editor.getLine(cur.line);
const m = line.match(/!\[.*?\]\((.*?)\)/);
if (!m || !this.uploader) return;
menu.addItem(item => {
item.setTitle("删除此图片").setIcon("trash").onClick(async () => {
try {
const key = TosUploader.parseKeyFromUrlOrKey(m[1]);
await this.uploader!.deleteByKey(key);
editor.setLine(cur.line, line.replace(m[0], ""));
new Notice("图片删除成功");
} catch (e: any) {
new Notice("图片删除失败: " + (e?.message || e));
}
});
});
})
);
// 右键菜单:删除全部图片
this.registerEvent(
this.app.workspace.on("editor-menu", (menu, editor) => {
menu.addItem(item => {
item.setTitle("删除全部图片").setIcon("trash").onClick(async () => {
const all = editor.getValue();
const matches = [...all.matchAll(/!\[.*?\]\((.*?)\)/g)];
if (matches.length === 0) return new Notice("未发现图片");
const keys = matches.map(m => TosUploader.parseKeyFromUrlOrKey(m[1]));
if (this.uploader) {
await Promise.all(keys.map(k => this.uploader!.deleteByKey(k).catch(e =>
new Notice("删除失败: " + (e?.message || e))
)));
}
editor.setValue(all.replace(/!\[.*?\]\((.*?)\)/g, ""));
new Notice(`已删除 ${keys.length} 张图片`);
});
});
})
);
this.addSettingTab(new TosPicbedSettingTab(this.app, this));
}
onunload() { this.uploader = null; }
async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); }
async saveSettings() { await this.saveData(this.settings); }
reinitUploaderIfReady() {
const s = this.settings;
if (s.secretId && s.secretKey && s.bucket && s.region) {
try { this.uploader = new TosUploader(s); } catch { this.uploader = null; }
}
}
private findImageFile(imagePath: string, currentFile: TFile): TFile | null {
let imageFile = this.app.vault.getAbstractFileByPath(imagePath);
if (imageFile instanceof TFile && this.isImageFile(imageFile)) return imageFile;
if (currentFile.parent) {
const relativePath = `${currentFile.parent.path}/${imagePath}`;
imageFile = this.app.vault.getAbstractFileByPath(relativePath);
if (imageFile instanceof TFile && this.isImageFile(imageFile)) return imageFile;
}
imageFile = this.app.vault.getAbstractFileByPath(`/${imagePath}`);
if (imageFile instanceof TFile && this.isImageFile(imageFile)) return imageFile;
const files = this.app.vault.getFiles();
return files.find(f => f.name === imagePath && this.isImageFile(f)) || null;
}
private isImageFile(file: TFile): boolean {
return /png|jpg|jpeg|gif|svg|webp/i.test(file.extension.toLowerCase());
}
}