Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions frontend/packages/editor/src/embed-block.parseHTML.test.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
]),
)
})
})
7 changes: 7 additions & 0 deletions frontend/packages/editor/src/embed-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
},
],
})

Expand Down
25 changes: 25 additions & 0 deletions frontend/packages/editor/src/mentions-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
]),
)
})
})
18 changes: 16 additions & 2 deletions frontend/packages/editor/src/mentions-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When copy pasting a mention in other platforms other than seed, the pasted text will be a raw hm link. Is this intended?

return link || ''
}

/** Creates the TipTap Node for rendering inline-embed mentions in the document. */
export function createInlineEmbedNode() {
const InlineEmbedNode = Node.create({
Expand All @@ -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')
Expand All @@ -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')
Expand Down
50 changes: 50 additions & 0 deletions frontend/packages/editor/src/ssr-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand All @@ -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('<a class="ssr-card" data-content-type="embed" data-url="hm://uid1/docs/page"')
expect(html).not.toContain('href="hm://')
})

it('preserves embed block metadata even when no SSR card data is available', () => {
const html = renderDocumentToHTML(
[
{
block: {
id: 'block-1',
type: 'Embed',
text: '',
link: 'hm://uid1/docs/page',
attributes: {view: 'Card'},
annotations: [],
},
children: [],
},
] as HMBlockNode[],
{},
)

expect(html).toContain('data-url="hm://uid1/docs/page"')
expect(html).toContain('data-view="Card"')
expect(html).toContain('data-content-type="embed"')
})
})
35 changes: 28 additions & 7 deletions frontend/packages/editor/src/ssr-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,15 +174,19 @@ function renderBlockContent(

case 'Embed': {
const link = block.link || ''
const view = block.attributes?.view || 'Content'
const dataUrlAttr = link ? ` data-url="${esc(link)}"` : ''
const dataViewAttr = ` data-view="${esc(view)}"`
const embedData = link ? embeds[link] : undefined
if (embedData?.title) {
return `<div class="blockContent" data-content-type="embed">${renderEmbedCard(
return `<div class="blockContent" data-content-type="embed"${dataUrlAttr}${dataViewAttr}>${renderEmbedCard(
embedData,
link,
view,
renderHref,
)}</div>`
}
return `<div class="blockContent" data-content-type="embed"><div class="ssr-embed-block"></div></div>`
return `<div class="blockContent" data-content-type="embed"${dataUrlAttr}${dataViewAttr}><div class="ssr-embed-block"></div></div>`
}

case 'WebEmbed':
Expand Down Expand Up @@ -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) {
Expand All @@ -307,6 +312,7 @@ function renderAnnotatedText(
break
case 'Embed':
isInlineEmbed = true
embedHref = (ann as any).link || ''
break
case 'Range':
break
Expand Down Expand Up @@ -341,20 +347,33 @@ function renderAnnotatedText(
}
}

if (linkHref != null) {
html = `<a class="link" href="${esc(resolveHref(linkHref, renderHref))}">${html}</a>`
const rawHref = embedHref ?? linkHref
if (rawHref != null) {
const inlineEmbedAttr = isInlineEmbed ? ` data-inline-embed="${esc(embedHref || rawHref)}"` : ''
const inlineEmbedClass = isInlineEmbed ? ' inline-embed' : ''
html = `<a class="link${inlineEmbedClass}" href="${esc(resolveHref(rawHref, renderHref))}" data-hm-link="${esc(
rawHref,
)}"${inlineEmbedAttr}>${html}</a>`

return html
}

if (isInlineEmbed) {
html = `<span class="inline-embed">${html}</span>`
const inlineEmbedAttr = embedHref ? ` data-inline-embed="${esc(embedHref)}"` : ''
html = `<span class="inline-embed"${inlineEmbedAttr}>${html}</span>`
}

return html
})
.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)) : ''
Expand All @@ -364,7 +383,9 @@ function renderEmbedCard(data: SSREmbedData, link: string, renderHref: SSRRender
: ''

return (
`<a class="ssr-card" ${href ? `href="${href}"` : ''}>` +
`<a class="ssr-card" data-content-type="embed" data-url="${esc(link)}" data-view="${esc(view)}" ${
href ? `href="${href}"` : ''
}>` +
imageHtml +
`<div class="ssr-card-content">` +
`<div class="ssr-card-title">${title}</div>` +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
48 changes: 48 additions & 0 deletions frontend/packages/editor/src/tiptap-extension-link/link.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading