import { IconPlus, IconTrash } from "@tabler/icons-react"; import { useMemo, useState } from "react"; import type { ZodType } from "zod"; import { cn } from "../utils.js"; import { ltrCodeBlockProps } from "./code-block-direction.js"; import { introspect, type FieldDescriptor } from "./schema-form/introspect.js"; import type { BlockRenderContext } from "./types.js"; /** * Schema-driven auto-editor. When a {@link BlockSpec} omits `Edit`, the registry * renders this: it walks the block's zod `data` schema and renders one control * per field (string → input, longtext → textarea, number, boolean → toggle, * enum → native select, array → repeating rows, object → nested fieldset). A * `markdown()`-tagged string field defers to the app-provided inline rich * editor via `ctx.renderMarkdownEditor` so prose stays Notion-editable. * * It uses plain accessible native controls (not template shadcn primitives, * which core does not bundle) styled to match the shadcn look. Validation runs * the spec's own schema on every edit; the raw edit is kept in local state so a * transiently-invalid value (e.g. mid-typing) doesn't get rolled back, and only * valid data is committed upstream. */ export function SchemaBlockEditor({ data, onChange, schema, editable, blockId, ctx, }: { data: T; onChange: (next: T) => void; schema: ZodType; editable: boolean; blockId?: string; ctx: BlockRenderContext; }) { const fields = useMemo(() => introspect(schema), [schema]); const [showOptional, setShowOptional] = useState(false); const setField = (key: string, value: unknown) => { const next = { ...(data as Record), [key]: value } as T; const parsed = schema.safeParse(next); // Commit valid data; otherwise pass the raw edit through so the user can // keep typing — the upstream owner re-validates before persisting. onChange((parsed.success ? parsed.data : next) as T); }; const required = fields.filter((field) => !field.optional); const optional = fields.filter((field) => field.optional); return (
{required.map((field) => ( )[field.key]} onChange={(value) => setField(field.key, value)} editable={editable} blockId={blockId} ctx={ctx} /> ))} {optional.length > 0 && (
{showOptional ? ( optional.map((field) => ( )[field.key]} onChange={(value) => setField(field.key, value)} editable={editable} blockId={blockId} ctx={ctx} /> )) ) : ( )}
)}
); } const inputClass = "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"; const textareaClass = "flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"; function FieldLabel({ children }: { children: React.ReactNode }) { return ( {children} ); } /** A sensible empty value for a single field, used when adding array items. */ function emptyForField(field: FieldDescriptor): unknown { switch (field.kind) { case "text": // A field literally named `id` gets a fresh stable id so new rows are // distinguishable without the user having to type one. return field.key === "id" ? `item-${Math.random().toString(36).slice(2, 10)}` : ""; case "longtext": case "markdown": case "richtext": return ""; case "boolean": return false; case "enum": return field.enumValues?.[0]; case "array": return []; case "object": return emptyObjectFromFields(field.fields); case "number": default: // number → undefined (the input shows blank until the user types); any // unsupported kind also defaults to undefined. return undefined; } } /** Build an empty object value from a descriptor's child fields. */ function emptyObjectFromFields( fields: FieldDescriptor[] | undefined, ): Record { const result: Record = {}; for (const child of fields ?? []) { result[child.key] = emptyForField(child); } return result; } /** A scalar element kind classified from an array's inner element schema. */ function scalarKindFromInner( inner: FieldDescriptor["inner"], ): "number" | "boolean" | "text" { const type = (inner?._def as { type?: string } | undefined)?.type; if (type === "number" || type === "bigint") return "number"; if (type === "boolean") return "boolean"; return "text"; } /** An empty value for a scalar array element of the given kind. */ function emptyScalar(kind: "number" | "boolean" | "text"): unknown { if (kind === "boolean") return false; if (kind === "number") return undefined; return ""; } function FieldControl({ field, value, onChange, editable, blockId, ctx, }: { field: FieldDescriptor; value: unknown; onChange: (value: unknown) => void; editable: boolean; blockId?: string; ctx: BlockRenderContext; }) { if (field.kind === "markdown" || field.kind === "richtext") { const node = ctx.renderMarkdownEditor?.({ value: typeof value === "string" ? value : "", onChange: (next) => onChange(next), editable, blockId, }); return (