mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user