mirror of
https://github.com/raven-pensieve/obsidian-ace-code-editor.git
synced 2026-07-22 05:48:56 +00:00
feat: 实现远程代码嵌入加载
This commit is contained in:
parent
396e354681
commit
e0f61a4cbe
13 changed files with 674 additions and 27 deletions
|
|
@ -62,13 +62,13 @@ importers:
|
|||
version: 10.4.0
|
||||
eslint-plugin-obsidianmd:
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0(@eslint/js@10.0.1(eslint@10.4.0))(@eslint/json@0.14.0)(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@5.9.3))(eslint@10.4.0)(obsidian@1.12.3(@codemirror/state@6.5.0)(@codemirror/view@6.38.6))(typescript-eslint@8.59.4(eslint@10.4.0)(typescript@5.9.3))
|
||||
version: 0.3.0(@eslint/js@10.0.1(eslint@10.4.0))(@eslint/json@0.14.0)(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@5.9.3))(eslint@10.4.0)(obsidian@1.12.3(@codemirror/state@6.6.0)(@codemirror/view@6.43.1))(typescript-eslint@8.59.4(eslint@10.4.0)(typescript@5.9.3))
|
||||
globals:
|
||||
specifier: ^17.6.0
|
||||
version: 17.6.0
|
||||
obsidian:
|
||||
specifier: ^1.12.3
|
||||
version: 1.12.3(@codemirror/state@6.5.0)(@codemirror/view@6.38.6)
|
||||
version: 1.12.3(@codemirror/state@6.6.0)(@codemirror/view@6.43.1)
|
||||
plist:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
|
|
@ -101,11 +101,11 @@ packages:
|
|||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@codemirror/state@6.5.0':
|
||||
resolution: {integrity: sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==}
|
||||
'@codemirror/state@6.6.0':
|
||||
resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==}
|
||||
|
||||
'@codemirror/view@6.38.6':
|
||||
resolution: {integrity: sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==}
|
||||
'@codemirror/view@6.43.1':
|
||||
resolution: {integrity: sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==}
|
||||
|
||||
'@conventional-changelog/git-client@2.7.0':
|
||||
resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==}
|
||||
|
|
@ -2603,13 +2603,13 @@ snapshots:
|
|||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@codemirror/state@6.5.0':
|
||||
'@codemirror/state@6.6.0':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.2
|
||||
|
||||
'@codemirror/view@6.38.6':
|
||||
'@codemirror/view@6.43.1':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.0
|
||||
'@codemirror/state': 6.6.0
|
||||
crelt: 1.0.6
|
||||
style-mod: 4.1.3
|
||||
w3c-keyname: 2.2.8
|
||||
|
|
@ -4299,7 +4299,7 @@ snapshots:
|
|||
dependencies:
|
||||
eslint: 10.4.0
|
||||
|
||||
eslint-plugin-obsidianmd@0.3.0(@eslint/js@10.0.1(eslint@10.4.0))(@eslint/json@0.14.0)(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@5.9.3))(eslint@10.4.0)(obsidian@1.12.3(@codemirror/state@6.5.0)(@codemirror/view@6.38.6))(typescript-eslint@8.59.4(eslint@10.4.0)(typescript@5.9.3)):
|
||||
eslint-plugin-obsidianmd@0.3.0(@eslint/js@10.0.1(eslint@10.4.0))(@eslint/json@0.14.0)(@typescript-eslint/parser@8.59.4(eslint@10.4.0)(typescript@5.9.3))(eslint@10.4.0)(obsidian@1.12.3(@codemirror/state@6.6.0)(@codemirror/view@6.43.1))(typescript-eslint@8.59.4(eslint@10.4.0)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@eslint/config-helpers': 0.4.2
|
||||
'@eslint/js': 10.0.1(eslint@10.4.0)
|
||||
|
|
@ -4316,7 +4316,7 @@ snapshots:
|
|||
eslint-plugin-no-unsanitized: 4.1.5(eslint@10.4.0)
|
||||
eslint-plugin-security: 2.1.1
|
||||
globals: 14.0.0
|
||||
obsidian: 1.12.3(@codemirror/state@6.5.0)(@codemirror/view@6.38.6)
|
||||
obsidian: 1.12.3(@codemirror/state@6.6.0)(@codemirror/view@6.43.1)
|
||||
semver: 7.8.0
|
||||
typescript: 5.4.5
|
||||
typescript-eslint: 8.59.4(eslint@10.4.0)(typescript@5.9.3)
|
||||
|
|
@ -4880,10 +4880,10 @@ snapshots:
|
|||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
obsidian@1.12.3(@codemirror/state@6.5.0)(@codemirror/view@6.38.6):
|
||||
obsidian@1.12.3(@codemirror/state@6.6.0)(@codemirror/view@6.43.1):
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.0
|
||||
'@codemirror/view': 6.38.6
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.1
|
||||
'@types/codemirror': 5.60.8
|
||||
moment: 2.29.4
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ const en = {
|
|||
session: "Session",
|
||||
editor: "Editor",
|
||||
extend: "Extend",
|
||||
remote: "Remote",
|
||||
about: "About",
|
||||
},
|
||||
desc: "View <a href='https://docs.ravenhogwarts.top/en/obsidian-ace-code-editor/' target='_blank'>wiki documentation</a> to learn more features",
|
||||
|
|
@ -160,6 +161,12 @@ const en = {
|
|||
hidden: "Hidden",
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
enabled: {
|
||||
name: "Enable remote code embedding",
|
||||
desc: "Embed code from remote URLs (e.g. GitHub blob pages) in your notes",
|
||||
},
|
||||
},
|
||||
about: {
|
||||
runtime_files: "Ace Runtime Files",
|
||||
checking: "Checking local runtime environment...",
|
||||
|
|
|
|||
|
|
@ -283,6 +283,10 @@ type RootTranslation = {
|
|||
* 扩展
|
||||
*/
|
||||
extend: string
|
||||
/**
|
||||
* 远程
|
||||
*/
|
||||
remote: string
|
||||
/**
|
||||
* 关于
|
||||
*/
|
||||
|
|
@ -476,6 +480,18 @@ type RootTranslation = {
|
|||
hidden: string
|
||||
}
|
||||
}
|
||||
remote: {
|
||||
enabled: {
|
||||
/**
|
||||
* 启用远程代码嵌入
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* 从远程 URL(如 GitHub blob 页面)嵌入代码到笔记中
|
||||
*/
|
||||
desc: string
|
||||
}
|
||||
}
|
||||
about: {
|
||||
/**
|
||||
* Ace 运行时文件
|
||||
|
|
@ -806,6 +822,10 @@ export type TranslationFunctions = {
|
|||
* 扩展
|
||||
*/
|
||||
extend: () => LocalizedString
|
||||
/**
|
||||
* 远程
|
||||
*/
|
||||
remote: () => LocalizedString
|
||||
/**
|
||||
* 关于
|
||||
*/
|
||||
|
|
@ -999,6 +1019,18 @@ export type TranslationFunctions = {
|
|||
hidden: () => LocalizedString
|
||||
}
|
||||
}
|
||||
remote: {
|
||||
enabled: {
|
||||
/**
|
||||
* 启用远程代码嵌入
|
||||
*/
|
||||
name: () => LocalizedString
|
||||
/**
|
||||
* 从远程 URL(如 GitHub blob 页面)嵌入代码到笔记中
|
||||
*/
|
||||
desc: () => LocalizedString
|
||||
}
|
||||
}
|
||||
about: {
|
||||
/**
|
||||
* Ace 运行时文件
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ const zh_TW = {
|
|||
session: "會話",
|
||||
editor: "編輯",
|
||||
extend: "擴展",
|
||||
remote: "遠端",
|
||||
about: "關於",
|
||||
},
|
||||
desc: "查看<a href='https://docs.ravenhogwarts.top/obsidian-ace-code-editor/' target='_blank'>wiki文件</a>了解更多功能",
|
||||
|
|
@ -157,6 +158,12 @@ const zh_TW = {
|
|||
hidden: "隱藏",
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
enabled: {
|
||||
name: "啟用遠端程式碼嵌入",
|
||||
desc: "從遠端 URL(如 GitHub blob 頁面)嵌入程式碼到筆記中",
|
||||
},
|
||||
},
|
||||
about: {
|
||||
runtime_files: "Ace 執行時檔案",
|
||||
checking: "正在檢查本機執行環境...",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ const zh = {
|
|||
session: "会话",
|
||||
editor: "编辑",
|
||||
extend: "扩展",
|
||||
remote: "远程",
|
||||
about: "关于",
|
||||
},
|
||||
desc: "查看<a href='https://docs.ravenhogwarts.top/obsidian-ace-code-editor/' target='_blank'>wiki文档</a>了解更多功能",
|
||||
|
|
@ -156,6 +157,12 @@ const zh = {
|
|||
hidden: "隐藏",
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
enabled: {
|
||||
name: "启用远程代码嵌入",
|
||||
desc: "从远程 URL(如 GitHub blob 页面)嵌入代码到笔记中",
|
||||
},
|
||||
},
|
||||
about: {
|
||||
runtime_files: "Ace 运行时文件",
|
||||
checking: "正在检查本地运行环境...",
|
||||
|
|
|
|||
86
src/main.ts
86
src/main.ts
|
|
@ -14,6 +14,7 @@ import { EditCodeBlock } from "./component/modal/EditCodeBlockModal";
|
|||
import { QuickConfigModal } from "./component/suggest/QuickConfigModal";
|
||||
import { SettingsBus } from "./hooks/useSettings";
|
||||
import { LL } from "./i18n/i18n";
|
||||
import { isRemoteUrl, readFromUrl } from "./service/remote/RemoteManager";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import AceCodeEditorSettingTab from "./settings/SettingsTab";
|
||||
import { EmbedCreator } from "./type/obsidian-extend";
|
||||
|
|
@ -29,6 +30,7 @@ import { getCodeBlockAtCursor, updateCodeBlock } from "./utils/CodeBlock";
|
|||
import { SnippetUtils } from "./utils/SnippetUtils";
|
||||
import { CodeEditorView } from "./view/CodeEditorView";
|
||||
import { CodeEmbedView } from "./view/CodeEmbedView";
|
||||
import { RemoteEmbedView } from "./view/RemoteEmbedView";
|
||||
import { SettingsView } from "./view/SettingsView";
|
||||
import { SnippetsEditorView } from "./view/SnippetsEditorView";
|
||||
|
||||
|
|
@ -126,9 +128,9 @@ export default class AceCodeEditorPlugin extends Plugin {
|
|||
}
|
||||
|
||||
private registerMarkdownProcessor() {
|
||||
// 注册markdown后处理器,用于处理带行范围的双链
|
||||
// 注册markdown后处理器,用于处理带行范围的双链和远程代码嵌入
|
||||
this.registerMarkdownPostProcessor((element, context) => {
|
||||
// 查找所有的内部链接
|
||||
// ─── 本地文件的行范围嵌入 ───
|
||||
const links = element.querySelectorAll("a.internal-link");
|
||||
|
||||
links.forEach((link: HTMLAnchorElement) => {
|
||||
|
|
@ -170,13 +172,89 @@ export default class AceCodeEditorPlugin extends Plugin {
|
|||
// 替换原链接
|
||||
link.replaceWith(embedContainer);
|
||||
|
||||
// 加载嵌入视图
|
||||
embedView.onload();
|
||||
// 注册到 markdown 渲染上下文,确保自动清理
|
||||
context.addChild(embedView);
|
||||
});
|
||||
|
||||
// ─── 远程代码嵌入 ───
|
||||
if (!this.settings.remoteEmbed?.enabled) return;
|
||||
|
||||
const embeds = element.querySelectorAll(".internal-embed");
|
||||
|
||||
embeds.forEach((embed: HTMLElement) => {
|
||||
const src = embed.getAttribute("src");
|
||||
if (!src) return;
|
||||
|
||||
// 已处理的跳过
|
||||
if (embed.classList.contains("ace-remote-processed")) return;
|
||||
|
||||
// 仅处理远程 URL
|
||||
if (!isRemoteUrl(src)) return;
|
||||
|
||||
// 提取 subpath(#L 部分)
|
||||
let cleanSrc = src;
|
||||
let subpath = "";
|
||||
const hashIndex = src.lastIndexOf("#");
|
||||
if (hashIndex !== -1) {
|
||||
const afterHash = src.substring(hashIndex + 1);
|
||||
if (/^L\d+(-L\d+)?$/i.test(afterHash)) {
|
||||
subpath = afterHash;
|
||||
cleanSrc = src.substring(0, hashIndex);
|
||||
}
|
||||
}
|
||||
|
||||
embed.classList.add("ace-remote-processed");
|
||||
embed.empty();
|
||||
|
||||
// 构建异步加载函数
|
||||
const loadFn = async () => {
|
||||
const result = await readFromUrl(cleanSrc);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
result.error || "Failed to load remote file",
|
||||
);
|
||||
}
|
||||
return {
|
||||
content: result.content!,
|
||||
extension: result.extension || "txt",
|
||||
sourceUrl: cleanSrc,
|
||||
};
|
||||
};
|
||||
|
||||
const remoteView = new RemoteEmbedView(
|
||||
this,
|
||||
embed,
|
||||
loadFn,
|
||||
subpath,
|
||||
);
|
||||
// 注册到 markdown 渲染上下文,确保文档重新渲染时自动清理
|
||||
context.addChild(remoteView);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private registerEventHandlers() {
|
||||
// 拦截远程链接的点击,阻止 Obsidian 将其当作本地文件路径解析
|
||||
this.registerDomEvent(
|
||||
document.body,
|
||||
"click",
|
||||
(evt) => {
|
||||
const target = evt.target as HTMLElement;
|
||||
const link = target.closest(
|
||||
"a.internal-link",
|
||||
) as HTMLAnchorElement | null;
|
||||
if (!link) return;
|
||||
|
||||
const href = link.getAttribute("href");
|
||||
if (href && isRemoteUrl(href)) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("file-menu", this.handleFileMenu.bind(this)),
|
||||
);
|
||||
|
|
|
|||
191
src/service/remote/RemoteManager.ts
Normal file
191
src/service/remote/RemoteManager.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { requestUrl, RequestUrlParam } from "obsidian";
|
||||
import { RemoteReadResult } from "../../type/remote";
|
||||
|
||||
// ─── 简易内存缓存(避免重复请求同一 URL) ───
|
||||
const cache = new Map<
|
||||
string,
|
||||
{ content: string; extension: string; timestamp: number }
|
||||
>();
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 分钟
|
||||
|
||||
// ─── 请求去重(防止并发请求同一 URL) ───
|
||||
const pendingRequests = new Map<string, Promise<RemoteReadResult>>();
|
||||
|
||||
// ─── 请求超时 ───
|
||||
const REQUEST_TIMEOUT = 30_000; // 30 秒
|
||||
|
||||
function getFromCache(url: string): RemoteReadResult | null {
|
||||
const entry = cache.get(url);
|
||||
if (!entry) return null;
|
||||
if (Date.now() - entry.timestamp > CACHE_TTL) {
|
||||
cache.delete(url);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
content: entry.content,
|
||||
extension: entry.extension,
|
||||
};
|
||||
}
|
||||
|
||||
function setCache(url: string, content: string, extension: string): void {
|
||||
cache.set(url, { content, extension, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有缓存(供设置变更时调用)
|
||||
*/
|
||||
export function clearRemoteCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
// ─── 公共 API ───
|
||||
|
||||
/**
|
||||
* 从直接 URL 读取文件
|
||||
* 自动将 GitHub/GitLab/Gitea blob 页面 URL 转为 raw URL
|
||||
*/
|
||||
export async function readFromUrl(url: string): Promise<RemoteReadResult> {
|
||||
const normalizedUrl = normalizeRemoteUrl(url);
|
||||
const ext = inferExtension(normalizedUrl);
|
||||
return await fetchWithCache(normalizedUrl, {}, ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断链接文本是否为远程 URL
|
||||
*/
|
||||
export function isRemoteUrl(linktext: string): boolean {
|
||||
return linktext.startsWith("https://") || linktext.startsWith("http://");
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 或文件路径推断扩展名
|
||||
*/
|
||||
export function inferExtension(path: string): string {
|
||||
const cleanPath = path.split("?")[0].split("#")[0];
|
||||
const lastDot = cleanPath.lastIndexOf(".");
|
||||
if (lastDot === -1) return "txt";
|
||||
return cleanPath.substring(lastDot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
// ─── 内部工具 ───
|
||||
|
||||
/**
|
||||
* 将 GitHub/GitLab/Gitea blob 页面 URL 转为 raw 内容 URL
|
||||
*
|
||||
* 支持的转换:
|
||||
* github.com/{owner}/{repo}/blob/{branch}/{path}
|
||||
* github.com/{owner}/{repo}/raw/{branch}/{path}
|
||||
* gitlab.com/{owner}/{repo}/-/blob/{branch}/{path}
|
||||
* gitlab.com/{owner}/{repo}/-/raw/{branch}/{path}
|
||||
* gitea.com/{owner}/{repo}/blob/{branch}/{path}
|
||||
* gitea.com/{owner}/{repo}/raw/{branch}/{path}
|
||||
*/
|
||||
export function normalizeRemoteUrl(url: string): string {
|
||||
// GitHub blob: /blob/ → raw.githubusercontent.com
|
||||
const githubBlobMatch = url.match(
|
||||
/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/blob\/([^/]+\/.+)$/,
|
||||
);
|
||||
if (githubBlobMatch) {
|
||||
return `https://raw.githubusercontent.com/${githubBlobMatch[1]}/${githubBlobMatch[2]}`;
|
||||
}
|
||||
|
||||
// GitHub raw → 统一到 raw.githubusercontent.com
|
||||
const githubRawMatch = url.match(
|
||||
/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/raw\/([^/]+\/.+)$/,
|
||||
);
|
||||
if (githubRawMatch) {
|
||||
return `https://raw.githubusercontent.com/${githubRawMatch[1]}/${githubRawMatch[2]}`;
|
||||
}
|
||||
|
||||
// GitLab blob → raw(仅匹配包含 /-/ 的 GitLab 风格路径)
|
||||
const gitlabBlobMatch = url.match(
|
||||
/^https?:\/\/([^/]+)\/([^/]+\/[^/]+)\/-\/blob\/([^/]+\/.+)$/,
|
||||
);
|
||||
if (gitlabBlobMatch) {
|
||||
return `https://${gitlabBlobMatch[1]}/${gitlabBlobMatch[2]}/-/raw/${gitlabBlobMatch[3]}`;
|
||||
}
|
||||
|
||||
// Gitea blob → raw(排除已被 GitHub 处理的域名)
|
||||
const giteaMatch = url.match(
|
||||
/^https?:\/\/(?!github\.com|raw\.githubusercontent\.com)([^/]+)\/([^/]+\/[^/]+)\/blob\/([^/]+\/.+)$/,
|
||||
);
|
||||
if (giteaMatch) {
|
||||
return `https://${giteaMatch[1]}/${giteaMatch[2]}/raw/${giteaMatch[3]}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async function fetchWithCache(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
extension: string,
|
||||
): Promise<RemoteReadResult> {
|
||||
// 1. 检查缓存
|
||||
const cached = getFromCache(url);
|
||||
if (cached) return cached;
|
||||
|
||||
// 2. 检查是否有正在进行的相同请求(去重)
|
||||
const pending = pendingRequests.get(url);
|
||||
if (pending) return pending;
|
||||
|
||||
// 3. 发起新请求
|
||||
const requestPromise = doFetch(url, headers, extension);
|
||||
pendingRequests.set(url, requestPromise);
|
||||
|
||||
try {
|
||||
const result = await requestPromise;
|
||||
return result;
|
||||
} finally {
|
||||
pendingRequests.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function doFetch(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
extension: string,
|
||||
): Promise<RemoteReadResult> {
|
||||
try {
|
||||
// 使用 AbortController 实现超时
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(
|
||||
() => controller.abort(),
|
||||
REQUEST_TIMEOUT,
|
||||
);
|
||||
|
||||
const params: RequestUrlParam = {
|
||||
url,
|
||||
method: "GET",
|
||||
headers,
|
||||
};
|
||||
|
||||
const response = await requestUrl(params);
|
||||
window.clearTimeout(timeoutId);
|
||||
|
||||
// 检查 HTTP 状态码,只缓存成功响应
|
||||
if (response.status >= 400) {
|
||||
return {
|
||||
success: false,
|
||||
error: `HTTP ${response.status}: ${response.text?.substring(0, 200) || "Request failed"}`,
|
||||
};
|
||||
}
|
||||
|
||||
const content = response.text;
|
||||
// 只缓存成功响应,避免 404 等错误被缓存 5 分钟
|
||||
setCache(url, content, extension);
|
||||
return { success: true, content, extension };
|
||||
} catch (err) {
|
||||
const isTimeout =
|
||||
err instanceof DOMException && err.name === "AbortError";
|
||||
return {
|
||||
success: false,
|
||||
error: isTimeout
|
||||
? `Request timed out after ${REQUEST_TIMEOUT / 1000}s`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -627,6 +627,27 @@ export const AceSettings: React.FC<AceSettingsProps> = ({}) => {
|
|||
);
|
||||
}, [settings, app]);
|
||||
|
||||
const RemoteSettings = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<SettingsItem
|
||||
name={LL.setting.remote.enabled.name()}
|
||||
desc={LL.setting.remote.enabled.desc()}
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.remoteEmbed.enabled}
|
||||
onChange={(value) =>
|
||||
settingsStore.updateSettingByPath("remoteEmbed", {
|
||||
...settings.remoteEmbed,
|
||||
enabled: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</>
|
||||
);
|
||||
}, [settings, settingsStore]);
|
||||
|
||||
const AboutSettings = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -751,6 +772,11 @@ export const AceSettings: React.FC<AceSettingsProps> = ({}) => {
|
|||
title: LL.setting.tabs.extend(),
|
||||
content: ExtendSettings,
|
||||
},
|
||||
{
|
||||
id: "remote",
|
||||
title: LL.setting.tabs.remote(),
|
||||
content: RemoteSettings,
|
||||
},
|
||||
{
|
||||
id: "about",
|
||||
title: LL.setting.tabs.about(),
|
||||
|
|
@ -762,6 +788,7 @@ export const AceSettings: React.FC<AceSettingsProps> = ({}) => {
|
|||
SessionSettings,
|
||||
EditorSettings,
|
||||
ExtendSettings,
|
||||
RemoteSettings,
|
||||
AboutSettings,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
16
src/type/remote.ts
Normal file
16
src/type/remote.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/** 远程读取结果 */
|
||||
export interface RemoteReadResult {
|
||||
success: boolean;
|
||||
content?: string;
|
||||
extension?: string; // 从 URL 或 Content-Type 推断的文件扩展名
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 远程嵌入设置 */
|
||||
export interface RemoteEmbedSettings {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_REMOTE_SETTINGS: RemoteEmbedSettings = {
|
||||
enabled: false,
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import {
|
|||
AceKeyboard,
|
||||
AceLightThemes,
|
||||
} from "@src/service/AceThemes";
|
||||
import { DEFAULT_REMOTE_SETTINGS, RemoteEmbedSettings } from "@src/type/remote";
|
||||
|
||||
export const CODE_EDITOR_VIEW_TYPE = "ace-code-editor";
|
||||
export const SNIPPETS_EDITOR_VIEW_TYPE = "ace-snippets-editor";
|
||||
|
|
@ -31,6 +32,7 @@ export interface ICodeEditorConfig {
|
|||
enabled: boolean;
|
||||
mode: "always" | "hover";
|
||||
};
|
||||
remoteEmbed: RemoteEmbedSettings;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: ICodeEditorConfig = {
|
||||
|
|
@ -56,6 +58,7 @@ export const DEFAULT_CONFIG: ICodeEditorConfig = {
|
|||
enabled: true,
|
||||
mode: "always",
|
||||
},
|
||||
remoteEmbed: DEFAULT_REMOTE_SETTINGS,
|
||||
};
|
||||
|
||||
export interface ICodeBlock {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@ import { useSettings } from "@src/hooks/useSettings";
|
|||
import AceCodeEditorPlugin from "@src/main";
|
||||
import { getLanguageMode } from "@src/service/AceLanguages";
|
||||
import { AceService } from "@src/service/AceService";
|
||||
import { AcePluginComponent } from "@src/type/component";
|
||||
import { Embed } from "@src/type/obsidian-extend";
|
||||
import { LineRange } from "@src/type/types";
|
||||
import { parseLinkWithRange } from "@src/utils/LineRange";
|
||||
import { Ace } from "ace-builds";
|
||||
import { Maximize2 } from "lucide-react";
|
||||
import { TFile } from "obsidian";
|
||||
import { MarkdownRenderChild, TFile } from "obsidian";
|
||||
import {
|
||||
createElement,
|
||||
useCallback,
|
||||
|
|
@ -137,8 +136,8 @@ const CodeEmbedContainer: React.FC<CodeEmbedContainerProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export class CodeEmbedView extends AcePluginComponent implements Embed {
|
||||
private contentEl: HTMLElement;
|
||||
export class CodeEmbedView extends MarkdownRenderChild implements Embed {
|
||||
plugin: AceCodeEditorPlugin;
|
||||
private root: Root | null = null;
|
||||
private file: TFile;
|
||||
private subpath: string;
|
||||
|
|
@ -150,8 +149,8 @@ export class CodeEmbedView extends AcePluginComponent implements Embed {
|
|||
file: TFile,
|
||||
subpath: string,
|
||||
) {
|
||||
super(plugin);
|
||||
this.contentEl = containerEl;
|
||||
super(containerEl);
|
||||
this.plugin = plugin;
|
||||
this.file = file;
|
||||
this.subpath = subpath;
|
||||
|
||||
|
|
@ -164,14 +163,14 @@ export class CodeEmbedView extends AcePluginComponent implements Embed {
|
|||
|
||||
async onload() {
|
||||
super.onload();
|
||||
this.contentEl.addClass("ace-embed-view");
|
||||
this.containerEl.addClass("ace-embed-view");
|
||||
|
||||
// 如果有行范围,添加特殊样式
|
||||
if (this.range) {
|
||||
this.contentEl.addClass("line-range");
|
||||
this.containerEl.addClass("line-range");
|
||||
}
|
||||
|
||||
this.root = createRoot(this.contentEl);
|
||||
this.root = createRoot(this.containerEl);
|
||||
|
||||
await this.loadFile();
|
||||
}
|
||||
|
|
|
|||
212
src/view/RemoteEmbedView.tsx
Normal file
212
src/view/RemoteEmbedView.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { useSettings } from "@src/hooks/useSettings";
|
||||
import AceCodeEditorPlugin from "@src/main";
|
||||
import { getLanguageMode } from "@src/service/AceLanguages";
|
||||
import { AceService } from "@src/service/AceService";
|
||||
import { LineRange } from "@src/type/types";
|
||||
import { parseLineRange } from "@src/utils/LineRange";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { createElement, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import { MarkdownRenderChild } from "obsidian";
|
||||
|
||||
// ─── 远程嵌入容器 Props ───
|
||||
|
||||
interface RemoteEmbedContainerProps {
|
||||
plugin: AceCodeEditorPlugin;
|
||||
content: string;
|
||||
extension: string;
|
||||
range?: LineRange;
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
const RemoteEmbedContainer: React.FC<RemoteEmbedContainerProps> = ({
|
||||
plugin,
|
||||
content,
|
||||
extension,
|
||||
range,
|
||||
sourceUrl,
|
||||
}) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const aceServiceRef = useRef<AceService | null>(null);
|
||||
const [lang, setLang] = useState<string>();
|
||||
|
||||
const { settings } = useSettings(plugin);
|
||||
|
||||
// 只在挂载时初始化一次编辑器,settings 变更不应导致重建
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
const initialize = async () => {
|
||||
aceServiceRef.current = new AceService();
|
||||
if (!editorRef.current) return;
|
||||
const editor = aceServiceRef.current.createEditor(
|
||||
editorRef.current,
|
||||
);
|
||||
editor.setReadOnly(true);
|
||||
aceServiceRef.current.configureEditor(settings, extension);
|
||||
|
||||
if (range) {
|
||||
aceServiceRef.current.setValueWithLineRange(content, range);
|
||||
} else {
|
||||
aceServiceRef.current.setValue(content);
|
||||
}
|
||||
|
||||
const contentLines = range
|
||||
? range.endLine - range.startLine + 1
|
||||
: content.split("\n").length;
|
||||
|
||||
const editorHeight = Math.min(
|
||||
contentLines * (settings.fontSize + 4) + 20,
|
||||
settings.embedMaxHeight,
|
||||
);
|
||||
editorRef.current.style.height = `${editorHeight}px`;
|
||||
|
||||
const languageMode = await getLanguageMode(extension);
|
||||
setLang(languageMode);
|
||||
};
|
||||
|
||||
initialize();
|
||||
|
||||
return () => {
|
||||
aceServiceRef.current?.destroy();
|
||||
aceServiceRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const displayLabel = useMemo(() => {
|
||||
if (range) {
|
||||
return range.startLine === range.endLine
|
||||
? `${lang} (Line ${range.startLine})`
|
||||
: `${lang} (Lines ${range.startLine}-${range.endLine})`;
|
||||
}
|
||||
return lang;
|
||||
}, [lang, range]);
|
||||
|
||||
const handleOpenUrl = () => {
|
||||
window.open(sourceUrl, "_blank");
|
||||
};
|
||||
|
||||
const fileName = useMemo(() => {
|
||||
return sourceUrl.split("/").pop() || sourceUrl;
|
||||
}, [sourceUrl]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ace-embed-header">
|
||||
<div className="ace-embed-title ace-embed-remote-title">
|
||||
{fileName}
|
||||
</div>
|
||||
<div className="ace-embed-header-right">
|
||||
<div className="ace-embed-language-label">
|
||||
{displayLabel}
|
||||
</div>
|
||||
<div className="ace-embed-link" onClick={handleOpenUrl}>
|
||||
<ExternalLink size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ace-embed-content">
|
||||
<div ref={editorRef} className="ace-embed-editor"></div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 加载状态组件 ───
|
||||
|
||||
const LoadingView: React.FC = () => {
|
||||
return (
|
||||
<div className="ace-embed-loading">
|
||||
<span>Loading remote code...</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 错误状态组件 ───
|
||||
|
||||
const ErrorView: React.FC<{ error: string }> = ({ error }) => {
|
||||
return (
|
||||
<div className="ace-embed-error">
|
||||
<span>Failed to load remote code</span>
|
||||
<span className="ace-embed-error-msg">{error}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── RemoteEmbedView 类(Component 子类) ───
|
||||
|
||||
export interface RemoteEmbedLoadResult {
|
||||
content: string;
|
||||
extension: string;
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
export class RemoteEmbedView extends MarkdownRenderChild {
|
||||
plugin: AceCodeEditorPlugin;
|
||||
private root: Root | null = null;
|
||||
private range: LineRange | null = null;
|
||||
private loadResult: RemoteEmbedLoadResult | null = null;
|
||||
private subpath: string;
|
||||
|
||||
constructor(
|
||||
plugin: AceCodeEditorPlugin,
|
||||
containerEl: HTMLElement,
|
||||
private loadFn: () => Promise<RemoteEmbedLoadResult>,
|
||||
subpath: string,
|
||||
) {
|
||||
super(containerEl);
|
||||
this.plugin = plugin;
|
||||
this.subpath = subpath;
|
||||
|
||||
if (subpath) {
|
||||
const parsed = parseLineRange(subpath);
|
||||
if (parsed) this.range = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
async onload() {
|
||||
super.onload();
|
||||
this.containerEl.addClass("ace-embed-view");
|
||||
this.containerEl.addClass("ace-embed-remote-view");
|
||||
|
||||
this.root = createRoot(this.containerEl);
|
||||
|
||||
// 先渲染 loading
|
||||
this.root.render(createElement(LoadingView, {}));
|
||||
|
||||
// 异步加载远程内容
|
||||
try {
|
||||
this.loadResult = await this.loadFn();
|
||||
// 异步操作后检查组件是否仍然存活,避免在已卸载的 root 上渲染
|
||||
if (!this.root) return;
|
||||
this.renderContent();
|
||||
} catch (err) {
|
||||
if (!this.root) return;
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.root.render(createElement(ErrorView, { error: errorMsg }));
|
||||
}
|
||||
}
|
||||
|
||||
private renderContent(): void {
|
||||
if (!this.root || !this.loadResult) return;
|
||||
|
||||
this.root.render(
|
||||
createElement(RemoteEmbedContainer, {
|
||||
plugin: this.plugin,
|
||||
content: this.loadResult.content,
|
||||
extension: this.loadResult.extension,
|
||||
range: this.range || undefined,
|
||||
sourceUrl: this.loadResult.sourceUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onunload(): void {
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
super.onunload();
|
||||
}
|
||||
}
|
||||
|
|
@ -66,3 +66,71 @@
|
|||
display: block;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* ─── 远程代码嵌入样式 ─── */
|
||||
|
||||
/* 远程嵌入视图容器:使用 accent 色边框区分本地嵌入 */
|
||||
.ace-embed-remote-view {
|
||||
border-color: var(--interactive-accent);
|
||||
|
||||
&.mod-empty-attachment {
|
||||
padding: 0rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 远程嵌入标题修饰:显示外链图标和 accent 色 */
|
||||
.ace-embed-remote-title {
|
||||
color: var(--text-accent);
|
||||
font-style: italic;
|
||||
|
||||
&::before {
|
||||
content: "\2197\00a0"; /* ↗ + nbsp */
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 加载状态 ─── */
|
||||
.ace-embed-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
gap: 8px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--background-modifier-border);
|
||||
border-top-color: var(--interactive-accent);
|
||||
border-radius: 50%;
|
||||
animation: ace-embed-spin 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ace-embed-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 错误状态 ─── */
|
||||
.ace-embed-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
color: var(--text-error);
|
||||
font-size: 0.85rem;
|
||||
gap: 4px;
|
||||
|
||||
.ace-embed-error-msg {
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue