// --------------------------------------------------------------------------- // 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 = { 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 ( ); } if (field.type === "select" && field.options) { return ( ); } // string field return ( 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 (
); } return ( {/* Header chrome - not editable by ProseMirror */}
setCollapsed((c) => !c) : undefined} > {emoji} {titleField ? (
e.stopPropagation()} > handleFieldChange(titleField.name, v)} />
) : ( {def.label} )} {otherFields.map((f) => (
e.stopPropagation()}> handleFieldChange(f.name, v)} />
))} {isAccordion && ( )}
{/* Editable content area */}
); } WrapperNodeView.displayName = `${def.tag}View`; return WrapperNodeView; }