feat: 实现远程代码嵌入加载

This commit is contained in:
RavenHogwarts 2026-06-11 19:08:26 +08:00 committed by RavenHogWarts
parent 396e354681
commit e0f61a4cbe
13 changed files with 674 additions and 27 deletions

View file

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

View file

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

View file

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

View file

@ -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: "正在檢查本機執行環境...",

View file

@ -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: "正在检查本地运行环境...",

View file

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

View 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),
};
}
}

View file

@ -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
View 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,
};

View file

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

View file

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

View 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();
}
}

View file

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