@@ -6,6 +6,7 @@ import type { FileTraits } from "./fileFormat";
66import { execSync } from "node:child_process" ;
77import * as fs from "node:fs" ;
88import path from "node:path" ;
9+ import { fileURLToPath } from "node:url" ;
910import chokidar from "chokidar" ;
1011import { Document , HeadingLevel , Packer , Paragraph , TextRun } from "docx" ;
1112import { app , BrowserWindow , clipboard , dialog , ipcMain , shell } from "electron" ;
@@ -17,7 +18,7 @@ import {
1718 restoreFileTraits ,
1819} from "./fileFormat" ;
1920import { createThemeEditorWindow } from "./index" ;
20- import { normalizeMarkdownFilePath , readMarkdownFile } from "./markdownFile" ;
21+ import { isMarkdownFilePath , normalizeMarkdownFilePath , readMarkdownFile } from "./markdownFile" ;
2122import {
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 - z A - Z ] [ a - z A - Z \d + \- . ] * : / . test ( target ) && ! / ^ [ a - z A - Z ] : [ \\ / ] / . test ( target ) ;
83+ }
84+
85+ function isExternalLink ( target : string ) : boolean {
86+ return target . startsWith ( "//" ) || ( hasUriScheme ( target ) && ! / ^ f i l e : / i. test ( target ) ) ;
87+ }
88+
89+ function isObviousLocalPath ( target : string ) : boolean {
90+ return (
91+ / ^ [ a - z A - 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 ( / ^ l o c a l h o s t (?: : \d + ) ? $ / i. test ( firstSegment ) ) return true ;
115+ if ( / ^ \d { 1 , 3 } (?: \. \d { 1 , 3 } ) { 3 } (?: : \d + ) ? $ / . test ( firstSegment ) ) return true ;
116+ if ( / ^ \[ [ 0 - 9 a - f A - F : ] + \] (?: : \d + ) ? $ / . test ( firstSegment ) ) return true ;
117+ if ( / ^ [ a - z A - Z 0 - 9 - ] + (?: \. [ a - z A - Z 0 - 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 ( / ^ f i l e : / 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 ) || / ^ f i l e : / i. test ( trimmed ) ) {
156+ return trimmed . startsWith ( "//" ) ? `https:${ trimmed } ` : trimmed ;
157+ }
158+ return `https://${ trimmed } ` ;
159+ }
160+
80161function 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 ;
0 commit comments