Spaces:
Sleeping
Sleeping
| // --------------------------------------------------------------------------- | |
| // 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; | |
| } | |