diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b67716..c20e2c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/i18n/en/index.ts b/src/i18n/en/index.ts index 0ba5512..70d56d8 100644 --- a/src/i18n/en/index.ts +++ b/src/i18n/en/index.ts @@ -84,6 +84,7 @@ const en = { session: "Session", editor: "Editor", extend: "Extend", + remote: "Remote", about: "About", }, desc: "View wiki documentation 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...", diff --git a/src/i18n/i18n-types.ts b/src/i18n/i18n-types.ts index f8f75b1..daf1c0e 100644 --- a/src/i18n/i18n-types.ts +++ b/src/i18n/i18n-types.ts @@ -283,6 +283,10 @@ type RootTranslation = { * 扩​展 */ extend: string + /** + * 远​程 + */ + remote: string /** * 关​于 */ @@ -476,6 +480,18 @@ type RootTranslation = { hidden: string } } + remote: { + enabled: { + /** + * 启​用​远​程​代​码​嵌​入 + */ + name: string + /** + * 从​远​程​ ​U​R​L​(​如​ ​G​i​t​H​u​b​ ​b​l​o​b​ ​页​面​)​嵌​入​代​码​到​笔​记​中 + */ + desc: string + } + } about: { /** * A​c​e​ ​运​行​时​文​件 @@ -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 运行时文件 diff --git a/src/i18n/zh-TW/index.ts b/src/i18n/zh-TW/index.ts index b068d94..67a9547 100644 --- a/src/i18n/zh-TW/index.ts +++ b/src/i18n/zh-TW/index.ts @@ -81,6 +81,7 @@ const zh_TW = { session: "會話", editor: "編輯", extend: "擴展", + remote: "遠端", about: "關於", }, desc: "查看wiki文件了解更多功能", @@ -157,6 +158,12 @@ const zh_TW = { hidden: "隱藏", }, }, + remote: { + enabled: { + name: "啟用遠端程式碼嵌入", + desc: "從遠端 URL(如 GitHub blob 頁面)嵌入程式碼到筆記中", + }, + }, about: { runtime_files: "Ace 執行時檔案", checking: "正在檢查本機執行環境...", diff --git a/src/i18n/zh/index.ts b/src/i18n/zh/index.ts index 95507a4..29e3b6d 100644 --- a/src/i18n/zh/index.ts +++ b/src/i18n/zh/index.ts @@ -80,6 +80,7 @@ const zh = { session: "会话", editor: "编辑", extend: "扩展", + remote: "远程", about: "关于", }, desc: "查看wiki文档了解更多功能", @@ -156,6 +157,12 @@ const zh = { hidden: "隐藏", }, }, + remote: { + enabled: { + name: "启用远程代码嵌入", + desc: "从远程 URL(如 GitHub blob 页面)嵌入代码到笔记中", + }, + }, about: { runtime_files: "Ace 运行时文件", checking: "正在检查本地运行环境...", diff --git a/src/main.ts b/src/main.ts index b9ff60c..9e14199 100644 --- a/src/main.ts +++ b/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)), ); diff --git a/src/service/remote/RemoteManager.ts b/src/service/remote/RemoteManager.ts new file mode 100644 index 0000000..09dc9c1 --- /dev/null +++ b/src/service/remote/RemoteManager.ts @@ -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>(); + +// ─── 请求超时 ─── +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 { + 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, + extension: string, +): Promise { + // 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, + extension: string, +): Promise { + 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), + }; + } +} diff --git a/src/settings/AceSettings.tsx b/src/settings/AceSettings.tsx index 7d09951..dfd003e 100644 --- a/src/settings/AceSettings.tsx +++ b/src/settings/AceSettings.tsx @@ -627,6 +627,27 @@ export const AceSettings: React.FC = ({}) => { ); }, [settings, app]); + const RemoteSettings = useMemo(() => { + return ( + <> + + + settingsStore.updateSettingByPath("remoteEmbed", { + ...settings.remoteEmbed, + enabled: value, + }) + } + /> + + + ); + }, [settings, settingsStore]); + const AboutSettings = useMemo(() => { return ( <> @@ -751,6 +772,11 @@ export const AceSettings: React.FC = ({}) => { 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 = ({}) => { SessionSettings, EditorSettings, ExtendSettings, + RemoteSettings, AboutSettings, ], ); diff --git a/src/type/remote.ts b/src/type/remote.ts new file mode 100644 index 0000000..162a89b --- /dev/null +++ b/src/type/remote.ts @@ -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, +}; diff --git a/src/type/types.ts b/src/type/types.ts index 76ae5e6..ed67ba1 100644 --- a/src/type/types.ts +++ b/src/type/types.ts @@ -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 { diff --git a/src/view/CodeEmbedView.tsx b/src/view/CodeEmbedView.tsx index 4270d32..012bd3f 100644 --- a/src/view/CodeEmbedView.tsx +++ b/src/view/CodeEmbedView.tsx @@ -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 = ({ ); }; -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(); } diff --git a/src/view/RemoteEmbedView.tsx b/src/view/RemoteEmbedView.tsx new file mode 100644 index 0000000..866a9af --- /dev/null +++ b/src/view/RemoteEmbedView.tsx @@ -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 = ({ + plugin, + content, + extension, + range, + sourceUrl, +}) => { + const editorRef = useRef(null); + const aceServiceRef = useRef(null); + const [lang, setLang] = useState(); + + 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 ( + <> +
+
+ {fileName} +
+
+
+ {displayLabel} +
+
+ +
+
+
+
+
+
+ + ); +}; + +// ─── 加载状态组件 ─── + +const LoadingView: React.FC = () => { + return ( +
+ Loading remote code... +
+ ); +}; + +// ─── 错误状态组件 ─── + +const ErrorView: React.FC<{ error: string }> = ({ error }) => { + return ( +
+ Failed to load remote code + {error} +
+ ); +}; + +// ─── 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, + 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(); + } +} diff --git a/styles/AceEmbedView.css b/styles/AceEmbedView.css index 144c883..d5c1711 100644 --- a/styles/AceEmbedView.css +++ b/styles/AceEmbedView.css @@ -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; + } +}