Files
gitmost/apps/client/src/features/editor/components/html-embed/html-embed-view.tsx
claude_code c596e17a40 fix(html-embed): correct stale iframe height and damp the resize loop
Address the non-test code-review findings on the htmlEmbed sandbox change
(test-coverage gaps are tracked in issue #99):

- html-embed-view: track the iframe's reported content height even while a
  fixed height is set, so clearing the height (fixed -> auto) without editing
  the source no longer leaves the frame pinned to the stale value. Derive the
  fixed-height predicate once; seed autoHeight to the default.
- html-embed-view: drop width/border from the iframe inline style (the
  .htmlEmbedFrame CSS class already provides them).
- html-embed-sandbox: coalesce height reports via requestAnimationFrame and
  skip <=1px deltas to damp the self-measure feedback loop; fix the misleading
  bootstrap comment.
- tracker-settings: add an aria-label to the snippet Textarea (a11y).
- CHANGELOG: note the removal of server-side role-based HTML-embed stripping.
2026-06-21 03:50:17 +03:00

216 lines
7.5 KiB
TypeScript

import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import clsx from "clsx";
import {
ActionIcon,
Button,
Group,
Modal,
NumberInput,
Text,
Textarea,
} from "@mantine/core";
import { IconCode, IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import classes from "./html-embed-view.module.css";
import {
buildSandboxSrcdoc,
canEdit as computeCanEdit,
HTML_EMBED_HEIGHT_MESSAGE,
shouldRender as computeShouldRender,
} from "./html-embed-sandbox.ts";
// Sane bounds for the auto-resized iframe so a runaway embed cannot blow up the
// page layout, and a sensible default before the first height message arrives.
const MIN_IFRAME_HEIGHT = 40;
const MAX_IFRAME_HEIGHT = 4000;
const DEFAULT_IFRAME_HEIGHT = 150;
// Clamp a reported/configured height into the sane iframe bounds.
const clampHeight = (h: number) =>
Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, h));
export default function HtmlEmbedView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected, updateAttributes, editor } = props;
const { source, height } = node.attrs as {
source: string;
height: number | null;
};
// The HTML embed renders inside a SANDBOXED iframe (no same-origin access), so
// the workspace toggle is a feature switch, not a security gate. When OFF (the
// default) we render a neutral placeholder in the editor and nothing else.
const workspace = useAtomValue(workspaceAtom);
const htmlEmbedEnabled = workspace?.settings?.htmlEmbed === true;
const shouldRender = computeShouldRender(
editor.isEditable,
htmlEmbedEnabled,
);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [draft, setDraft] = useState<string>(source || "");
const [draftHeight, setDraftHeight] = useState<number | "">(height ?? "");
// True when the author pinned an explicit height; otherwise we auto-resize to
// the iframe's reported content height.
const hasFixedHeight = typeof height === "number" && Number.isFinite(height);
// Auto-resize height tracked in state. Seeded to the default and updated from
// the iframe's postMessage reports (see effect below) regardless of mode, so
// switching a fixed-height embed back to auto immediately reflects the last
// reported content height instead of staying pinned to the old fixed value.
const [autoHeight, setAutoHeight] = useState<number>(DEFAULT_IFRAME_HEIGHT);
const srcdoc = useMemo(() => buildSandboxSrcdoc(source || ""), [source]);
// Auto-resize: accept height messages ONLY from this iframe's own content
// window. The sandboxed srcdoc has an opaque ("null") origin, so we cannot
// match by event.origin — we match by event.source instead. We track the
// reported height even while a fixed height is in effect, so toggling back to
// auto shows the current content height with no iframe reload.
useEffect(() => {
function onMessage(event: MessageEvent) {
if (event.source !== iframeRef.current?.contentWindow) return;
const data = event.data as { type?: string; height?: number };
if (data?.type !== HTML_EMBED_HEIGHT_MESSAGE) return;
const next = Number(data.height);
if (!Number.isFinite(next)) return;
setAutoHeight(clampHeight(next));
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
const effectiveHeight = hasFixedHeight ? clampHeight(height) : autoHeight;
const openEditor = useCallback(() => {
setDraft(source || "");
setDraftHeight(height ?? "");
setModalOpen(true);
}, [source, height]);
const onSave = useCallback(() => {
if (editor.isEditable) {
updateAttributes({
source: draft,
height: draftHeight === "" ? null : Number(draftHeight),
});
}
setModalOpen(false);
}, [draft, draftHeight, editor.isEditable, updateAttributes]);
// The edit affordance is only meaningful in edit mode and is offered only when
// the workspace master toggle is ON. Any member can edit (sandboxed = safe).
const canEdit = computeCanEdit(editor.isEditable, htmlEmbedEnabled);
return (
<NodeViewWrapper
data-drag-handle
className={clsx(classes.htmlEmbedNodeView, {
[classes.htmlEmbedSelected]: selected,
})}
>
{canEdit && (
<div className={classes.htmlEmbedToolbar}>
<ActionIcon
variant="default"
size="sm"
aria-label={t("Edit HTML embed")}
onClick={openEditor}
>
<IconEdit size={16} />
</ActionIcon>
</div>
)}
{!shouldRender ? (
// Feature disabled for this workspace AND we're in the editable editor:
// render a neutral placeholder so an existing embed is visibly inert for
// the author. Read-only / share viewers never hit this branch
// (`shouldRender` is always true there) — they render exactly the
// source the server chose to serve.
<div className={classes.htmlEmbedPlaceholder}>
<IconCode size={18} />
<Text size="sm">
{t("HTML embed is disabled in this workspace")}
</Text>
</div>
) : source ? (
// Raw HTML/CSS/JS rendered inside a sandboxed iframe (no same-origin):
// scripts run in an opaque origin and cannot touch the viewer's
// session/cookies/API.
<iframe
ref={iframeRef}
className={classes.htmlEmbedFrame}
sandbox="allow-scripts allow-popups allow-forms"
srcDoc={srcdoc}
title={t("HTML embed")}
referrerPolicy="no-referrer"
style={{ height: effectiveHeight }}
/>
) : canEdit ? (
<div className={classes.htmlEmbedPlaceholder} onClick={openEditor}>
<IconCode size={18} />
<Text size="sm">{t("Click to add HTML / CSS / JS")}</Text>
</div>
) : (
// Empty source, non-editor: render nothing visible.
<div className={classes.htmlEmbedContent} />
)}
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={t("Edit HTML embed")}
size="lg"
>
<Text size="xs" c="dimmed" mb="xs">
{t(
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.",
)}
</Text>
<Textarea
autosize
minRows={10}
maxRows={24}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
placeholder={t("<script>...</script>")}
styles={{ input: { fontFamily: "monospace" } }}
data-autofocus
/>
<NumberInput
mt="md"
label={t("Height (px, blank = auto)")}
value={draftHeight}
onChange={(value) =>
setDraftHeight(
value === "" || value === null ? "" : Number(value),
)
}
min={MIN_IFRAME_HEIGHT}
max={MAX_IFRAME_HEIGHT}
allowDecimal={false}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={() => setModalOpen(false)}>
{t("Cancel")}
</Button>
<Button onClick={onSave}>{t("Save")}</Button>
</Group>
</Modal>
</NodeViewWrapper>
);
}