diff --git a/frontend/packages/editor/src/embed-block.parseHTML.test.ts b/frontend/packages/editor/src/embed-block.parseHTML.test.ts new file mode 100644 index 000000000..f3585e08e --- /dev/null +++ b/frontend/packages/editor/src/embed-block.parseHTML.test.ts @@ -0,0 +1,22 @@ +import {describe, expect, it} from 'vitest' + +import {vi} from 'vitest' + +vi.mock('./blocknote/react', () => ({ + createReactBlockSpec: (spec: any) => spec, +})) + +import {EmbedBlock} from './embed-block' + +describe('EmbedBlock.parseHTML', () => { + it('claims SSR embed card anchors before generic link parsing', () => { + expect(EmbedBlock.parseHTML).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tag: 'a[data-content-type=embed]', + priority: 1001, + }), + ]), + ) + }) +}) diff --git a/frontend/packages/editor/src/embed-block.tsx b/frontend/packages/editor/src/embed-block.tsx index 0f45c2276..fd729579c 100644 --- a/frontend/packages/editor/src/embed-block.tsx +++ b/frontend/packages/editor/src/embed-block.tsx @@ -66,6 +66,13 @@ export const EmbedBlock = createReactBlockSpec({ return Fragment.empty }, }, + { + tag: 'a[data-content-type=embed]', + priority: 1001, + getContent: (_node, _schema) => { + return Fragment.empty + }, + }, ], }) diff --git a/frontend/packages/editor/src/mentions-plugin.test.ts b/frontend/packages/editor/src/mentions-plugin.test.ts new file mode 100644 index 000000000..62f98cc9c --- /dev/null +++ b/frontend/packages/editor/src/mentions-plugin.test.ts @@ -0,0 +1,25 @@ +import {describe, expect, it} from 'vitest' +import {createInlineEmbedNode, inlineEmbedClipboardText} from './mentions-plugin' + +describe('inlineEmbedClipboardText', () => { + it('serializes inline embeds to a non-empty clipboard fallback', () => { + expect(inlineEmbedClipboardText('hm://uid1/docs/page')).toBe('hm://uid1/docs/page') + }) + + it('returns empty string when link is missing', () => { + expect(inlineEmbedClipboardText('')).toBe('') + }) + + it('parses data-inline-embed anchors before generic links', () => { + const parseRules = createInlineEmbedNode().config.parseHTML?.() || [] + + expect(parseRules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tag: 'a[data-inline-embed]', + priority: 1000, + }), + ]), + ) + }) +}) diff --git a/frontend/packages/editor/src/mentions-plugin.tsx b/frontend/packages/editor/src/mentions-plugin.tsx index 2242a635f..05f0d819e 100644 --- a/frontend/packages/editor/src/mentions-plugin.tsx +++ b/frontend/packages/editor/src/mentions-plugin.tsx @@ -10,6 +10,11 @@ import {Plugin} from '@tiptap/pm/state' import {NodeViewWrapper, ReactNodeViewRenderer} from '@tiptap/react' import './inline-embed.css' +/** Fallback text used when serializing an inline embed to the clipboard. */ +export function inlineEmbedClipboardText(link: string): string { + return link || '' +} + /** Creates the TipTap Node for rendering inline-embed mentions in the document. */ export function createInlineEmbedNode() { const InlineEmbedNode = Node.create({ @@ -20,13 +25,21 @@ export function createInlineEmbedNode() { addNodeView() { return ReactNodeViewRenderer(InlineEmbedNodeComponent) }, - renderHTML({HTMLAttributes}) { - return ['a', {...HTMLAttributes, href: HTMLAttributes.link, 'data-inline-embed': HTMLAttributes.link}] + renderHTML({node, HTMLAttributes}) { + return [ + 'a', + {...HTMLAttributes, href: HTMLAttributes.link, 'data-inline-embed': HTMLAttributes.link}, + inlineEmbedClipboardText(node.attrs.link), + ] + }, + renderText({node}) { + return inlineEmbedClipboardText(node.attrs.link) }, parseHTML() { return [ { tag: `a[data-inline-embed]`, + priority: 1000, getAttrs: (dom) => { if (dom instanceof HTMLElement) { var value = dom.getAttribute('data-inline-embed') @@ -37,6 +50,7 @@ export function createInlineEmbedNode() { }, { tag: `span[data-inline-embed]`, + priority: 1000, getAttrs: (dom) => { if (dom instanceof HTMLElement) { var value = dom.getAttribute('data-inline-embed') diff --git a/frontend/packages/editor/src/ssr-render.test.ts b/frontend/packages/editor/src/ssr-render.test.ts index 53dc34db1..237d30d3f 100644 --- a/frontend/packages/editor/src/ssr-render.test.ts +++ b/frontend/packages/editor/src/ssr-render.test.ts @@ -22,9 +22,33 @@ describe('renderDocumentToHTML', () => { ) expect(html).toContain('href="https://example.com/docs/page"') + expect(html).toContain('data-hm-link="hm://uid1/docs/page"') expect(html).not.toContain('href="hm://') }) + it('preserves inline embed metadata needed for copy-paste round-trips', () => { + const html = renderDocumentToHTML( + [ + { + block: { + id: 'block-1', + type: 'Paragraph', + text: 'inline embed', + annotations: [{type: 'Embed', starts: [0], ends: [12], link: 'hm://uid1/docs/page'}], + }, + children: [], + }, + ] as HMBlockNode[], + { + renderHref: (url) => (url === 'hm://uid1/docs/page' ? 'https://example.com/docs/page' : url), + }, + ) + + expect(html).toContain('href="https://example.com/docs/page"') + expect(html).toContain('data-hm-link="hm://uid1/docs/page"') + expect(html).toContain('data-inline-embed="hm://uid1/docs/page"') + }) + it('uses renderHref for SSR embed cards', () => { const html = renderDocumentToHTML( [ @@ -50,6 +74,32 @@ describe('renderDocumentToHTML', () => { ) expect(html).toContain('href="https://example.com/docs/page"') + expect(html).toContain('data-url="hm://uid1/docs/page"') + expect(html).toContain('data-view="Content"') + expect(html).toContain('${renderEmbedCard( + return `
${renderEmbedCard( embedData, link, + view, renderHref, )}
` } - return `
` + return `
` } case 'WebEmbed': @@ -283,6 +287,7 @@ function renderAnnotatedText( let html = content let linkHref: string | null = null + let embedHref: string | null = null let isInlineEmbed = false for (const ann of span.annotations) { @@ -307,6 +312,7 @@ function renderAnnotatedText( break case 'Embed': isInlineEmbed = true + embedHref = (ann as any).link || '' break case 'Range': break @@ -341,12 +347,20 @@ function renderAnnotatedText( } } - if (linkHref != null) { - html = `
${html}` + const rawHref = embedHref ?? linkHref + if (rawHref != null) { + const inlineEmbedAttr = isInlineEmbed ? ` data-inline-embed="${esc(embedHref || rawHref)}"` : '' + const inlineEmbedClass = isInlineEmbed ? ' inline-embed' : '' + html = `${html}` + + return html } if (isInlineEmbed) { - html = `${html}` + const inlineEmbedAttr = embedHref ? ` data-inline-embed="${esc(embedHref)}"` : '' + html = `${html}` } return html @@ -354,7 +368,12 @@ function renderAnnotatedText( .join('') } -function renderEmbedCard(data: SSREmbedData, link: string, renderHref: SSRRenderOpts['renderHref']): string { +function renderEmbedCard( + data: SSREmbedData, + link: string, + view: string, + renderHref: SSRRenderOpts['renderHref'], +): string { const title = esc(data.title || '') const summary = esc(data.summary || '') const href = link ? esc(resolveHref(link, renderHref)) : '' @@ -364,7 +383,9 @@ function renderEmbedCard(data: SSREmbedData, link: string, renderHref: SSRRender : '' return ( - `` + + `` + imageHtml + `
` + `
${title}
` + diff --git a/frontend/packages/editor/src/tiptap-extension-link/helpers/pasteHandler.test.ts b/frontend/packages/editor/src/tiptap-extension-link/helpers/pasteHandler.test.ts index 9640c9ca4..333d7e9c1 100644 --- a/frontend/packages/editor/src/tiptap-extension-link/helpers/pasteHandler.test.ts +++ b/frontend/packages/editor/src/tiptap-extension-link/helpers/pasteHandler.test.ts @@ -113,6 +113,27 @@ describe('pasteHandler', () => { expect(view.state.doc.nodeAt(0)?.marks[0]?.attrs.href).toBe(url) }) + it('does not treat multiple pasted hm:// URLs as one document link', async () => { + const text = 'hm://abc/avatar-options\nhm://abc/autoplay-videos' + const view = createPasteView() + const plugin = pasteHandler({ + editor: {schema} as any, + type: schema.marks.link, + universalClient: { + request: async () => { + throw new Error('multiple URLs should not resolve through the single-link paste flow') + }, + } as any, + gwUrl: {get: () => 'https://hyper.media'} as any, + checkWebUrl: async () => null, + }) + + const handled = plugin.props.handlePaste?.(view, {} as any, new Slice(Fragment.from(schema.text(text)), 0, 0)) + + expect(handled).toBe(false) + expect(view.state.doc.textContent).toBe('') + }) + it('uses the universal client to insert a pasted hm:// comment URL as a titled link', async () => { const url = 'hm://commenter/comment-id' const view = createPasteView() diff --git a/frontend/packages/editor/src/tiptap-extension-link/helpers/pasteHandler.ts b/frontend/packages/editor/src/tiptap-extension-link/helpers/pasteHandler.ts index 5c944451f..452625a52 100644 --- a/frontend/packages/editor/src/tiptap-extension-link/helpers/pasteHandler.ts +++ b/frontend/packages/editor/src/tiptap-extension-link/helpers/pasteHandler.ts @@ -185,8 +185,9 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin { } : null + const isSingleUrlText = !/\s/.test(textContent) const unpackedHmId = - isHypermediaScheme(textContent) || isPublicGatewayLink(textContent, options.gwUrl) + isSingleUrlText && (isHypermediaScheme(textContent) || isPublicGatewayLink(textContent, options.gwUrl)) ? unpackHmId(textContent) : null diff --git a/frontend/packages/editor/src/tiptap-extension-link/link.test.ts b/frontend/packages/editor/src/tiptap-extension-link/link.test.ts new file mode 100644 index 000000000..79ba22e18 --- /dev/null +++ b/frontend/packages/editor/src/tiptap-extension-link/link.test.ts @@ -0,0 +1,48 @@ +import {describe, expect, it} from 'vitest' +import {buildRenderedLinkAttributes, getLinkAttrsFromElement} from './link' + +describe('link DOM round-tripping', () => { + it('preserves canonical hm href in data-hm-link while rendering a platform href', () => { + const attrs = buildRenderedLinkAttributes( + { + href: 'hm://uid1/docs/page', + class: 'link', + }, + (url) => (url === 'hm://uid1/docs/page' ? 'https://example.com/docs/page' : url), + ) + + expect(attrs.href).toBe('https://example.com/docs/page') + expect(attrs['data-hm-link']).toBe('hm://uid1/docs/page') + expect(attrs.class).toContain('text-link') + }) + + it('prefers data-hm-link over rendered href while parsing', () => { + const element = document.createElement('a') + element.setAttribute('href', 'https://example.com/docs/page') + element.setAttribute('data-hm-link', 'hm://uid1/docs/page') + element.setAttribute('class', 'link text-link') + + expect(getLinkAttrsFromElement(element)).toEqual({ + href: 'hm://uid1/docs/page', + class: 'link text-link', + }) + }) + + it('falls back to href when no canonical raw attr is present', () => { + const element = document.createElement('a') + element.setAttribute('href', 'https://example.com/docs/page') + + expect(getLinkAttrsFromElement(element)).toEqual({ + href: 'https://example.com/docs/page', + }) + }) + + it('does not claim inline embed anchors as normal links', () => { + const element = document.createElement('a') + element.setAttribute('href', 'https://example.com/docs/page') + element.setAttribute('data-hm-link', 'hm://uid1/docs/page') + element.setAttribute('data-inline-embed', 'hm://uid1/docs/page') + + expect(getLinkAttrsFromElement(element)).toBe(false) + }) +}) diff --git a/frontend/packages/editor/src/tiptap-extension-link/link.ts b/frontend/packages/editor/src/tiptap-extension-link/link.ts index c0530a6bd..bc80a6f81 100644 --- a/frontend/packages/editor/src/tiptap-extension-link/link.ts +++ b/frontend/packages/editor/src/tiptap-extension-link/link.ts @@ -51,6 +51,46 @@ export interface LinkOptions { handleModifiedClicks?: boolean } +/** Returns the canonical raw href stored on a rendered link element. */ +export function getLinkAttrsFromElement(element: HTMLElement): false | Record { + if (element.hasAttribute('data-inline-embed')) return false + + const href = element.getAttribute('data-hm-link') || element.getAttribute('href') + if (!href) return false + + const attrs: Record = { + href, + } + + const target = element.getAttribute('target') + if (target) attrs.target = target + + const className = element.getAttribute('class') + if (className) attrs.class = className + + const id = element.getAttribute('id') + if (id) attrs.id = id + + return attrs +} + +/** Builds DOM attributes for a rendered link while preserving the canonical raw href. */ +export function buildRenderedLinkAttributes( + htmlAttributes: Record, + renderHref?: (url: string) => string, +): Record { + const attrs = {...htmlAttributes} + const rawHref = typeof attrs.href === 'string' ? attrs.href : null + const renderedHref = rawHref ? renderHref?.(rawHref) ?? rawHref : attrs.href + + return { + ...attrs, + ...(rawHref ? {'data-hm-link': rawHref} : {}), + href: renderedHref, + class: `${attrs.class} text-link hover:text-link-hover`, + } +} + declare module '@tiptap/core' { interface Commands { link: { @@ -132,22 +172,22 @@ export const Link = Mark.create({ }, parseHTML() { - return [{tag: 'a[href]:not([href *= "javascript:" i])'}, {tag: 'span.link'}] + const getAttrs = (dom: string | HTMLElement) => { + if (!(dom instanceof HTMLElement)) return false + return getLinkAttrsFromElement(dom) + } + + return [ + {tag: 'a[data-hm-link]:not([data-inline-embed]):not([href *= "javascript:" i])', getAttrs}, + {tag: 'a[href]:not([data-inline-embed]):not([href *= "javascript:" i])', getAttrs}, + {tag: 'span.link:not([data-inline-embed])', getAttrs}, + ] }, renderHTML({HTMLAttributes}) { const attrs = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes) const tag = this.options.openOnClick ? 'a' : 'span' - const href = typeof attrs.href === 'string' ? this.options.renderHref?.(attrs.href) : attrs.href - return [ - tag, - { - ...attrs, - href, - class: `${attrs.class} text-link hover:text-link-hover`, - }, - 0, - ] + return [tag, buildRenderedLinkAttributes(attrs, this.options.renderHref), 0] }, addCommands() {