jedmund-svelte/src/lib/components/edra/extensions/url-embed/UrlEmbed.ts
2025-11-24 05:41:12 +00:00

272 lines
6.3 KiB
TypeScript

import { Node, mergeAttributes } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
export interface UrlEmbedOptions {
HTMLAttributes: Record<string, unknown>
onShowDropdown?: (pos: number, url: string) => void
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
urlEmbed: {
/**
* Set a URL embed
*/
setUrlEmbed: (options: {
url: string
title?: string
description?: string
image?: string
favicon?: string
siteName?: string
}) => ReturnType
/**
* Insert a URL embed placeholder
*/
insertUrlEmbedPlaceholder: () => ReturnType
/**
* Convert a link at position to URL embed
*/
convertLinkToEmbed: (pos: number) => ReturnType
}
}
}
export const UrlEmbed = Node.create<UrlEmbedOptions>({
name: 'urlEmbed',
group: 'block',
atom: true,
addOptions() {
return {
HTMLAttributes: {}
}
},
addAttributes() {
return {
url: {
default: null
},
title: {
default: null
},
description: {
default: null
},
image: {
default: null
},
favicon: {
default: null
},
siteName: {
default: null
}
}
},
parseHTML() {
return [
{
tag: 'div[data-url-embed]'
}
]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes({ 'data-url-embed': '' }, this.options.HTMLAttributes, HTMLAttributes)
]
},
addCommands() {
return {
setUrlEmbed:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options
})
},
insertUrlEmbedPlaceholder:
() =>
({ commands }) => {
return commands.insertContent({
type: 'urlEmbedPlaceholder'
})
},
convertLinkToEmbed:
(pos) =>
({ state, chain }) => {
const { doc } = state
// Find the link mark at the given position
const $pos = doc.resolve(pos)
const marks = $pos.marks()
const linkMark = marks.find((mark) => mark.type.name === 'link')
if (!linkMark) return false
const url = linkMark.attrs.href
if (!url) return false
// Find the complete range of text with this link mark
let from = pos
let to = pos
// Walk backwards to find the start
doc.nodesBetween(Math.max(0, pos - 300), pos, (node, nodePos) => {
if (
node.isText &&
node.marks.some((m) => m.type.name === 'link' && m.attrs.href === url)
) {
from = nodePos
}
})
// Walk forwards to find the end
doc.nodesBetween(pos, Math.min(doc.content.size, pos + 300), (node, nodePos) => {
if (
node.isText &&
node.marks.some((m) => m.type.name === 'link' && m.attrs.href === url)
) {
to = nodePos + node.nodeSize
}
})
// Use Tiptap's chain commands to replace content
return chain()
.focus()
.deleteRange({ from, to })
.insertContent([
{
type: 'urlEmbedPlaceholder',
attrs: { url }
},
{
type: 'paragraph'
}
])
.run()
}
}
},
addProseMirrorPlugins() {
const options = this.options
return [
new Plugin({
key: new PluginKey('urlEmbedPaste'),
state: {
init: () => ({ lastPastedUrl: null, lastPastedPos: null }),
apply: (tr, value) => {
// Clear state if document changed significantly
if (tr.docChanged && tr.steps.length > 0) {
const meta = tr.getMeta('urlEmbedPaste')
if (meta) {
return meta
}
return { lastPastedUrl: null, lastPastedPos: null }
}
return value
}
},
props: {
handlePaste: (view, event) => {
const { clipboardData } = event
if (!clipboardData) return false
const text = clipboardData.getData('text/plain')
const html = clipboardData.getData('text/html')
// Check if it's a plain text paste
if (text && !html) {
// Simple URL regex check
const urlRegex =
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/
if (urlRegex.test(text.trim())) {
// It's a URL, let it paste as a link naturally (don't prevent default)
// But track it so we can show dropdown after
const pastedUrl = text.trim()
// Get the position before paste
const beforePos = view.state.selection.from
setTimeout(() => {
const { state } = view
const { doc } = state
// Find the link that was just inserted
// Start from where we were before paste
let linkStart = -1
// Search for the link in a reasonable range
for (
let pos = beforePos;
pos < Math.min(doc.content.size, beforePos + pastedUrl.length + 10);
pos++
) {
try {
const $pos = doc.resolve(pos)
const marks = $pos.marks()
const linkMark = marks.find(
(m) => m.type.name === 'link' && m.attrs.href === pastedUrl
)
if (linkMark) {
// Found the link, now find its boundaries
linkStart = pos
// Find the end of the link
for (
let endPos = pos;
endPos < Math.min(doc.content.size, pos + pastedUrl.length + 5);
endPos++
) {
const $endPos = doc.resolve(endPos)
const hasLink = $endPos
.marks()
.some((m) => m.type.name === 'link' && m.attrs.href === pastedUrl)
if (!hasLink) {
break
}
}
break
}
} catch (_e) {
// Position might be invalid, continue
}
}
if (linkStart !== -1) {
// Store the pasted URL info with correct position
const tr = state.tr.setMeta('urlEmbedPaste', {
lastPastedUrl: pastedUrl,
lastPastedPos: linkStart
})
view.dispatch(tr)
// Notify the editor to show dropdown
if (options.onShowDropdown) {
options.onShowDropdown(linkStart, pastedUrl)
// Ensure editor maintains focus
view.focus()
}
}
}, 100) // Small delay to let the link paste naturally
}
}
return false
}
}
})
]
}
})