import { Node, mergeAttributes } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; export interface HtmlEmbedOptions { HTMLAttributes: Record; view: any; } export interface HtmlEmbedAttributes { // Raw HTML/CSS/JS string rendered inside a sandboxed iframe by the NodeView. source?: string; // Fixed iframe height in pixels. null/absent => auto-resize via postMessage. height?: number | null; } declare module "@tiptap/core" { interface Commands { htmlEmbed: { setHtmlEmbed: (attributes?: HtmlEmbedAttributes) => ReturnType; }; } } /** * Encode the raw source to base64 for the `data-source` attribute. * * The source is arbitrary HTML/CSS/JS. Storing it raw inside an HTML attribute * would (a) require heavy escaping and (b) risk the parser interpreting markup * inside the attribute. Base64 makes the round-trip HTML <-> ProseMirror JSON * lossless and keeps the markup inert while it sits in the attribute. * * `encodeURIComponent`/`decodeURIComponent` wrap btoa/atob so that non-Latin1 * (UTF-8) characters survive the base64 step. */ export function encodeHtmlEmbedSource(source: string): string { if (!source) return ""; try { if (typeof btoa === "function") { return btoa(encodeURIComponent(source)); } // Node fallback (server-side schema parsing has no global btoa). return Buffer.from(encodeURIComponent(source), "utf-8").toString("base64"); } catch { // On an encoding error we drop to "" rather than returning the raw source. // Returning raw markup here is NOT a safe fallback: the value is stored in // the `data-source` attribute and read back through decodeHtmlEmbedSource, // which base64-decodes it — raw (un-encoded) HTML would make atob/ // decodeURIComponent throw and decode to "" anyway, and an un-encoded value // sitting in the attribute defeats the inert-storage guarantee (it could // become an injection vector). So "" is the correct, decode-symmetric // failure mode. In practice this is essentially unreachable: btoa runs on // the output of encodeURIComponent, which is always Latin1-safe ASCII. return ""; } } export function decodeHtmlEmbedSource(encoded: string): string { if (!encoded) return ""; try { if (typeof atob === "function") { return decodeURIComponent(atob(encoded)); } // Node fallback. return decodeURIComponent( Buffer.from(encoded, "base64").toString("utf-8"), ); } catch { return ""; } } /** * Parse the `data-height` attribute value into a fixed iframe height in px. * * Returns null (auto-resize) when the value is absent, empty, or non-numeric. * A non-numeric `data-height` (e.g. a crafted/corrupted import) must NOT become * NaN: NaN is typeof "number" and would disable auto-resize and yield an * unclamped iframe height downstream. The Number.isFinite guard pins that fix. */ export function parseHtmlEmbedHeight(value: string | null): number | null { if (!value) return null; const n = parseInt(value, 10); return Number.isFinite(n) ? n : null; } /** * Render a fixed height back to a `data-height` attribute. A null/0/absent * height means auto-resize, so no attribute is emitted. */ export function renderHtmlEmbedHeight( height: number | null | undefined, ): { "data-height": string } | Record { return height ? { "data-height": String(height) } : {}; } export const HtmlEmbed = Node.create({ name: "htmlEmbed", inline: false, group: "block", // atom + isolating: the node has no editable ProseMirror children; its body // is the opaque `source` string rendered by the NodeView. atom: true, isolating: true, defining: true, draggable: true, addOptions() { return { HTMLAttributes: {}, view: null, }; }, addAttributes() { return { source: { default: "", // Decode the base64 payload back to the raw source on parse. parseHTML: (element) => decodeHtmlEmbedSource(element.getAttribute("data-source") || ""), // Encode the raw source to base64 on render so it round-trips losslessly // through the HTML <-> JSON conversions used by export/import/collab. renderHTML: (attributes: HtmlEmbedAttributes) => ({ "data-source": encodeHtmlEmbedSource(attributes.source || ""), }), }, // Fixed iframe height in px. null/absent => auto-resize on the client. height: { default: null, parseHTML: (el) => parseHtmlEmbedHeight(el.getAttribute("data-height")), renderHTML: (attrs: HtmlEmbedAttributes) => renderHtmlEmbedHeight(attrs.height), }, }; }, parseHTML() { return [ { tag: `div[data-type="${this.name}"]`, }, ]; }, renderHTML({ HTMLAttributes }) { // The static HTML representation is just a marker div carrying the encoded // source. The actual raw markup is NOT expanded here on purpose: the static // generateHTML output (used for previews, search indexing, exports) must not // itself become an injection vector. Only the client NodeView expands and // executes the source. return [ "div", mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, HTMLAttributes, ), ]; }, addCommands() { return { setHtmlEmbed: (attrs: HtmlEmbedAttributes) => ({ commands }) => { return commands.insertContent({ type: this.name, attrs: attrs, }); }, }; }, addNodeView() { // Force the react node view to render immediately using flush sync. this.editor.isInitialized = true; return ReactNodeViewRenderer(this.options.view); }, });