Skip to content

Commit a5f93cf

Browse files
committed
feat: 链接支持打开本地文件
1 parent bd2388d commit a5f93cf

5 files changed

Lines changed: 127 additions & 8 deletions

File tree

src/core/editor.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -537,14 +537,13 @@ export class MilkupEditor implements IMilkupEditor {
537537
me.preventDefault();
538538
me.stopPropagation();
539539

540-
let href = this.getLinkHref(linkEl);
540+
const href = this.getLinkHref(linkEl);
541541
if (href) {
542-
// 补全协议前缀,避免被当作本地文件路径
543-
if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href)) {
544-
href = "https://" + href;
545-
}
546542
const electronAPI = (window as any).electronAPI;
547-
if (electronAPI?.openExternal) {
543+
const currentFilePath = (window as any).__currentFilePath || null;
544+
if (electronAPI?.openLink) {
545+
electronAPI.openLink(href, currentFilePath);
546+
} else if (electronAPI?.openExternal) {
548547
electronAPI.openExternal(href);
549548
} else {
550549
window.open(href, "_blank", "noopener,noreferrer");

src/core/plugins/link-tooltip.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ function findLinkElement(target: HTMLElement, root: HTMLElement): HTMLAnchorElem
2727
/** 用默认浏览器打开链接 */
2828
function openLinkExternal(href: string) {
2929
const electronAPI = (window as any).electronAPI;
30-
if (electronAPI?.openExternal) {
30+
const currentFilePath = (window as any).__currentFilePath || null;
31+
if (electronAPI?.openLink) {
32+
electronAPI.openLink(href, currentFilePath);
33+
} else if (electronAPI?.openExternal) {
3134
electronAPI.openExternal(href);
3235
} else {
3336
window.open(href, "_blank", "noopener,noreferrer");

src/main/ipcBridge.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { FileTraits } from "./fileFormat";
66
import { execSync } from "node:child_process";
77
import * as fs from "node:fs";
88
import path from "node:path";
9+
import { fileURLToPath } from "node:url";
910
import chokidar from "chokidar";
1011
import { Document, HeadingLevel, Packer, Paragraph, TextRun } from "docx";
1112
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron";
@@ -17,7 +18,7 @@ import {
1718
restoreFileTraits,
1819
} from "./fileFormat";
1920
import { createThemeEditorWindow } from "./index";
20-
import { normalizeMarkdownFilePath, readMarkdownFile } from "./markdownFile";
21+
import { isMarkdownFilePath, normalizeMarkdownFilePath, readMarkdownFile } from "./markdownFile";
2122
import {
2223
cancelDragFollow,
2324
clearWindowDragPreview,
@@ -77,6 +78,86 @@ function normalizeRelativeImageDirectory(inputPath: string): string {
7778
.trim();
7879
}
7980

81+
function hasUriScheme(target: string): boolean {
82+
return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(target) && !/^[a-zA-Z]:[\\/]/.test(target);
83+
}
84+
85+
function isExternalLink(target: string): boolean {
86+
return target.startsWith("//") || (hasUriScheme(target) && !/^file:/i.test(target));
87+
}
88+
89+
function isObviousLocalPath(target: string): boolean {
90+
return (
91+
/^[a-zA-Z]:[\\/]/.test(target) ||
92+
/^\\\\[^\\]/.test(target) ||
93+
/^[\\/]/.test(target) ||
94+
/^\.\.?([\\/]|$)/.test(target)
95+
);
96+
}
97+
98+
function localPathExists(filePath: string): boolean {
99+
try {
100+
return fs.existsSync(filePath);
101+
} catch {
102+
return false;
103+
}
104+
}
105+
106+
function isLikelyHostnameWithoutProtocol(target: string): boolean {
107+
const candidate = target.trim().split(/[?#]/)[0];
108+
if (!candidate || /[\s\\]/.test(candidate)) return false;
109+
if (isObviousLocalPath(candidate) || isExternalLink(candidate)) return false;
110+
111+
const firstSegment = candidate.split("/")[0];
112+
if (!firstSegment) return false;
113+
114+
if (/^localhost(?::\d+)?$/i.test(firstSegment)) return true;
115+
if (/^\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?$/.test(firstSegment)) return true;
116+
if (/^\[[0-9a-fA-F:]+\](?::\d+)?$/.test(firstSegment)) return true;
117+
if (/^[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+(?::\d+)?$/.test(firstSegment)) return true;
118+
119+
return false;
120+
}
121+
122+
function resolveLocalLinkPath(target: string, currentFilePath?: string | null): string | null {
123+
const trimmed = target.trim();
124+
if (!trimmed || trimmed.startsWith("#")) return null;
125+
126+
if (/^file:/i.test(trimmed)) {
127+
try {
128+
return fileURLToPath(trimmed);
129+
} catch {
130+
return null;
131+
}
132+
}
133+
134+
if (isExternalLink(trimmed)) return null;
135+
136+
const cleanPath = trimmed.split(/[?#]/)[0];
137+
if (!cleanPath) return null;
138+
139+
if (path.isAbsolute(cleanPath) || /^\\\\[^\\]/.test(cleanPath)) {
140+
return cleanPath;
141+
}
142+
143+
if (!currentFilePath) return null;
144+
const resolvedPath = path.resolve(path.dirname(currentFilePath), cleanPath);
145+
if (localPathExists(resolvedPath)) return resolvedPath;
146+
147+
if (isLikelyHostnameWithoutProtocol(trimmed)) return null;
148+
149+
return resolvedPath;
150+
}
151+
152+
function normalizeExternalLink(target: string): string {
153+
const trimmed = target.trim();
154+
if (!trimmed) return trimmed;
155+
if (isExternalLink(trimmed) || /^file:/i.test(trimmed)) {
156+
return trimmed.startsWith("//") ? `https:${trimmed}` : trimmed;
157+
}
158+
return `https://${trimmed}`;
159+
}
160+
80161
function getImageOutputExtension(fileName?: string, mimeType?: string): string {
81162
const fileExt = fileName ? path.extname(fileName) : "";
82163
if (fileExt) {
@@ -255,6 +336,39 @@ export function registerIpcOnHandlers() {
255336
ipcMain.on("shell:openExternal", (_event, url) => {
256337
shell.openExternal(url);
257338
});
339+
ipcMain.handle("shell:openLink", async (event, href: string, currentFilePath?: string | null) => {
340+
const localPath = resolveLocalLinkPath(href, currentFilePath);
341+
if (localPath) {
342+
if (isMarkdownFilePath(localPath)) {
343+
const sourceWin = BrowserWindow.fromWebContents(event.sender);
344+
const targetWin = findWindowWithFile(localPath, sourceWin?.id);
345+
if (targetWin) {
346+
targetWin.webContents.send("tab:activate-file", localPath);
347+
targetWin.focus();
348+
return;
349+
}
350+
351+
const result = readMarkdownFile(localPath);
352+
if (result && sourceWin && !sourceWin.isDestroyed()) {
353+
sourceWin.webContents.send("open-file-at-launch", {
354+
filePath: result.filePath,
355+
content: result.content,
356+
fileTraits: result.fileTraits,
357+
});
358+
sourceWin.focus();
359+
return;
360+
}
361+
}
362+
363+
await shell.openPath(localPath);
364+
return;
365+
}
366+
367+
const externalUrl = normalizeExternalLink(href);
368+
if (externalUrl) {
369+
await shell.openExternal(externalUrl);
370+
}
371+
});
258372
ipcMain.on("change-save-status", (event, isSavedStatus) => {
259373
const targetWin = BrowserWindow.fromWebContents(event.sender);
260374
if (!targetWin) return;

src/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
3838
});
3939
},
4040
openExternal: (url: string) => ipcRenderer.send("shell:openExternal", url),
41+
openLink: (href: string, currentFilePath?: string | null) =>
42+
ipcRenderer.invoke("shell:openLink", href, currentFilePath),
4143
getFilePathInClipboard: () => ipcRenderer.invoke("clipboard:getFilePath"),
4244
writeTempImage: (
4345
file: Uint8Array,

src/renderer/global.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ interface Window {
3030
cb: (payload: { filePath: string; content: string; fileTraits?: FileTraitsDTO }) => void
3131
) => void;
3232
openExternal: (url: string) => Promise<void>;
33+
openLink: (href: string, currentFilePath?: string | null) => Promise<void>;
3334
getFilePathInClipboard: () => Promise<string | null>;
3435
writeTempImage: (
3536
file: Uint8Array<ArrayBuffer>,

0 commit comments

Comments
 (0)