Extract parse/renderHtmlEmbedHeight and test: '300'->300, absent->null,
'abc'->null (pins the NaN guard), '120px'->120; render 120->data-height, null/0->{}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
180 lines
5.7 KiB
TypeScript
180 lines
5.7 KiB
TypeScript
import { Node, mergeAttributes } from "@tiptap/core";
|
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
|
|
export interface HtmlEmbedOptions {
|
|
HTMLAttributes: Record<string, any>;
|
|
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<ReturnType> {
|
|
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<string, never> {
|
|
return height ? { "data-height": String(height) } : {};
|
|
}
|
|
|
|
export const HtmlEmbed = Node.create<HtmlEmbedOptions>({
|
|
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);
|
|
},
|
|
});
|