{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-editor-card",
  "title": "TextEditorCard",
  "description": "Render an uncontrolled plain-text or Markdown draft with accessible inline copy and download actions.",
  "dependencies": [
    "ai",
    "zod",
    "lucide-react"
  ],
  "registryDependencies": [
    "alert",
    "button",
    "card",
    "empty",
    "field",
    "skeleton",
    "textarea",
    "tooltip",
    "https://fable-ui.shobky.com/r/core.json"
  ],
  "files": [
    {
      "path": "components/fable-ui/text-editor-card/index.ts",
      "content": "export * from \"./text-editor-card\"\nexport * from \"./use-plain-text-draft\"\n",
      "type": "registry:component",
      "target": "@components/fable-ui/text-editor-card/index.ts"
    },
    {
      "path": "components/fable-ui/text-editor-card/text-editor-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Copy, Download } from \"lucide-react\"\n\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardAction,\n  CardContent,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport {\n  Empty,\n  EmptyDescription,\n  EmptyHeader,\n  EmptyTitle,\n} from \"@/components/ui/empty\"\nimport {\n  Field,\n  FieldError,\n  FieldGroup,\n  FieldLabel,\n} from \"@/components/ui/field\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport { Textarea } from \"@/components/ui/textarea\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { useCopyToClipboard } from \"@/hooks/use-copy-to-clipboard\"\n\nimport {\n  resolveTextDirection,\n  usePlainTextDraft,\n  type TextDirection,\n} from \"./use-plain-text-draft\"\n\nexport type TextEditorFormat = \"plain\" | \"markdown\"\n\nexport type TextEditorCardProps = {\n  label?: string\n  content: string\n  format?: TextEditorFormat\n  filename?: string\n  editable?: boolean\n  direction?: TextDirection\n  maxLength?: number\n  isLoading?: boolean\n  isStreaming?: boolean\n  isDisabled?: boolean\n  error?: {\n    title: string\n    description?: string\n  }\n  onContentChange?: (content: string) => void\n}\n\nfunction downloadText({\n  content,\n  filename,\n  format,\n}: {\n  content: string\n  filename: string\n  format: TextEditorFormat\n}) {\n  const blob = new Blob([content], {\n    type:\n      format === \"markdown\"\n        ? \"text/markdown;charset=utf-8\"\n        : \"text/plain;charset=utf-8\",\n  })\n  const url = URL.createObjectURL(blob)\n  const link = document.createElement(\"a\")\n\n  link.href = url\n  link.download = filename\n  link.style.display = \"none\"\n  document.body.appendChild(link)\n  link.click()\n  document.body.removeChild(link)\n  URL.revokeObjectURL(url)\n}\n\nfunction getDownloadFilename({\n  filename,\n  label,\n  format,\n}: {\n  filename?: string\n  label?: string\n  format: TextEditorFormat\n}) {\n  const extension = format === \"markdown\" ? \"md\" : \"txt\"\n  const source = (filename || label || \"text\").trim()\n  const safeBase = source\n    .replace(/[<>:\"/\\\\|?*\\u0000-\\u001f]/g, \"-\")\n    .replace(/\\s+/g, \"-\")\n    .replace(/^\\.+/, \"\")\n    .replace(/\\.(?:txt|md|markdown)$/iu, \"\")\n    .slice(0, 80)\n\n  return `${safeBase || \"text\"}.${extension}`\n}\n\nfunction TextEditorContent({\n  content,\n  direction,\n  editable,\n  isDisabled,\n  maxLength,\n  onContentChange,\n  onActionableContentChange,\n  editorRef,\n}: Pick<\n  TextEditorCardProps,\n  | \"content\"\n  | \"direction\"\n  | \"editable\"\n  | \"isDisabled\"\n  | \"maxLength\"\n  | \"onContentChange\"\n> & {\n  onActionableContentChange: (hasContent: boolean) => void\n  editorRef: React.RefObject<HTMLTextAreaElement | null>\n}) {\n  const contentId = React.useId()\n  const draft = usePlainTextDraft<HTMLTextAreaElement>({\n    initialValue: content,\n  })\n  const softLimitExceeded = Boolean(maxLength && draft.value.length > maxLength)\n\n  return (\n    <FieldGroup>\n      <Field data-disabled={isDisabled || !editable || undefined}>\n        <FieldLabel htmlFor={contentId}>Content</FieldLabel>\n        <Textarea\n          id={contentId}\n          ref={editorRef}\n          defaultValue={content}\n          dir={direction}\n          readOnly={isDisabled || !editable}\n          onInput={(event) => {\n            draft.onInput(event)\n            onActionableContentChange(Boolean(event.currentTarget.value))\n          }}\n          onBlur={(event) => onContentChange?.(event.currentTarget.value)}\n          className=\"max-h-80 min-h-40 overflow-auto leading-6\"\n        />\n        {maxLength ? (\n          <p className=\"text-xs text-muted-foreground\">\n            {draft.value.length} / {maxLength} characters\n          </p>\n        ) : null}\n        {softLimitExceeded ? (\n          <FieldError className=\"text-muted-foreground\">\n            Soft limit exceeded. The text is still available to copy or\n            download.\n          </FieldError>\n        ) : null}\n      </Field>\n    </FieldGroup>\n  )\n}\n\nfunction ReadOnlyText({\n  content,\n  direction,\n}: {\n  content: string\n  direction: TextDirection\n}) {\n  return (\n    <pre\n      dir={direction}\n      className=\"max-h-80 overflow-auto rounded-2xl border bg-muted/30 p-3 text-sm break-words whitespace-pre-wrap\"\n    >\n      {content}\n    </pre>\n  )\n}\n\nexport function TextEditorCard({\n  label = \"Text editor\",\n  content,\n  format = \"plain\",\n  filename,\n  editable = true,\n  direction = \"auto\",\n  maxLength,\n  isLoading,\n  isStreaming,\n  isDisabled,\n  error,\n  onContentChange,\n}: TextEditorCardProps) {\n  const [copyError, setCopyError] = React.useState(false)\n  const editorRef = React.useRef<HTMLTextAreaElement>(null)\n  const { copyToClipboard, isCopied } = useCopyToClipboard()\n  const hasContent = content.length > 0\n  const isReady = !isLoading && !isStreaming && !error && hasContent\n  const [draftActionability, setDraftActionability] = React.useState(() => ({\n    source: content,\n    hasContent,\n  }))\n  const downloadFilename = getDownloadFilename({ filename, label, format })\n  const title = label || \"Text editor\"\n  const copyLabel = isCopied ? \"Copied text\" : \"Copy text\"\n  const downloadLabel = `Download .${format === \"markdown\" ? \"md\" : \"txt\"}`\n  const hasActionableContent = isReady\n    ? draftActionability.source === content\n      ? draftActionability.hasContent\n      : hasContent\n    : hasContent\n\n  async function copyContent() {\n    setCopyError(false)\n    const currentContent = isReady\n      ? (editorRef.current?.value ?? content)\n      : content\n    const didCopy = await copyToClipboard(currentContent)\n\n    setCopyError(!didCopy)\n  }\n\n  function downloadContent() {\n    const currentContent = isReady\n      ? (editorRef.current?.value ?? content)\n      : content\n\n    downloadText({\n      content: currentContent,\n      filename: downloadFilename,\n      format,\n    })\n  }\n\n  return (\n    <Card\n      size=\"sm\"\n      className=\"w-full max-w-2xl\"\n      data-fable-ui=\"text-editor-card\"\n      aria-busy={isLoading || isStreaming || undefined}\n    >\n      <CardHeader>\n        <CardTitle\n          className={\n            title === \"Text editor\" ? \"sr-only\" : \"text-sm font-medium\"\n          }\n        >\n          {title}\n        </CardTitle>\n        <CardAction>\n          <TooltipProvider>\n            <div className=\"flex items-center gap-1\">\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"icon-sm\"\n                    aria-label={copyLabel}\n                    disabled={!hasActionableContent}\n                    onClick={copyContent}\n                  >\n                    {isCopied ? <Check /> : <Copy />}\n                    <span className=\"sr-only\">{copyLabel}</span>\n                  </Button>\n                </TooltipTrigger>\n                <TooltipContent>{copyLabel}</TooltipContent>\n              </Tooltip>\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"icon-sm\"\n                    aria-label={downloadLabel}\n                    disabled={!hasActionableContent}\n                    onClick={downloadContent}\n                  >\n                    <Download />\n                    <span className=\"sr-only\">{downloadLabel}</span>\n                  </Button>\n                </TooltipTrigger>\n                <TooltipContent>{downloadLabel}</TooltipContent>\n              </Tooltip>\n            </div>\n          </TooltipProvider>\n        </CardAction>\n      </CardHeader>\n      <CardContent className=\"flex flex-col gap-4\">\n        {isLoading || (isStreaming && !hasContent) ? (\n          <>\n            <p role=\"status\" className=\"sr-only\">\n              Preparing text editor...\n            </p>\n            <Skeleton className=\"h-5 w-36 motion-reduce:animate-none\" />\n            <Skeleton className=\"h-40 w-full motion-reduce:animate-none\" />\n          </>\n        ) : null}\n        {isStreaming && hasContent ? (\n          <>\n            <p role=\"status\" className=\"text-sm text-muted-foreground\">\n              Generating text...\n            </p>\n            <ReadOnlyText content={content} direction={direction} />\n          </>\n        ) : null}\n        {!isLoading && !isStreaming && error ? (\n          <>\n            <Alert variant=\"destructive\">\n              <AlertTitle>{error.title}</AlertTitle>\n              {error.description ? (\n                <AlertDescription>{error.description}</AlertDescription>\n              ) : null}\n            </Alert>\n            {hasContent ? (\n              <ReadOnlyText content={content} direction={direction} />\n            ) : null}\n          </>\n        ) : null}\n        {!isLoading && !isStreaming && !error && !hasContent ? (\n          <Empty className=\"min-h-40 p-6\">\n            <EmptyHeader>\n              <EmptyTitle>No text yet</EmptyTitle>\n              <EmptyDescription>\n                There is no content to edit or export.\n              </EmptyDescription>\n            </EmptyHeader>\n          </Empty>\n        ) : null}\n        {!isLoading && !isStreaming && !error && hasContent ? (\n          <TextEditorContent\n            key={`${format}:${content}`}\n            content={content}\n            direction={direction}\n            editable={editable}\n            isDisabled={isDisabled}\n            maxLength={maxLength}\n            onContentChange={onContentChange}\n            onActionableContentChange={(nextHasContent) => {\n              setDraftActionability({\n                source: content,\n                hasContent: nextHasContent,\n              })\n            }}\n            editorRef={editorRef}\n          />\n        ) : null}\n        {copyError ? (\n          <p role=\"status\" className=\"text-sm text-destructive\">\n            Could not copy. Select the text and copy it manually.\n          </p>\n        ) : null}\n      </CardContent>\n    </Card>\n  )\n}\n\nexport default TextEditorCard\n\n// shadcn rewrites imports from a registry dependency's barrel to its primary\n// component file. Keep the shared draft contract available at that boundary.\nexport { resolveTextDirection, usePlainTextDraft }\nexport type { TextDirection }\n",
      "type": "registry:component",
      "target": "@components/fable-ui/text-editor-card/text-editor-card.tsx"
    },
    {
      "path": "components/fable-ui/text-editor-card/use-plain-text-draft.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\ntype PlainTextControl = HTMLInputElement | HTMLTextAreaElement\n\nexport function usePlainTextDraft<TControl extends PlainTextControl>({\n  initialValue,\n}: {\n  initialValue: string\n}) {\n  const [value, setValue] = React.useState(initialValue)\n\n  const onInput = React.useCallback((event: React.FormEvent<TControl>) => {\n    const nextValue = event.currentTarget.value\n\n    setValue(nextValue)\n  }, [])\n\n  return {\n    onInput,\n    value,\n  }\n}\n\nexport type TextDirection = \"ltr\" | \"rtl\" | \"auto\"\n\nexport function resolveTextDirection(\n  direction: TextDirection,\n  content: string\n) {\n  if (direction !== \"auto\") {\n    return direction\n  }\n\n  return /[\\u0590-\\u08ff]/u.test(content) ? \"rtl\" : \"ltr\"\n}\n",
      "type": "registry:hook",
      "target": "@components/fable-ui/text-editor-card/use-plain-text-draft.ts"
    },
    {
      "path": "hooks/use-copy-to-clipboard.ts",
      "content": "\"use client\"\r\n\r\nimport * as React from \"react\"\r\n\r\nfunction legacyCopyToClipboard(value: string) {\r\n  const textArea = document.createElement(\"textarea\")\r\n  textArea.value = value\r\n  textArea.setAttribute(\"readonly\", \"\")\r\n  textArea.style.position = \"fixed\"\r\n  textArea.style.opacity = \"0\"\r\n  textArea.style.pointerEvents = \"none\"\r\n\r\n  document.body.appendChild(textArea)\r\n  textArea.focus()\r\n  textArea.select()\r\n  textArea.setSelectionRange(0, value.length)\r\n\r\n  let hasCopied = false\r\n  try {\r\n    hasCopied = document.execCommand(\"copy\")\r\n  } catch {\r\n    hasCopied = false\r\n  }\r\n\r\n  document.body.removeChild(textArea)\r\n  return hasCopied\r\n}\r\n\r\nexport function useCopyToClipboard({\r\n  timeout = 2000,\r\n  onCopy,\r\n}: {\r\n  timeout?: number\r\n  onCopy?: () => void\r\n} = {}) {\r\n  const [isCopied, setIsCopied] = React.useState(false)\r\n\r\n  const copyToClipboard = async (value: string) => {\r\n    if (typeof window === \"undefined\") {\r\n      return false\r\n    }\r\n\r\n    if (!value) {\r\n      return false\r\n    }\r\n\r\n    let hasCopied = false\r\n\r\n    if (navigator.clipboard?.writeText) {\r\n      try {\r\n        await navigator.clipboard.writeText(value)\r\n        hasCopied = true\r\n      } catch {\r\n        hasCopied = legacyCopyToClipboard(value)\r\n      }\r\n    } else {\r\n      hasCopied = legacyCopyToClipboard(value)\r\n    }\r\n\r\n    if (!hasCopied) {\r\n      return false\r\n    }\r\n\r\n    setIsCopied(true)\r\n\r\n    if (onCopy) {\r\n      onCopy()\r\n    }\r\n\r\n    if (timeout !== 0) {\r\n      setTimeout(() => {\r\n        setIsCopied(false)\r\n      }, timeout)\r\n    }\r\n\r\n    return true\r\n  }\r\n\r\n  return { isCopied, copyToClipboard }\r\n}\r\n",
      "type": "registry:hook",
      "target": "@hooks/use-copy-to-clipboard.ts"
    },
    {
      "path": "lib/fable-ui/tools/show-text-editor-tool.ts",
      "content": "import { tool } from \"ai\"\nimport { z } from \"zod\"\n\nimport {\n  TextEditorCard,\n  type TextEditorCardProps,\n} from \"@/components/fable-ui/text-editor-card\"\nimport { defineFableComponent } from \"@/lib/fable-ui/core/definitions\"\n\nconst textEditorFormatSchema = z.enum([\"plain\", \"markdown\"])\nconst textDirectionSchema = z.enum([\"ltr\", \"rtl\", \"auto\"])\n\nexport const showTextEditorInputSchema = z.object({\n  label: z.string().min(1).optional(),\n  content: z.string(),\n  format: textEditorFormatSchema.default(\"plain\"),\n  filename: z.string().min(1).optional(),\n  editable: z.boolean().default(true),\n  direction: textDirectionSchema.default(\"auto\"),\n  maxLength: z.number().int().positive().optional(),\n})\n\nexport type ShowTextEditorInput = z.infer<typeof showTextEditorInputSchema>\n\nfunction getPartialTextEditorProps(input: unknown): TextEditorCardProps {\n  const partial =\n    input && typeof input === \"object\" ? (input as Record<string, unknown>) : {}\n\n  return {\n    label: typeof partial.label === \"string\" ? partial.label : \"Text editor\",\n    content: typeof partial.content === \"string\" ? partial.content : \"\",\n    format: partial.format === \"markdown\" ? \"markdown\" : \"plain\",\n    filename:\n      typeof partial.filename === \"string\" ? partial.filename : undefined,\n    editable: typeof partial.editable === \"boolean\" ? partial.editable : true,\n    direction:\n      partial.direction === \"ltr\" ||\n      partial.direction === \"rtl\" ||\n      partial.direction === \"auto\"\n        ? partial.direction\n        : \"auto\",\n    maxLength:\n      typeof partial.maxLength === \"number\" &&\n      Number.isInteger(partial.maxLength) &&\n      partial.maxLength > 0\n        ? partial.maxLength\n        : undefined,\n    isStreaming: true,\n  }\n}\n\nexport function createShowTextEditorTool() {\n  return tool({\n    description:\n      \"Show one plain-text or Markdown draft for review, local editing, copying, or download. Use for a self-contained document or note, not rich text, multi-recipient email, code, forms, or host-side file editing.\",\n    inputSchema: showTextEditorInputSchema,\n    execute: async (input) => input,\n  })\n}\n\nexport const showTextEditor = defineFableComponent({\n  name: \"show_text_editor\",\n  schema: showTextEditorInputSchema,\n  tool: createShowTextEditorTool(),\n  renderer: {\n    Component: TextEditorCard,\n    loadingProps: { label: \"Text editor\", content: \"\", isLoading: true },\n    streamingProps: getPartialTextEditorProps,\n    emptyProps: { label: \"Text editor\", content: \"\" },\n    errorProps: (description, part) => ({\n      ...getPartialTextEditorProps(part?.input),\n      isStreaming: false,\n      error: { title: \"Text editor unavailable\", description },\n    }),\n    toProps: (data: ShowTextEditorInput) => data,\n  },\n})\n",
      "type": "registry:lib",
      "target": "@lib/fable-ui/tools/show-text-editor-tool.ts"
    },
    {
      "path": "lib/fable-ui/manifests/show-text-editor.md",
      "content": "---\ntool: show_text_editor\ntype: registry:component\n---\n\n# show_text_editor\n\nUse `show_text_editor` for one self-contained plain-text or Markdown draft that the user can review, edit locally after generation completes, copy, or download.\n\nInclude the complete `content`. Use `format: \"markdown\"` only when the text is Markdown. Set `editable: false` for a read-only historical or host-controlled draft. Use `direction` when the content direction is known; otherwise leave it as `auto`.\n\nAvoid it for rich text, multiple email recipients, source code, structured forms, live collaborative editing, file writes, or data retrieval. The card does not persist edits or mutate host files.\n\n`maxLength` is a visible soft guidance limit, not a truncation or validation rule. Partial and interrupted text stays available to copy or download when meaningful.\n",
      "type": "registry:file",
      "target": "@lib/fable-ui/manifests/show-text-editor.md"
    }
  ],
  "type": "registry:component"
}