fix(editor): keep relative link targets when pasting between note folders (#35743)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-29 17:18:42 -07:00
committed by GitHub
co-authored by OrcaWin
parent 553c0ccb28
commit 85884f8397
4 changed files with 378 additions and 0 deletions
@@ -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<string, string> }
// Why: absolute source paths stay in this renderer; the clipboard only carries an opaque id.
const origins = new Map<string, ClipboardOrigin>()
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
}
})
}
})
]
}
})
@@ -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<string>(parentSteps).fill('..'), ...targetSegments.slice(common)].join(
'/'
)
return `${relative || './'}${canonicalTarget.search}${canonicalTarget.hash}`
}
@@ -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> = {}
): 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')
})
@@ -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() {