mirror of
https://github.com/yv1ing/obsidian-tos-picbed.git
synced 2025-09-30 11:55:34 +08:00
Complete the basic functions of the plug-in
This commit is contained in:
267
main.ts
Normal file
267
main.ts
Normal 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 = ``;
|
||||
|
||||
// 如果此时光标恰好在占位符末尾,让它在替换后仍然保持在图片 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user