tfrere's picture
tfrere HF Staff
feat(frontend): editor refresh (embed studio, comment popover, shiki, top bar, hooks, styles)
76fc93a
// ---------------------------------------------------------------------------
// Generic wrapper NodeView
//
// Renders any "wrapper" component from the registry as:
// ┌──────────────────────────────────────┐
// │ [icon] Title / fields [chevron]│ <- chrome (contentEditable=false)
// ├──────────────────────────────────────┤
// │ NodeViewContent (editable blocks) │ <- ProseMirror content hole
// └──────────────────────────────────────┘
// ---------------------------------------------------------------------------
import React, { useState, useCallback } from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import type { ComponentDef, ComponentField } from "./registry";
const VARIANT_COLORS: Record<string, { border: string; bg: string; icon: string }> = {
neutral: { border: "var(--border-color)", bg: "var(--surface-bg)", icon: "📝" },
info: { border: "#f39c12", bg: "color-mix(in oklab, #f39c12 10%, var(--surface-bg))", icon: "💡" },
success: { border: "#2ecc71", bg: "color-mix(in oklab, #2ecc71 8%, var(--surface-bg))", icon: "✅" },
danger: { border: "#e74c3c", bg: "color-mix(in oklab, #e74c3c 8%, var(--surface-bg))", icon: "⚠️" },
};
function FieldInput({
field,
value,
onChange,
}: {
field: ComponentField;
value: unknown;
onChange: (val: unknown) => void;
}) {
if (field.type === "boolean") {
return (
<label
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 12,
color: "var(--muted-color)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
style={{ accentColor: "var(--primary-color)" }}
/>
{field.label}
</label>
);
}
if (field.type === "select" && field.options) {
return (
<select
value={String(value ?? field.default ?? "")}
onChange={(e) => onChange(e.target.value)}
style={{
background: "var(--surface-bg)",
border: "1px solid var(--border-color)",
borderRadius: 4,
color: "var(--text-color)",
fontSize: 12,
padding: "2px 6px",
outline: "none",
}}
>
{field.options.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
);
}
// string field
return (
<input
type="text"
value={String(value ?? "")}
placeholder={field.placeholder || field.label}
onChange={(e) => onChange(e.target.value)}
style={{
background: "transparent",
border: "none",
borderBottom: "1px solid var(--border-color)",
color: "var(--text-color)",
fontSize: 14,
fontWeight: 600,
padding: "2px 0",
outline: "none",
flex: 1,
minWidth: 0,
}}
/>
);
}
/**
* Factory: returns a React component bound to a specific ComponentDef.
*/
export function makeWrapperView(def: ComponentDef) {
function WrapperNodeView({ node, updateAttributes }: NodeViewProps) {
const [collapsed, setCollapsed] = useState(
def.name === "accordion" ? !node.attrs.open : false,
);
const handleFieldChange = useCallback(
(fieldName: string, value: unknown) => {
updateAttributes({ [fieldName]: value });
},
[updateAttributes],
);
const titleField = def.fields.find((f) => f.name === "title");
const otherFields = def.fields.filter((f) => f.name !== "title");
// Variant-aware styling for Note
const variant = node.attrs.variant as string | undefined;
const colors = def.name === "note" && variant ? VARIANT_COLORS[variant] || VARIANT_COLORS.neutral : null;
const emoji = (node.attrs.emoji as string) || colors?.icon || def.icon;
const borderColor = colors?.border || "var(--border-color)";
const bgColor = colors?.bg || "var(--surface-bg)";
const isAccordion = def.name === "accordion";
const isNote = def.name === "note";
const isQuote = def.name === "quoteBlock";
const isSidenote = def.name === "sidenote";
const isWideOrFull = def.name === "wide" || def.name === "fullWidth";
// NOTE: for wide/fullWidth we intentionally DO NOT set any inline margin.
// The `margin` shorthand would collapse to `margin-left: 0` and defeat the
// CSS viewport-breakout (see article.css `.node-wide` / `.node-fullWidth`).
// Vertical spacing for those two is applied from CSS via `margin-block`.
const wrapperStyle: React.CSSProperties | undefined = isNote
? {
borderLeft: `2px solid ${borderColor}`,
borderTopRightRadius: 8,
borderBottomRightRadius: 8,
background: bgColor,
padding: 0,
margin: "0.75em 0",
}
: isQuote
? {
position: "relative" as const,
margin: "32px 0",
maxWidth: 600,
}
: isSidenote
? {
borderRadius: 8,
padding: 0,
margin: "0.75em 0",
fontSize: "0.9rem",
color: "var(--muted-color)",
}
: isWideOrFull
? undefined
: {
border: `1px solid ${borderColor}`,
borderRadius: "var(--table-border-radius, 8px)",
background: bgColor,
margin: "0.75em 0",
overflow: "hidden",
};
if (isSidenote) {
return (
<NodeViewWrapper
data-component={def.name}
className="sidenote-wrapper-node"
>
<div className="sidenote-content-area">
<NodeViewContent className="component-content" />
</div>
<span contentEditable={false} className="sidenote-badge"></span>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper
data-component={def.name}
data-variant={variant || undefined}
style={wrapperStyle}
>
{/* Header chrome - not editable by ProseMirror */}
<div
contentEditable={false}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: isNote ? "8px 18px" : isQuote ? "0" : "8px 12px",
borderBottom: collapsed || isQuote ? "none" : isNote ? "none" : `1px solid ${borderColor}`,
userSelect: "none",
cursor: isAccordion ? "pointer" : "default",
}}
onClick={isAccordion ? () => setCollapsed((c) => !c) : undefined}
>
<span style={{ fontSize: 14, lineHeight: 1, flexShrink: 0 }}>{emoji}</span>
{titleField ? (
<div
style={{ flex: 1, minWidth: 0 }}
onClick={(e) => e.stopPropagation()}
>
<FieldInput
field={titleField}
value={node.attrs[titleField.name]}
onChange={(v) => handleFieldChange(titleField.name, v)}
/>
</div>
) : (
<span style={{ flex: 1, fontSize: 13, fontWeight: 600, color: "var(--muted-color)" }}>
{def.label}
</span>
)}
{otherFields.map((f) => (
<div key={f.name} onClick={(e) => e.stopPropagation()}>
<FieldInput
field={f}
value={node.attrs[f.name]}
onChange={(v) => handleFieldChange(f.name, v)}
/>
</div>
))}
{isAccordion && (
<span
style={{
fontSize: 14,
transition: "transform 200ms ease",
transform: collapsed ? "rotate(-90deg)" : "rotate(0deg)",
color: "var(--muted-color)",
flexShrink: 0,
opacity: 0.6,
}}
>
</span>
)}
</div>
{/* Editable content area */}
<div
style={{
display: collapsed ? "none" : "block",
padding: isNote ? "0 18px 12px" : isQuote ? "0" : "8px 12px",
}}
>
<NodeViewContent className="component-content" />
</div>
</NodeViewWrapper>
);
}
WrapperNodeView.displayName = `${def.tag}View`;
return WrapperNodeView;
}