Files
gitmost/packages/editor-ext/src/lib/html-embed/html-embed.ts
claude code agent 227 ba37907f50 test(editor-ext): cover the html-embed height attr codec (#98, #99)
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>
2026-06-21 05:52:15 +03:00

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);
},
});