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