import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { getUrls, debounce } from './helpers/index.js'
const TAG_NAME = 'link-preview'
const MAX_PREVIEW_ITEMS = 5
export const handleUpdate = debounce((editor, extension) => {
const doc = editor.getJSON()
const state = extension.storage
const urls = getUrls(doc, (href) => {
const cmd = editor.chain().focus().extendMarkRange('link')
if (href) cmd.setLink({ href }).run()
else cmd.unsetLink().run()
})
for (const url of [...urls].slice(0, MAX_PREVIEW_ITEMS)) {
if (state.active.has(url) || state.failed.has(url)) continue
editor.commands.setLinkPreview({ url })
state.active.add(url)
}
for (const url of state.active) {
if (!state.failed.has(url) && urls.has(url)) continue
editor.commands.removeLinkPreview({ url })
state.active.delete(url)
}
}, 600)
export const LinkPreview = Node.create({
name: TAG_NAME,
group: 'block',
atom: true,
addStorage () {
return {
active: new Set(),
failed: new Set()
}
},
addAttributes () {
return {
url: {
default: null
},
title: {
default: null
},
description: {
default: null
},
image: {
default: null
}
}
},
parseHTML () {
return [{ tag: TAG_NAME }]
},
renderHTML ({ HTMLAttributes }) {
return [TAG_NAME, mergeAttributes(HTMLAttributes)]
},
addCommands () {
return {
saveFile: (url, file) => () => {
if (file) this.options.store.set(url, file)
return true
},
removeFile: (url) => () => {
this.options.store.del(url)
return true
},
setLinkPreview:
({ url }) =>
({ state, dispatch }) => {
const { tr, schema, doc } = state
const node = schema.nodes[TAG_NAME].create({ url })
const transaction = tr.insert(doc.content.size, node)
if (dispatch && transaction.docChanged) {
dispatch(transaction)
}
return true
},
removeLinkPreview:
({ url }) =>
({ state, dispatch }) => {
const { tr, doc } = state
let transaction = tr
let removed = false
doc.descendants((node, pos) => {
if (node.type.name === TAG_NAME && node.attrs.url === url) {
transaction = transaction.delete(pos, pos + node.nodeSize)
removed = true
}
})
if (dispatch && removed) {
dispatch(transaction)
}
return true
}
}
},
addNodeView () {
return ReactNodeViewRenderer(this.options.Component, { className: 'inline-block' })
},
onUpdate ({ editor }) {
handleUpdate(editor, this)
}
})