your personal website on atproto - mirror blento.app
at blog-embeds 155 lines 3.7 kB view raw
1import { Node, mergeAttributes } from '@tiptap/core'; 2import { SvelteNodeViewRenderer } from 'svelte-tiptap'; 3import { matchEmbed } from '$lib/embeds'; 4import EmbedNodeComponent from './EmbedNodeComponent.svelte'; 5import { Plugin, PluginKey } from '@tiptap/pm/state'; 6 7declare module '@tiptap/core' { 8 interface Commands<ReturnType> { 9 embed: { 10 setEmbed: (options: { url: string }) => ReturnType; 11 }; 12 } 13} 14 15export const EmbedNode = Node.create({ 16 name: 'embed', 17 group: 'block', 18 atom: true, 19 draggable: true, 20 selectable: true, 21 inline: false, 22 23 addAttributes() { 24 return { 25 url: { default: null }, 26 embedType: { default: null }, 27 embedData: { default: null } 28 }; 29 }, 30 31 addCommands() { 32 return { 33 setEmbed: 34 ({ url }) => 35 ({ commands }) => { 36 const match = matchEmbed(url); 37 if (!match) return false; 38 return commands.insertContent({ 39 type: this.name, 40 attrs: { 41 url, 42 embedType: match.type, 43 embedData: JSON.stringify(match) 44 } 45 }); 46 } 47 }; 48 }, 49 50 parseHTML() { 51 return [{ tag: 'div[data-type="embed"]' }]; 52 }, 53 54 renderHTML({ HTMLAttributes }) { 55 return ['div', mergeAttributes({ 'data-type': 'embed' }, HTMLAttributes)]; 56 }, 57 58 addNodeView() { 59 return SvelteNodeViewRenderer(EmbedNodeComponent); 60 }, 61 62 // Markdown integration for @tiptap/markdown 63 // These fields are read by the Markdown extension via getExtensionField() 64 65 markdownTokenName: 'embedUrl', 66 67 markdownTokenizer: { 68 name: 'embedUrl', 69 level: 'block' as const, 70 tokenize(src: string) { 71 const match = src.match(/^(https?:\/\/\S+)\s*(?:\n|$)/); 72 if (!match) return undefined; 73 74 const url = match[1]; 75 const embedMatch = matchEmbed(url); 76 if (!embedMatch) return undefined; 77 78 return { 79 type: 'embedUrl', 80 raw: match[0], 81 url, 82 embedType: embedMatch.type, 83 embedData: embedMatch 84 }; 85 } 86 }, 87 88 parseMarkdown( 89 token: { url: string; embedType: string; embedData: Record<string, unknown> }, 90 helpers: { createNode: (type: string, attrs: Record<string, unknown>) => unknown } 91 ) { 92 return helpers.createNode('embed', { 93 url: token.url, 94 embedType: token.embedType, 95 embedData: JSON.stringify(token.embedData) 96 }); 97 }, 98 99 renderMarkdown(node: { attrs?: { url?: string } }) { 100 return (node.attrs?.url ?? '') + '\n'; 101 }, 102 103 addProseMirrorPlugins() { 104 const nodeType = this.type; 105 106 return [ 107 new Plugin({ 108 key: new PluginKey('embed-auto-detect'), 109 appendTransaction(transactions, _oldState, newState) { 110 const docChanged = transactions.some((tr) => tr.docChanged); 111 if (!docChanged) return null; 112 113 const tr = newState.tr; 114 let modified = false; 115 116 newState.doc.descendants((node, pos) => { 117 if (modified) return false; 118 if (node.type.name !== 'paragraph') return; 119 if (node.childCount !== 1) return; 120 121 const child = node.firstChild; 122 if (!child || !child.isText) return; 123 124 const text = child.text?.trim(); 125 if (!text) return; 126 127 // Check if the text (possibly with a link mark) is a bare URL 128 const urlMatch = text.match(/^(https?:\/\/\S+)$/); 129 if (!urlMatch) return; 130 131 const url = urlMatch[1]; 132 const embed = matchEmbed(url); 133 if (!embed) return; 134 135 // Only convert when cursor is NOT inside this paragraph 136 const sel = newState.selection; 137 const nodeEnd = pos + node.nodeSize; 138 if (sel.from >= pos && sel.from <= nodeEnd) return; 139 140 const embedNode = nodeType.create({ 141 url, 142 embedType: embed.type, 143 embedData: JSON.stringify(embed) 144 }); 145 146 tr.replaceWith(pos, nodeEnd, embedNode); 147 modified = true; 148 }); 149 150 return modified ? tr : null; 151 } 152 }) 153 ]; 154 } 155});