From 85884f8397a8a06ecbccd2a2c6e51cdfe93fbc44 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 29 Oct 2026 17:18:42 -0700 Subject: [PATCH] fix(editor): keep relative link targets when pasting between note folders (#35743) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../rich-markdown-clipboard-link-origin.ts | 172 ++++++++++++++++++ .../rich-markdown-clipboard-link-target.ts | 79 ++++++++ .../rich-markdown-clipboard-links.test.ts | 125 +++++++++++++ .../editor/rich-markdown-extensions.ts | 2 + 4 files changed, 378 insertions(+) create mode 100644 src/renderer/src/components/editor/rich-markdown-clipboard-link-origin.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-clipboard-link-target.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-clipboard-links.test.ts diff --git a/src/renderer/src/components/editor/rich-markdown-clipboard-link-origin.ts b/src/renderer/src/components/editor/rich-markdown-clipboard-link-origin.ts new file mode 100644 index 00000000000..1a559c619c1 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-clipboard-link-origin.ts @@ -0,0 +1,172 @@ +import { Extension, type Editor } from '@tiptap/core' +import { DOMSerializer } from '@tiptap/pm/model' +import { Plugin } from '@tiptap/pm/state' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { + readRichMarkdownImageRuntimeContext, + type RichMarkdownImageRuntimeContext +} from './rich-markdown-image-context' +import { + isRelativeClipboardLink, + rebaseClipboardLink, + resolveRelativeClipboardLink +} from './rich-markdown-clipboard-link-target' + +const LINK_ORIGIN_ATTRIBUTE = 'data-orca-link-clipboard-origin' +const MAX_ORIGINS = 16 +const MAX_LINKS_PER_ORIGIN = 256 + +type ClipboardDocument = { + filePath: string + workspaceRoot: string + runtimeContext: RichMarkdownImageRuntimeContext +} +type ClipboardOrigin = ClipboardDocument & { links: Map } + +// Why: absolute source paths stay in this renderer; the clipboard only carries an opaque id. +const origins = new Map() + +function readClipboardDocument(editor: Editor): ClipboardDocument | null { + const storage: unknown = 'image' in editor.storage ? editor.storage.image : undefined + if ( + !storage || + typeof storage !== 'object' || + !('filePath' in storage) || + typeof storage.filePath !== 'string' || + !storage.filePath + ) { + return null + } + const runtimeContext = readRichMarkdownImageRuntimeContext({ + runtimeContext: 'runtimeContext' in storage ? storage.runtimeContext : undefined + }) + const workspaceRoot = runtimeContext?.worktreePath + return runtimeContext && runtimeContext.worktreeId && workspaceRoot + ? { filePath: storage.filePath, workspaceRoot, runtimeContext } + : null +} + +function sameWorkspace( + source: RichMarkdownImageRuntimeContext, + destination: RichMarkdownImageRuntimeContext +): boolean { + return ( + source.worktreeId === destination.worktreeId && + source.worktreePath === destination.worktreePath && + source.target.kind === destination.target.kind && + (source.target.kind !== 'environment' || + (destination.target.kind === 'environment' && + source.target.environmentId === destination.target.environmentId)) && + (source.connectionId ?? null) === (destination.connectionId ?? null) && + source.expectedExternalSshTargetId === destination.expectedExternalSshTargetId + ) +} + +function rebaseClipboardLinks(html: string, editor: Editor): string { + if (!html.includes(LINK_ORIGIN_ATTRIBUTE)) { + return html + } + const destination = readClipboardDocument(editor) + if (!destination) { + return html + } + const document = new DOMParser().parseFromString(html, 'text/html') + let changed = false + for (const link of document.querySelectorAll(`a[${LINK_ORIGIN_ATTRIBUTE}]`)) { + const origin = origins.get(link.getAttribute(LINK_ORIGIN_ATTRIBUTE) ?? '') + const href = link.getAttribute('href') + const absoluteHref = href ? origin?.links.get(href) : undefined + if ( + !origin || + !href || + !absoluteHref || + !sameWorkspace(origin.runtimeContext, destination.runtimeContext) + ) { + continue + } + const rebased = rebaseClipboardLink( + href, + absoluteHref, + destination.filePath, + destination.workspaceRoot + ) + if (rebased !== href) { + link.setAttribute('href', rebased) + changed = true + } + } + return changed ? document.body.innerHTML : html +} + +export const RichMarkdownClipboardLinkOrigin = Extension.create({ + name: 'richMarkdownClipboardLinkOrigin', + addProseMirrorPlugins() { + const editor = this.editor + const serializer = DOMSerializer.fromSchema(editor.schema) + const serializeLink = serializer.marks.link + if (!serializeLink) { + return [] + } + let originId: string | null = null + let source: ClipboardDocument | null | undefined + const captureOrigin = (href: string): string | null => { + if (source === undefined) { + source = readClipboardDocument(editor) + } + const absoluteHref = source ? resolveRelativeClipboardLink(href, source.filePath) : null + if (!source || !absoluteHref) { + return null + } + let origin = originId ? origins.get(originId) : undefined + if (!origin) { + originId = createBrowserUuid() + origin = { ...source, links: new Map() } + origins.set(originId, origin) + if (origins.size > MAX_ORIGINS) { + const oldest = origins.keys().next().value + if (oldest !== undefined) { + origins.delete(oldest) + } + } + } + if (!origin.links.has(href)) { + if (origin.links.size >= MAX_LINKS_PER_ORIGIN) { + return null + } + origin.links.set(href, absoluteHref) + } + return originId + } + return [ + new Plugin({ + props: { + transformPastedHTML: (html) => rebaseClipboardLinks(html, editor), + // Why: ProseMirror calls transformCopied before serializing, so each copy gets a fresh origin. + transformCopied: (slice) => { + originId = null + source = undefined + return slice + }, + clipboardSerializer: new DOMSerializer(serializer.nodes, { + ...serializer.marks, + link: (mark, inline) => { + const result = DOMSerializer.renderSpec(document, serializeLink(mark, inline)) + const href: unknown = mark.attrs.href + if ( + typeof href === 'string' && + isRelativeClipboardLink(href) && + result.dom instanceof HTMLElement + ) { + const id = captureOrigin(href) + if (id) { + result.dom.setAttribute(LINK_ORIGIN_ATTRIBUTE, id) + } + } + return result + } + }) + } + }) + ] + } +}) diff --git a/src/renderer/src/components/editor/rich-markdown-clipboard-link-target.ts b/src/renderer/src/components/editor/rich-markdown-clipboard-link-target.ts new file mode 100644 index 00000000000..a4998cb44ee --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-clipboard-link-target.ts @@ -0,0 +1,79 @@ +import { + isCaseInsensitiveRuntimeRoot, + isWindowsAbsolutePathLike, + relativePathInsideRoot +} from '../../../../shared/cross-platform-path' +import { fileUriToFilesystemPath, filesystemPathToFileUri } from '../../../../shared/file-uri-path' +import { projectMarkdownHrefForClipboard } from './markdown-internal-links' +import { resolveMarkdownPreviewHref } from './markdown-preview-links' + +export function isRelativeClipboardLink(href: string): boolean { + const projected = projectMarkdownHrefForClipboard(href) + return Boolean(projected && !/^(?:[a-z][a-z\d+.-]*:|[/\\#?])/i.test(projected)) +} + +export function resolveRelativeClipboardLink(href: string, filePath: string): string | null { + if (!isRelativeClipboardLink(href)) { + return null + } + const target = resolveMarkdownPreviewHref(href, filePath) + return target?.protocol === 'file:' && fileUriToFilesystemPath(target) !== null + ? target.toString() + : null +} + +/** Rewrites a relative href so it keeps its target from another note in the same workspace root; + * returns the original href whenever the target or destination lies outside that root. */ +export function rebaseClipboardLink( + href: string, + absoluteHref: string, + destinationFilePath: string, + workspaceRoot: string +): string { + if (resolveRelativeClipboardLink(href, destinationFilePath) === absoluteHref) { + return href + } + const target = new URL(absoluteHref) + const targetPath = fileUriToFilesystemPath(target) + const destinationInRoot = relativePathInsideRoot(workspaceRoot, destinationFilePath) + if ( + targetPath === null || + relativePathInsideRoot(workspaceRoot, targetPath) === null || + !destinationInRoot + ) { + return href + } + const canonicalTarget = new URL(filesystemPathToFileUri(targetPath)) + canonicalTarget.search = target.search + canonicalTarget.hash = target.hash + const destination = new URL(filesystemPathToFileUri(destinationFilePath)) + const windows = isWindowsAbsolutePathLike(targetPath) + const caseInsensitive = isCaseInsensitiveRuntimeRoot(targetPath) + const targetSegments = canonicalTarget.pathname.split('/') + const directorySegments = destination.pathname.split('/').slice(0, -1) + const equal = (left: string, right: string): boolean => + caseInsensitive ? left.toLowerCase() === right.toLowerCase() : left === right + if ( + canonicalTarget.host !== destination.host || + windows !== isWindowsAbsolutePathLike(destinationFilePath) + ) { + return href + } + let common = 0 + while ( + common < targetSegments.length && + common < directorySegments.length && + equal(targetSegments[common], directorySegments[common]) + ) { + common++ + } + const parentSteps = directorySegments.length - common + // Why: never climb past the workspace root even if root spellings differ (NFC, case, aliases). + if (parentSteps > destinationInRoot.split('/').length - 1) { + return href + } + const relative = [...Array(parentSteps).fill('..'), ...targetSegments.slice(common)].join( + '/' + ) + return `${relative || './'}${canonicalTarget.search}${canonicalTarget.hash}` +} diff --git a/src/renderer/src/components/editor/rich-markdown-clipboard-links.test.ts b/src/renderer/src/components/editor/rich-markdown-clipboard-links.test.ts new file mode 100644 index 00000000000..ca12ff43477 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-clipboard-links.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment happy-dom +import { Editor } from '@tiptap/core' +import { afterEach, expect, it } from 'vitest' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { + setRichMarkdownImageResolverContext, + type RichMarkdownImageRuntimeContext +} from './rich-markdown-image-context' +import { resolveMarkdownPreviewHref } from './markdown-preview-links' + +const editors: Editor[] = [] + +function workspace( + worktreePath: string, + overrides: Partial = {} +): RichMarkdownImageRuntimeContext { + return { target: { kind: 'local' }, worktreeId: `wt:${worktreePath}`, worktreePath, ...overrides } +} + +function createEditor( + filePath: string, + href = './guide.md', + runtimeContext: RichMarkdownImageRuntimeContext | undefined = workspace('/repo') +): Editor { + const element = document.createElement('div') + document.body.append(element) + const editor = new Editor({ + element, + extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }), + content: `Before **[guide](${href} "Reference")** after.`, + contentType: 'markdown' + }) + editors.push(editor) + setRichMarkdownImageResolverContext(editor, { filePath, runtimeContext }) + return editor +} +function copy(editor: Editor): string { + editor.commands.selectAll() + return editor.view.serializeForClipboard(editor.state.selection.content()).dom.innerHTML +} +function paste(editor: Editor, html: string): void { + editor.commands.selectAll() + editor.view.pasteHTML(html) +} +function href(editor: Editor): string | null { + return editor.view.dom.querySelector('a')?.getAttribute('href') ?? null +} +afterEach(() => { + editors.splice(0).forEach((editor) => editor.destroy()) + document.getSelection()?.removeAllRanges() + document.body.replaceChildren() +}) + +it('rebases a relative link pasted into a note in another folder of the same workspace', () => { + const source = createEditor('/repo/notes/note.md') + const html = copy(source) + source.destroy() + const target = createEditor('/repo/other/note.md', 'https://example.com') + paste(target, html) + expect(href(target)).toBe('../notes/guide.md') + expect(target.view.dom.querySelector('a')?.getAttribute('title')).toBe('Reference') + const markdown = target.getMarkdown() + expect(markdown).toContain('[**guide**](../notes/guide.md "Reference")') + expect(markdown).not.toContain('clipboard-origin') +}) + +it.each([ + [ + '/repo/notes/note.md', + '/repo/other/note.md', + './a%20b%23c.md?version=2#part', + '../notes/a%20b%23c.md?version=2#part' + ], + ['C:\\repo\\notes\\note.md', 'C:\\repo\\other\\note.md', './guide.md', '../notes/guide.md'] +])('keeps the resolved target from %s into %s for %s', (from, to, link, expected) => { + const root = from.startsWith('C:') ? 'C:\\repo' : '/repo' + const source = createEditor(from, link, workspace(root)) + const target = createEditor(to, './old.md', workspace(root)) + paste(target, copy(source)) + expect(href(target)).toBe(expected) + expect(resolveMarkdownPreviewHref(href(target) ?? '', to)?.toString()).toBe( + resolveMarkdownPreviewHref(link, from)?.toString() + ) +}) + +it('keeps the relative spelling within the same folder', () => { + const source = createEditor('/repo/notes/note.md') + const target = createEditor('/repo/notes/second.md') + paste(target, copy(source)) + expect(href(target)).toBe('./guide.md') +}) + +it('keeps the relative spelling between sibling worktrees of the same repo', () => { + const source = createEditor('/ws/A/notes/note.md', './guide.md', workspace('/ws/A')) + const target = createEditor('/ws/B/notes/note.md', './old.md', workspace('/ws/B')) + paste(target, copy(source)) + expect(href(target)).toBe('./guide.md') +}) + +it('does not rebase a link whose target leaves the workspace root', () => { + const source = createEditor('/repo/notes/note.md', '../../outside.md') + const target = createEditor('/repo/other/deeper/note.md') + paste(target, copy(source)) + expect(href(target)).toBe('../../outside.md') +}) + +it('does not rebase between different runtime owners', () => { + const source = createEditor('/repo/notes/note.md', './guide.md', workspace('/repo')) + const target = createEditor( + '/repo/other/note.md', + './old.md', + workspace('/repo', { target: { kind: 'environment', environmentId: 'remote' } }) + ) + paste(target, copy(source)) + expect(href(target)).toBe('./guide.md') +}) + +it('ignores origin metadata that does not match the copied href', () => { + const source = createEditor('/repo/notes/note.md') + const html = copy(source).replace('href="./guide.md"', 'href="./different.md"') + const target = createEditor('/repo/other/note.md') + paste(target, html) + expect(href(target)).toBe('./different.md') +}) diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index fa82c1004c5..21914bd04b2 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -16,6 +16,7 @@ import { RichMarkdownTable } from './rich-markdown-table' import { TableCell, TableHeader, TableRow } from '@tiptap/extension-table' import { BlockMath, InlineMath } from '@tiptap/extension-mathematics' import { createRichMarkdownExtension } from './rich-markdown-extension' +import { RichMarkdownClipboardLinkOrigin } from './rich-markdown-clipboard-link-origin' import { createLowlight, common } from 'lowlight' import { acquireLocalImageSrcLease, @@ -102,6 +103,7 @@ export function createRichMarkdownExtensions({ autolink: true, linkOnPaste: true }), + RichMarkdownClipboardLinkOrigin, // Load local images through IPC/blob URLs so dev-server cross-origin restrictions cannot block file URLs. Image.extend({ addStorage() {