diff --git a/package.json b/package.json index fbbc4b6..6f0a35d 100644 --- a/package.json +++ b/package.json @@ -36,13 +36,6 @@ "email": "ptheofan@gmail.com" }, "license": "GPL-3.0", - "pnpm": { - "onlyBuiltDependencies": [ - "electron", - "macos-alias", - "fs-xattr" - ] - }, "devDependencies": { "@electron-forge/cli": "^7.11.1", "@electron-forge/maker-dmg": "^7.11.1", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..7b5cc7e --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +allowBuilds: + electron: true + esbuild: true + fs-xattr: true + macos-alias: true +nodeLinker: hoisted +executionEnv: + nodeVersion: 22.17.0 diff --git a/src/plugins/builtin/GithubFlavoredPlugin.ts b/src/plugins/builtin/GithubFlavoredPlugin.ts index 6df2e0d..7db55da 100644 --- a/src/plugins/builtin/GithubFlavoredPlugin.ts +++ b/src/plugins/builtin/GithubFlavoredPlugin.ts @@ -56,6 +56,49 @@ export class GithubFlavoredPlugin implements MarkdownPlugin { // Add task list support this.addTaskLists(md); + + // Add GitHub-style ids to headings so in-document anchors work + this.addHeadingAnchors(md); + } + + /** + * Assign GitHub-style slug ids to headings (e.g. "TextField API" -> + * "textfield-api") so that in-document links like [foo](#textfield-api) + * resolve. Duplicate slugs get a numeric suffix, matching GitHub. + */ + private addHeadingAnchors(md: MarkdownIt): void { + md.core.ruler.push('heading_anchors', (state) => { + const usedSlugs = new Map(); + const tokens = state.tokens; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (!token || token.type !== 'heading_open') continue; + + const inline = tokens[i + 1]; + if (!inline || inline.type !== 'inline') continue; + + const text = (inline.children ?? []) + .filter((t) => t.type === 'text' || t.type === 'code_inline') + .map((t) => t.content) + .join(''); + + let slug = text + .trim() + .toLowerCase() + .replace(/[^\w\- ]+/g, '') + .replace(/\s+/g, '-'); + if (!slug) continue; + + const count = usedSlugs.get(slug) ?? 0; + usedSlugs.set(slug, count + 1); + if (count > 0) { + slug = `${slug}-${count}`; + } + + token.attrSet('id', slug); + } + }); } /** diff --git a/src/preload/assetResolver.ts b/src/preload/assetResolver.ts index 0f39000..d25605a 100644 --- a/src/preload/assetResolver.ts +++ b/src/preload/assetResolver.ts @@ -11,14 +11,16 @@ import { pathToFileURL } from 'node:url'; import { ASSET_PROTOCOL_SCHEME, ASSET_PROTOCOL_HOST } from '@shared/constants'; /** - * Resolve an image reference against the markdown file that contains it. + * Resolve a document reference to an absolute filesystem path. * * @param baseFilePath - Absolute path to the markdown file being rendered. - * @param ref - The raw `src`/`srcset` reference from the document. - * @returns An `om-asset:` URL for local files, or `null` when the reference - * should be left untouched (already a URL, protocol-relative, or empty). + * @param ref - The raw reference from the document (e.g. `./other.md`, + * `images/logo.svg`, `/abs/path/file.md`). + * @returns The absolute path on disk, or `null` when the reference is not a + * local file (already a URL, protocol-relative, in-document anchor, or + * empty). Query strings and fragments are stripped. */ -export function resolveAssetUrl(baseFilePath: string, ref: string): string | null { +export function resolveLocalPath(baseFilePath: string, ref: string): string | null { if (!baseFilePath || !ref) { return null; } @@ -35,7 +37,7 @@ export function resolveAssetUrl(baseFilePath: string, ref: string): string | nul return null; } - // Pure in-document anchors are not assets. + // Pure in-document anchors are not local files. if (trimmed.startsWith('#')) { return null; } @@ -53,9 +55,24 @@ export function resolveAssetUrl(baseFilePath: string, ref: string): string | nul decoded = cleaned; } - const resolved = path.isAbsolute(decoded) + return path.isAbsolute(decoded) ? decoded : path.resolve(path.dirname(baseFilePath), decoded); +} + +/** + * Resolve an image reference against the markdown file that contains it. + * + * @param baseFilePath - Absolute path to the markdown file being rendered. + * @param ref - The raw `src`/`srcset` reference from the document. + * @returns An `om-asset:` URL for local files, or `null` when the reference + * should be left untouched (already a URL, protocol-relative, or empty). + */ +export function resolveAssetUrl(baseFilePath: string, ref: string): string | null { + const resolved = resolveLocalPath(baseFilePath, ref); + if (!resolved) { + return null; + } // Embed the (already percent-encoded) file path under a fixed host. A bare // `om-asset:///path` URL is mangled by Chromium's standard-scheme parser, diff --git a/src/preload/preload.ts b/src/preload/preload.ts index f4a575d..1ceab1f 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -5,7 +5,7 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron'; import { IPC_CHANNELS } from '@shared/types/api'; -import { resolveAssetUrl } from './assetResolver'; +import { resolveAssetUrl, resolveLocalPath } from './assetResolver'; import type { ElectronAPI, @@ -339,6 +339,10 @@ const electronAPI: ElectronAPI = { resolve: (baseFilePath: string, ref: string): string | null => { return resolveAssetUrl(baseFilePath, ref); }, + + resolvePath: (baseFilePath: string, ref: string): string | null => { + return resolveLocalPath(baseFilePath, ref); + }, }, }; diff --git a/src/renderer.ts b/src/renderer.ts index 892ff3e..29181f7 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -280,6 +280,14 @@ class App { }, }); + this.markdownViewer.setOnOpenLocalFile((filePath, fragment) => { + void this.loadFile(filePath).then(() => { + if (fragment) { + this.markdownViewer?.scrollToHeading(fragment); + } + }); + }); + this.dropZone.setOnFileDrop((filePath) => { void this.handleFileDrop(filePath); }); diff --git a/src/renderer/components/MarkdownViewer.ts b/src/renderer/components/MarkdownViewer.ts index 5ed32d6..6d1c1f6 100644 --- a/src/renderer/components/MarkdownViewer.ts +++ b/src/renderer/components/MarkdownViewer.ts @@ -9,7 +9,7 @@ import { createMermaidPlugin, MermaidPlugin, } from '@plugins/index'; -import { BUILTIN_PLUGINS } from '@shared/constants'; +import { BUILTIN_PLUGINS, MARKDOWN_EXTENSIONS } from '@shared/constants'; import { Toast } from './Toast'; import { EditModeController, createEditModeController } from './EditModeController'; @@ -49,6 +49,9 @@ export class MarkdownViewer { private toast: Toast; private editModeController: EditModeController | null = null; private isEditMode = false; + private onOpenLocalFile: + | ((filePath: string, fragment: string | null) => void) + | null = null; constructor(container: HTMLElement) { this.container = container; @@ -338,13 +341,59 @@ export class MarkdownViewer { if (!(anchor instanceof HTMLAnchorElement)) return; const href = anchor.getAttribute('href'); - if (!href || !this.isExternalUrl(href)) return; + if (!href) return; + // In-document anchor links: scroll to the target heading + if (href.startsWith('#')) { + e.preventDefault(); + this.scrollToHeading(decodeURIComponent(href.slice(1))); + return; + } + + if (this.isExternalUrl(href)) { + e.preventDefault(); + void window.electronAPI.shell.openExternal(href); + return; + } + + // Anything else is a local reference. Default navigation would replace + // the whole app page (blanking it back to the welcome screen), so it is + // always prevented; markdown files are opened in the viewer instead. e.preventDefault(); - void window.electronAPI.shell.openExternal(href); + this.openLocalLink(href); }); } + /** + * Open a link to a local markdown file in the viewer, resolving relative + * references against the current document's location + */ + private openLocalLink(href: string): void { + const basePath = this.state.filePath; + if (!basePath || !this.onOpenLocalFile) return; + + const resolved = window.electronAPI.assets.resolvePath(basePath, href); + if (!resolved) return; + + const ext = resolved.slice(resolved.lastIndexOf('.')).toLowerCase(); + if (!(MARKDOWN_EXTENSIONS as readonly string[]).includes(ext)) return; + + const hashIndex = href.indexOf('#'); + const fragment = + hashIndex >= 0 ? decodeURIComponent(href.slice(hashIndex + 1)) : null; + + this.onOpenLocalFile(resolved, fragment); + } + + /** + * Set the callback invoked when a link to a local markdown file is clicked + */ + setOnOpenLocalFile( + callback: (filePath: string, fragment: string | null) => void + ): void { + this.onOpenLocalFile = callback; + } + /** * Determine whether a link points to the internet (vs. an in-document anchor) */ diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index a15dbe4..c340e89 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -268,6 +268,13 @@ export interface AssetsAPI { * otherwise should be left untouched. */ resolve: (baseFilePath: string, ref: string) => string | null; + + /** + * Resolve a document reference (e.g. a link to another markdown file) to an + * absolute filesystem path. Returns `null` when the reference is not a + * local file (already a URL or an in-document anchor). + */ + resolvePath: (baseFilePath: string, ref: string) => string | null; } /**