Skip to content

Commit f12bddf

Browse files
committed
Embed block addition
1 parent 2f6264d commit f12bddf

5 files changed

Lines changed: 555 additions & 33 deletions

File tree

frontend/src/components/Editor.tsx

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const insertChatBlockItem = (editor: BlockNoteEditor<any, any, any>) => ({
7373
);
7474
},
7575
aliases: ["chat", "message", "discussion", "conversation"],
76-
group: "Other",
76+
group: "Advanced",
7777
icon: "💬" as any,
7878
subtext: "Start a collaborative chat discussion",
7979
});
@@ -98,17 +98,55 @@ const insertMermaidBlockItem = (editor: BlockNoteEditor<any, any, any>) => ({
9898
);
9999
},
100100
aliases: ["mermaid", "diagram", "flowchart", "chart", "graph"],
101-
group: "Other",
101+
group: "Advanced",
102102
icon: "📊" as any,
103103
subtext: "Create a Mermaid flowchart or diagram",
104104
});
105105

106+
// Custom slash menu item for embed block (YouTube, Vimeo, Spotify, etc.)
107+
const insertEmbedBlockItem = (editor: BlockNoteEditor<any, any, any>) => ({
108+
title: "Embed",
109+
onItemClick: () => {
110+
const currentBlock = editor.getTextCursorPosition().block;
111+
editor.insertBlocks(
112+
[{
113+
type: "embed" as any,
114+
props: {
115+
url: "",
116+
width: 640,
117+
height: 360,
118+
caption: "",
119+
}
120+
}],
121+
currentBlock.id,
122+
"after"
123+
);
124+
},
125+
aliases: ["embed", "youtube", "video", "vimeo", "spotify", "twitch", "loom", "figma"],
126+
group: "Media",
127+
icon: "🔗" as any,
128+
subtext: "Embed YouTube, Vimeo, Spotify, and more",
129+
});
130+
106131
// Get all slash menu items including custom blocks
107-
const getCustomSlashMenuItems = (editor: BlockNoteEditor<any, any, any>) => [
108-
...getDefaultReactSlashMenuItems(editor),
109-
insertChatBlockItem(editor),
110-
insertMermaidBlockItem(editor),
111-
];
132+
const getCustomSlashMenuItems = (editor: BlockNoteEditor<any, any, any>) => {
133+
const defaultItems = getDefaultReactSlashMenuItems(editor);
134+
135+
// Find index of Table (last item in Advanced group) to insert Chat and Mermaid after it
136+
const tableIndex = defaultItems.findIndex(item => item.title === "Table");
137+
if (tableIndex !== -1) {
138+
defaultItems.splice(tableIndex + 1, 0, insertChatBlockItem(editor), insertMermaidBlockItem(editor));
139+
}
140+
141+
// Find index of File (last item in Media group) to insert Embed after it
142+
// Note: indices shifted after previous splice, so we search again
143+
const fileIndex = defaultItems.findIndex(item => item.title === "File");
144+
if (fileIndex !== -1) {
145+
defaultItems.splice(fileIndex + 1, 0, insertEmbedBlockItem(editor));
146+
}
147+
148+
return defaultItems;
149+
};
112150

113151
// Filter slash menu items based on query
114152
const filterSlashMenuItems = (
@@ -176,13 +214,13 @@ export function Editor({ docId }: EditorProps) {
176214
schema, // Custom schema with syntax highlighting
177215
collaboration: provider
178216
? {
179-
fragment: doc.getXmlFragment("document"),
180-
user: {
181-
name: userInfo?.name || "Anonymous",
182-
color: userColor,
183-
},
184-
provider,
185-
}
217+
fragment: doc.getXmlFragment("document"),
218+
user: {
219+
name: userInfo?.name || "Anonymous",
220+
color: userColor,
221+
},
222+
provider,
223+
}
186224
: undefined,
187225
},
188226
[provider, mode] // Recreate editor when provider or mode changes
@@ -313,12 +351,12 @@ export function Editor({ docId }: EditorProps) {
313351
try {
314352
// Convert markdown to BlockNote blocks
315353
const blocks = await editor.tryParseMarkdownToBlocks(markdown);
316-
354+
317355
// Replace the default empty paragraph with imported content
318356
editor.replaceBlocks(editor.document, blocks);
319-
357+
320358
console.log('✓ Successfully imported content');
321-
359+
322360
// Clean up URL parameter
323361
window.history.replaceState({}, '', `/${docId}`);
324362
} catch (error) {
@@ -424,9 +462,9 @@ export function Editor({ docId }: EditorProps) {
424462
color: "var(--page-text)",
425463
}}
426464
>
427-
<span className="mobile-logo-bracket" style={{ fontWeight: 800, fontSize:"28px", color: "#646cff"}}>[</span>
465+
<span className="mobile-logo-bracket" style={{ fontWeight: 800, fontSize: "28px", color: "#646cff" }}>[</span>
428466
<span>MarkDoc </span>
429-
<span className="mobile-logo-bracket" style={{ fontWeight: 800, fontSize:"28px", color: "#646cff"}}>]</span>
467+
<span className="mobile-logo-bracket" style={{ fontWeight: 800, fontSize: "28px", color: "#646cff" }}>]</span>
430468
</h1>
431469
<p
432470
style={{
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/**
2+
* EmbedBlock Component
3+
*
4+
* A BlockNote block for embedding external content like YouTube, Vimeo, Spotify, etc.
5+
* Features:
6+
* - Compact inline URL input
7+
* - Responsive iframe embedding with auto-sizing
8+
* - Support for multiple platforms
9+
*/
10+
11+
import { useState, useEffect, useCallback } from 'react';
12+
import { useBlockNoteEditor } from '@blocknote/react';
13+
import { parseEmbedUrl, getEmbedTypeName, type EmbedInfo } from '../../lib/embedParser';
14+
15+
interface EmbedBlockProps {
16+
block: {
17+
id: string;
18+
type: string;
19+
props: {
20+
url: string;
21+
width: number;
22+
height: number;
23+
caption: string;
24+
};
25+
};
26+
}
27+
28+
// Get optimal dimensions for each embed type
29+
function getEmbedDimensions(type: EmbedInfo['type']): { aspectRatio?: string; height?: string } {
30+
switch (type) {
31+
case 'spotify':
32+
return { height: '152px' };
33+
case 'soundcloud':
34+
return { height: '166px' };
35+
case 'twitter':
36+
return { height: '400px' };
37+
default:
38+
return { aspectRatio: '16 / 9' };
39+
}
40+
}
41+
42+
export function EmbedBlock({ block }: EmbedBlockProps) {
43+
const editor = useBlockNoteEditor();
44+
const [inputUrl, setInputUrl] = useState(block.props.url || '');
45+
const [embedInfo, setEmbedInfo] = useState<EmbedInfo | null>(null);
46+
const [error, setError] = useState<string | null>(null);
47+
48+
useEffect(() => {
49+
if (block.props.url) {
50+
const info = parseEmbedUrl(block.props.url);
51+
if (info) {
52+
setEmbedInfo(info);
53+
setError(null);
54+
setInputUrl(block.props.url);
55+
} else {
56+
setEmbedInfo(null);
57+
setError('Unsupported URL');
58+
}
59+
} else {
60+
setEmbedInfo(null);
61+
setError(null);
62+
}
63+
}, [block.props.url]);
64+
65+
const handleSubmit = useCallback((e?: React.FormEvent) => {
66+
e?.preventDefault();
67+
const trimmedUrl = inputUrl.trim();
68+
69+
if (!trimmedUrl) return;
70+
71+
const info = parseEmbedUrl(trimmedUrl);
72+
if (!info) {
73+
setError('Unsupported URL');
74+
return;
75+
}
76+
77+
editor.updateBlock(block.id, {
78+
type: 'embed' as any,
79+
props: { ...block.props, url: trimmedUrl },
80+
});
81+
82+
setEmbedInfo(info);
83+
setError(null);
84+
}, [editor, block.id, block.props, inputUrl]);
85+
86+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
87+
if (e.key === 'Enter' && !e.shiftKey) {
88+
e.preventDefault();
89+
handleSubmit();
90+
}
91+
}, [handleSubmit]);
92+
93+
const handleBlur = useCallback(() => {
94+
if (inputUrl.trim() && inputUrl !== block.props.url) {
95+
handleSubmit();
96+
}
97+
}, [inputUrl, block.props.url, handleSubmit]);
98+
99+
// URL input mode
100+
if (!embedInfo) {
101+
return (
102+
<div
103+
className="embed-block-input"
104+
style={{
105+
display: 'flex',
106+
alignItems: 'center',
107+
gap: '8px',
108+
padding: '8px 12px',
109+
border: `1px solid ${error ? '#ef4444' : 'var(--chat-border)'}`,
110+
borderRadius: '8px',
111+
backgroundColor: 'var(--chat-bg)',
112+
width: '70%',
113+
margin: '0 auto',
114+
}}
115+
>
116+
<span style={{ fontSize: '16px', opacity: 0.6 }}>🔗</span>
117+
<input
118+
type="url"
119+
value={inputUrl}
120+
onChange={(e) => {
121+
setInputUrl(e.target.value);
122+
setError(null);
123+
}}
124+
onKeyDown={handleKeyDown}
125+
onBlur={handleBlur}
126+
placeholder="Paste YouTube, Vimeo, Spotify, or other embed URL..."
127+
autoFocus
128+
style={{
129+
flex: 1,
130+
padding: '6px 0',
131+
fontSize: '14px',
132+
border: 'none',
133+
backgroundColor: 'transparent',
134+
color: 'var(--chat-text)',
135+
outline: 'none',
136+
}}
137+
/>
138+
{inputUrl && (
139+
<button
140+
type="button"
141+
onClick={() => handleSubmit()}
142+
style={{
143+
padding: '6px 14px',
144+
fontSize: '13px',
145+
fontWeight: 500,
146+
border: 'none',
147+
borderRadius: '6px',
148+
backgroundColor: '#646cff',
149+
color: 'white',
150+
cursor: 'pointer',
151+
whiteSpace: 'nowrap',
152+
}}
153+
>
154+
Embed
155+
</button>
156+
)}
157+
</div>
158+
);
159+
}
160+
161+
const dimensions = getEmbedDimensions(embedInfo.type);
162+
const containerMaxWidth = embedInfo.type === 'twitter' ? 550 : (block.props.width || 640);
163+
164+
return (
165+
<div
166+
className="embed-block"
167+
style={{
168+
width: '100%',
169+
maxWidth: containerMaxWidth,
170+
margin: '0 auto',
171+
}}
172+
>
173+
<div
174+
style={{
175+
position: 'relative',
176+
width: '100%',
177+
aspectRatio: dimensions.aspectRatio,
178+
height: dimensions.height,
179+
borderRadius: '12px',
180+
overflow: 'hidden',
181+
}}
182+
>
183+
<iframe
184+
src={embedInfo.embedUrl}
185+
title={`${getEmbedTypeName(embedInfo.type)} embed`}
186+
frameBorder="0"
187+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
188+
allowFullScreen
189+
style={{
190+
position: dimensions.aspectRatio ? 'absolute' : 'relative',
191+
top: 0,
192+
left: 0,
193+
width: '100%',
194+
height: '100%',
195+
border: 'none',
196+
borderRadius: '12px',
197+
}}
198+
/>
199+
</div>
200+
201+
{block.props.caption && (
202+
<p
203+
style={{
204+
marginTop: '8px',
205+
fontSize: '13px',
206+
color: 'var(--chat-text-secondary)',
207+
textAlign: 'center',
208+
fontStyle: 'italic',
209+
}}
210+
>
211+
{block.props.caption}
212+
</p>
213+
)}
214+
</div>
215+
);
216+
}

0 commit comments

Comments
 (0)