your personal website on atproto - mirror
blento.app
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});