From 7bfe8b170967a4384d1f4610f2e3488ecd4a2be2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:24:19 +0000 Subject: [PATCH 1/7] Initial plan From 68b73d1d19a632c1819f237b735e250adb45e970 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:29:50 +0000 Subject: [PATCH 2/7] Handle relative links in preview mode via MarkEdit native API Add isRelativePath() utility to detect relative URLs and click event delegation on preview pane to intercept relative link clicks, resolve them against the current file's parent path, and open via MarkEdit.openFile(). Includes 13 new tests for isRelativePath(). Agent-Logs-Url: https://github.com/MarkEdit-app/MarkEdit-preview/sessions/d166ad61-577f-4c71-a7e5-d62f97cab8f6 Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> --- src/utils.ts | 14 ++++++++++++ src/view.ts | 35 +++++++++++++++++++++++++++- tests/utils.test.ts | 56 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/utils.test.ts diff --git a/src/utils.ts b/src/utils.ts index 6db8cfb..6d93eef 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -90,6 +90,20 @@ export function joinPaths(path1: string, path2: string) { return path1 + '/' + path2; } +export function isRelativePath(href: string) { + // Anchor links + if (href.startsWith('#')) { + return false; + } + + // Absolute URLs (http:, https:, mailto:, data:, etc.) or protocol-relative URLs + if (/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(href)) { + return false; + } + + return true; +} + export function stripQuotes(input: string) { if ((input.startsWith('"') && input.endsWith('"')) || (input.startsWith("'") && input.endsWith("'"))) { return input.slice(1, -1); diff --git a/src/view.ts b/src/view.ts index bf8ea27..d5e8423 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,5 +1,5 @@ import { MarkEdit } from 'markedit-api'; -import { appendStyle, getFileExtension, getFileName, selectFullRange } from './utils'; +import { appendStyle, getFileExtension, getFileName, isRelativePath, joinPaths, selectFullRange } from './utils'; import { renderMarkdown, renderMermaid, renderKatex, handlePostRender, applyStyles } from './render'; import { replaceImageURLs } from './image'; import { hidePreviewButtons, previewModes } from './settings'; @@ -63,6 +63,39 @@ export function setUp() { } }); + // Delegate relative links to native file opening + previewPane.addEventListener('click', async event => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const anchor = target.closest('a'); + if (anchor === null) { + return; + } + + // Use getAttribute to get the raw href, not the resolved absolute URL + const href = anchor.getAttribute('href'); + if (href === null || href === '' || !isRelativePath(href)) { + return; + } + + if (typeof MarkEdit.getFileInfo !== 'function') { + return; + } + + event.preventDefault(); + + const basePath = (await MarkEdit.getFileInfo())?.parentPath; + if (basePath === undefined) { + return; + } + + const absolutePath = joinPaths(basePath, href); + await MarkEdit.openFile(absolutePath); + }); + const mutationObserver = new MutationObserver(updateGutterStyle); mutationObserver.observe(previewPane, { attributes: true, attributeFilter: ['style', 'class'] }); diff --git a/tests/utils.test.ts b/tests/utils.test.ts new file mode 100644 index 0000000..8edbe14 --- /dev/null +++ b/tests/utils.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { isRelativePath } from '../src/utils'; + +describe('isRelativePath', () => { + it('should return true for paths starting with ../', () => { + expect(isRelativePath('../assets/image.png')).toBe(true); + }); + + it('should return true for paths starting with ./', () => { + expect(isRelativePath('./assets/image.png')).toBe(true); + }); + + it('should return true for bare relative paths', () => { + expect(isRelativePath('assets/image.png')).toBe(true); + }); + + it('should return true for file names', () => { + expect(isRelativePath('readme.md')).toBe(true); + }); + + it('should return false for http URLs', () => { + expect(isRelativePath('http://example.com')).toBe(false); + }); + + it('should return false for https URLs', () => { + expect(isRelativePath('https://example.com/page')).toBe(false); + }); + + it('should return false for anchor links', () => { + expect(isRelativePath('#heading')).toBe(false); + }); + + it('should return false for mailto links', () => { + expect(isRelativePath('mailto:user@example.com')).toBe(false); + }); + + it('should return false for data URIs', () => { + expect(isRelativePath('data:image/png;base64,abc')).toBe(false); + }); + + it('should return false for protocol-relative URLs', () => { + expect(isRelativePath('//example.com/page')).toBe(false); + }); + + it('should return false for ftp URLs', () => { + expect(isRelativePath('ftp://files.example.com')).toBe(false); + }); + + it('should return false for tel links', () => { + expect(isRelativePath('tel:+1234567890')).toBe(false); + }); + + it('should return false for javascript URIs', () => { + expect(isRelativePath('javascript:void(0)')).toBe(false); + }); +}); From d41be5fef86137de1a0049cc6cc286177488f514 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:41:26 +0000 Subject: [PATCH 3/7] Narrow relative link handling to only ../ paths Per maintainer feedback, restrict the click interception to links starting with "../" only. Remove the now-unnecessary isRelativePath() utility and its test file. Agent-Logs-Url: https://github.com/MarkEdit-app/MarkEdit-preview/sessions/bf2367cc-cfbf-4aab-8991-df30b8f2a973 Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> --- src/utils.ts | 14 ------------ src/view.ts | 4 ++-- tests/utils.test.ts | 56 --------------------------------------------- 3 files changed, 2 insertions(+), 72 deletions(-) delete mode 100644 tests/utils.test.ts diff --git a/src/utils.ts b/src/utils.ts index 6d93eef..6db8cfb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -90,20 +90,6 @@ export function joinPaths(path1: string, path2: string) { return path1 + '/' + path2; } -export function isRelativePath(href: string) { - // Anchor links - if (href.startsWith('#')) { - return false; - } - - // Absolute URLs (http:, https:, mailto:, data:, etc.) or protocol-relative URLs - if (/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(href)) { - return false; - } - - return true; -} - export function stripQuotes(input: string) { if ((input.startsWith('"') && input.endsWith('"')) || (input.startsWith("'") && input.endsWith("'"))) { return input.slice(1, -1); diff --git a/src/view.ts b/src/view.ts index d5e8423..0a61c23 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,5 +1,5 @@ import { MarkEdit } from 'markedit-api'; -import { appendStyle, getFileExtension, getFileName, isRelativePath, joinPaths, selectFullRange } from './utils'; +import { appendStyle, getFileExtension, getFileName, joinPaths, selectFullRange } from './utils'; import { renderMarkdown, renderMermaid, renderKatex, handlePostRender, applyStyles } from './render'; import { replaceImageURLs } from './image'; import { hidePreviewButtons, previewModes } from './settings'; @@ -77,7 +77,7 @@ export function setUp() { // Use getAttribute to get the raw href, not the resolved absolute URL const href = anchor.getAttribute('href'); - if (href === null || href === '' || !isRelativePath(href)) { + if (!href?.startsWith('../')) { return; } diff --git a/tests/utils.test.ts b/tests/utils.test.ts deleted file mode 100644 index 8edbe14..0000000 --- a/tests/utils.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { isRelativePath } from '../src/utils'; - -describe('isRelativePath', () => { - it('should return true for paths starting with ../', () => { - expect(isRelativePath('../assets/image.png')).toBe(true); - }); - - it('should return true for paths starting with ./', () => { - expect(isRelativePath('./assets/image.png')).toBe(true); - }); - - it('should return true for bare relative paths', () => { - expect(isRelativePath('assets/image.png')).toBe(true); - }); - - it('should return true for file names', () => { - expect(isRelativePath('readme.md')).toBe(true); - }); - - it('should return false for http URLs', () => { - expect(isRelativePath('http://example.com')).toBe(false); - }); - - it('should return false for https URLs', () => { - expect(isRelativePath('https://example.com/page')).toBe(false); - }); - - it('should return false for anchor links', () => { - expect(isRelativePath('#heading')).toBe(false); - }); - - it('should return false for mailto links', () => { - expect(isRelativePath('mailto:user@example.com')).toBe(false); - }); - - it('should return false for data URIs', () => { - expect(isRelativePath('data:image/png;base64,abc')).toBe(false); - }); - - it('should return false for protocol-relative URLs', () => { - expect(isRelativePath('//example.com/page')).toBe(false); - }); - - it('should return false for ftp URLs', () => { - expect(isRelativePath('ftp://files.example.com')).toBe(false); - }); - - it('should return false for tel links', () => { - expect(isRelativePath('tel:+1234567890')).toBe(false); - }); - - it('should return false for javascript URIs', () => { - expect(isRelativePath('javascript:void(0)')).toBe(false); - }); -}); From ec61398b3338f32ab764dc88103d7abbeeb478b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:50:09 +0000 Subject: [PATCH 4/7] Add stopPropagation() to relative link click handler Agent-Logs-Url: https://github.com/MarkEdit-app/MarkEdit-preview/sessions/f529384f-2def-40e2-a1bb-bc5bdfde21d0 Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> --- src/view.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/view.ts b/src/view.ts index 0a61c23..43ff631 100644 --- a/src/view.ts +++ b/src/view.ts @@ -86,6 +86,7 @@ export function setUp() { } event.preventDefault(); + event.stopPropagation(); const basePath = (await MarkEdit.getFileInfo())?.parentPath; if (basePath === undefined) { From c71d4a903202afc8285a3f847038180790a6495d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:52:56 +0000 Subject: [PATCH 5/7] Move preventDefault/stopPropagation after valid parentPath check Agent-Logs-Url: https://github.com/MarkEdit-app/MarkEdit-preview/sessions/fd3c45a9-39c8-4dcd-995d-78325ff7b444 Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> --- src/view.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/view.ts b/src/view.ts index 43ff631..bc932ad 100644 --- a/src/view.ts +++ b/src/view.ts @@ -85,14 +85,14 @@ export function setUp() { return; } - event.preventDefault(); - event.stopPropagation(); - const basePath = (await MarkEdit.getFileInfo())?.parentPath; if (basePath === undefined) { return; } + event.preventDefault(); + event.stopPropagation(); + const absolutePath = joinPaths(basePath, href); await MarkEdit.openFile(absolutePath); }); From f7d0e6f74dfba08ce9e09c086c4a203d634f7812 Mon Sep 17 00:00:00 2001 From: Ying Zhong <0x00eeee@gmail.com> Date: Sun, 29 Mar 2026 11:56:32 +0800 Subject: [PATCH 6/7] Update src/view.ts Co-authored-by: George Politis --- src/view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view.ts b/src/view.ts index bc932ad..1b7750a 100644 --- a/src/view.ts +++ b/src/view.ts @@ -93,7 +93,7 @@ export function setUp() { event.preventDefault(); event.stopPropagation(); - const absolutePath = joinPaths(basePath, href); + const absolutePath = joinPaths(basePath, decodeURIComponent(href)); await MarkEdit.openFile(absolutePath); }); From b9fd3abc20e7b46125345112a14799e0c1cc0aac Mon Sep 17 00:00:00 2001 From: Ying Zhong <0x00eeee@gmail.com> Date: Sun, 29 Mar 2026 12:26:52 +0800 Subject: [PATCH 7/7] nit-picking --- src/view.ts | 71 ++++++++++++++++++++++++++++------------------------- 1 file changed, 37 insertions(+), 34 deletions(-) diff --git a/src/view.ts b/src/view.ts index 1b7750a..d0a1af6 100644 --- a/src/view.ts +++ b/src/view.ts @@ -63,40 +63,6 @@ export function setUp() { } }); - // Delegate relative links to native file opening - previewPane.addEventListener('click', async event => { - const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } - - const anchor = target.closest('a'); - if (anchor === null) { - return; - } - - // Use getAttribute to get the raw href, not the resolved absolute URL - const href = anchor.getAttribute('href'); - if (!href?.startsWith('../')) { - return; - } - - if (typeof MarkEdit.getFileInfo !== 'function') { - return; - } - - const basePath = (await MarkEdit.getFileInfo())?.parentPath; - if (basePath === undefined) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const absolutePath = joinPaths(basePath, decodeURIComponent(href)); - await MarkEdit.openFile(absolutePath); - }); - const mutationObserver = new MutationObserver(updateGutterStyle); mutationObserver.observe(previewPane, { attributes: true, attributeFilter: ['style', 'class'] }); @@ -109,6 +75,11 @@ export function setUp() { renderHtmlPreview(); } }); + + // Delegate external links ("../link") to native file opening + if (typeof MarkEdit.getFileInfo === 'function') { + previewPane.addEventListener('click', handleExternalFiles); + } } export function setViewMode(mode: ViewMode, needsDisplay = true) { @@ -325,6 +296,38 @@ async function saveGeneratedHtml(styled: boolean) { MarkEdit.showSavePanel({ fileName, string }); } +async function handleExternalFiles(event: MouseEvent) { + if (!(event.target instanceof Element)) { + return; + } + + const anchor = event.target.closest('a'); + if (anchor === null) { + return; + } + + // We need to handle this because it is outside of the webpage root + const href = anchor.getAttribute('href'); + if (!href?.startsWith('../')) { + return; + } + + const basePath = (await MarkEdit.getFileInfo())?.parentPath; + if (basePath === undefined) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + try { + const absolutePath = joinPaths(basePath, decodeURIComponent(href)); + await MarkEdit.openFile(absolutePath); + } catch (error) { + console.error('Failed to open file:', error); + } +} + const states: { viewMode: ViewMode; splitter: Splitter | undefined;