Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions apps/web/src/components/admin/blog-editor/embed-block.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { CodeIcon, PencilIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";

import { EmbedBlockNode as BaseEmbedBlockNode } from "@hypr/tiptap/shared";

function EmbedBlockNodeView({
node,
updateAttributes,
selected,
deleteNode,
}: NodeViewProps) {
const [isEditing, setIsEditing] = useState(!node.attrs.content);
const [inputValue, setInputValue] = useState(node.attrs.content || "");
const textareaRef = useRef<HTMLTextAreaElement>(null);

useEffect(() => {
setInputValue(node.attrs.content || "");
}, [node.attrs.content]);

useEffect(() => {
if (isEditing && textareaRef.current) {
textareaRef.current.focus();
const el = textareaRef.current;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
}, [isEditing]);

const handleSubmit = useCallback(() => {
const code = inputValue.trim();
if (!code) return;
updateAttributes({ content: code });
setIsEditing(false);
}, [inputValue, updateAttributes]);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
if (e.key === "Escape") {
e.preventDefault();
if (node.attrs.content) {
setInputValue(node.attrs.content);
setIsEditing(false);
}
}
if (e.key === "Backspace" && !inputValue && !node.attrs.content) {
e.preventDefault();
deleteNode();
}
},
[handleSubmit, node.attrs.content, inputValue, deleteNode],
);

if (isEditing || !node.attrs.content) {
return (
<NodeViewWrapper>
<div className="my-4 border border-dashed border-neutral-300 rounded-md bg-neutral-50 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 bg-neutral-100 border-b border-neutral-200">
<CodeIcon className="size-3.5 text-neutral-500" />
<span className="text-xs text-neutral-500 font-medium">
Embed Block
</span>
<span className="text-xs text-neutral-400">
Paste iframe, JSX, or HTML
</span>
</div>
<div className="p-3">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
placeholder={
'<iframe src="..." />\n<CtaCard />\n<Callout type="note">...</Callout>'
}
className="w-full px-3 py-2 text-sm font-mono bg-white border border-neutral-200 rounded resize-none focus:outline-none focus:border-blue-500 min-h-[80px]"
onKeyDown={handleKeyDown}
rows={3}
/>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-neutral-400">
{navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to
save · Esc to cancel
</span>
<div className="flex gap-2">
{node.attrs.content && (
<button
type="button"
onClick={() => {
setInputValue(node.attrs.content);
setIsEditing(false);
}}
className="px-3 py-1.5 text-xs text-neutral-600 hover:bg-neutral-200 rounded"
>
Cancel
</button>
)}
<button
type="button"
onClick={handleSubmit}
disabled={!inputValue.trim()}
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Save
</button>
</div>
</div>
</div>
</div>
</NodeViewWrapper>
);
}

const preview = getPreviewLabel(node.attrs.content);

return (
<NodeViewWrapper>
<div
className={[
"my-4 border rounded-md overflow-hidden group",
selected ? "border-blue-500" : "border-neutral-200",
].join(" ")}
>
<div className="flex items-center justify-between px-3 py-2 bg-neutral-50 border-b border-neutral-200">
<div className="flex items-center gap-2">
<CodeIcon className="size-3.5 text-neutral-500" />
<span className="text-xs text-neutral-600 font-medium">
{preview.label}
</span>
{preview.detail && (
<span className="text-xs text-neutral-400">{preview.detail}</span>
)}
</div>
<button
type="button"
onClick={() => setIsEditing(true)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-neutral-200"
title="Edit embed code"
>
<PencilIcon className="size-3.5 text-neutral-500" />
</button>
</div>
<div className="p-3 bg-white">
<pre className="text-xs font-mono text-neutral-600 whitespace-pre-wrap break-all leading-relaxed max-h-[200px] overflow-y-auto">
{node.attrs.content}
</pre>
</div>
</div>
</NodeViewWrapper>
);
}

function getPreviewLabel(content: string): {
label: string;
detail?: string;
} {
const trimmed = content.trim();

const iframeMatch = trimmed.match(/<iframe[^>]*src="([^"]*)"[^>]*>/i);
if (iframeMatch) {
try {
const url = new URL(iframeMatch[1]);
return { label: "iframe", detail: url.hostname };
} catch {
return { label: "iframe" };
}
}

const jsxMatch = trimmed.match(/^<([A-Z][A-Za-z0-9]*)/);
if (jsxMatch) {
return { label: "JSX Component", detail: `<${jsxMatch[1]} />` };
}

const htmlMatch = trimmed.match(/^<([a-z][a-z0-9-]*)/i);
if (htmlMatch) {
return { label: "HTML", detail: `<${htmlMatch[1]}>` };
}

return { label: "Embed Block" };
}

export const EmbedBlockNode = BaseEmbedBlockNode.extend({
addNodeView() {
return ReactNodeViewRenderer(EmbedBlockNodeView);
},
});
2 changes: 2 additions & 0 deletions apps/web/src/components/admin/blog-editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import "@hypr/tiptap/styles.css";

import "./blog-editor.css";
import { ClipNode } from "./clip-embed";
import { EmbedBlockNode } from "./embed-block";
import { GoogleDocsImport } from "./google-docs-import";
import { BlogImage } from "./image-with-alt";
import { Toolbar } from "./toolbar";
Expand Down Expand Up @@ -76,6 +77,7 @@ const BlogEditor = forwardRef<{ editor: TiptapEditor | null }, BlogEditorProps>(
),
Markdown,
ClipNode,
EmbedBlockNode,
],
[onImageUpload],
);
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/admin/blog-editor/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
CodeIcon,
CodeXmlIcon,
FilmIcon,
Heading1Icon,
Heading2Icon,
Expand Down Expand Up @@ -439,6 +440,21 @@ export function Toolbar({
)}
</div>

<ToolbarDivider />

<ToolbarButton
onClick={() => {
editor
.chain()
.focus()
.insertContent({ type: "embedBlock", attrs: { content: "" } })
.run();
}}
title="Insert Embed Block"
>
<CodeXmlIcon className="size-4" />
</ToolbarButton>

<div className="flex-1" />

<ToolbarButton
Expand Down
85 changes: 85 additions & 0 deletions packages/tiptap/src/shared/embed-block.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";

const IFRAME_REGEX = /<iframe\s[^>]*>/i;
const JSX_SELF_CLOSING_REGEX = /^<([A-Z][A-Za-z0-9]*)\s*[^>]*\/>\s*$/;
const JSX_BLOCK_REGEX =
/^<([A-Z][A-Za-z0-9]*)[\s>][\s\S]*<\/\1>\s*$|^<([A-Z][A-Za-z0-9]*)\s*[^>]*\/>\s*$/;
const HTML_BLOCK_REGEX = /^<([a-z][a-z0-9-]*)[\s>][\s\S]*<\/\1>\s*$/i;

export function looksLikeEmbedCode(text: string): boolean {
const trimmed = text.trim();
if (IFRAME_REGEX.test(trimmed)) return true;
if (JSX_SELF_CLOSING_REGEX.test(trimmed)) return true;
if (JSX_BLOCK_REGEX.test(trimmed)) return true;
if (HTML_BLOCK_REGEX.test(trimmed)) return true;
return false;
}

export const EmbedBlockNode = Node.create({
name: "embedBlock",
group: "block",
atom: true,

addAttributes() {
return {
content: { default: "" },
};
},

parseHTML() {
return [
{
tag: 'div[data-type="embed-block"]',
getAttrs: (dom) => ({
content: (dom as HTMLElement).getAttribute("data-content") || "",
}),
},
];
},

renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, {
"data-type": "embed-block",
"data-content": HTMLAttributes.content,
}),
];
},

addProseMirrorPlugins() {
const nodeType = this.type;
return [
new Plugin({
key: new PluginKey("embedBlockPaste"),
props: {
handlePaste(view, event) {
const text = event.clipboardData?.getData("text/plain");
if (!text) return false;

if (!looksLikeEmbedCode(text)) return false;

const node = nodeType.create({ content: text.trim() });
const { tr } = view.state;
tr.replaceSelectionWith(node);
view.dispatch(tr);
return true;
},
},
}),
];
},

parseMarkdown: (token: Record<string, string>) => {
const raw = token.raw || token.text || "";
return {
type: "embedBlock",
attrs: { content: raw.trim() },
};
},

renderMarkdown: (node: { attrs?: { content?: string } }) => {
return `${node.attrs?.content || ""}\n`;
},
});
1 change: 1 addition & 0 deletions packages/tiptap/src/shared/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./animation";
export * from "./clip";
export * from "./embed-block";
export * from "./extensions";
export * from "./hashtag";
export * from "./schema-validation";
Expand Down