{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"text-element","type":"registry:ui","title":"TextElement","description":"Slide text element renderer.","package":"fancy-slides","dependencies":["@particle-academy\/react-fancy"],"registryDependencies":["types","theme","slide"],"files":[{"path":"components\/fancy\/text-element\/TextElement.tsx","content":"import { useId, type CSSProperties } from \"react\";\r\nimport { ContentRenderer, Editor } from \"@particle-academy\/react-fancy\";\r\nimport type { TextElement, Theme } from \"..\/..\/..\/types\";\r\nimport { resolveTheme } from \"..\/..\/..\/theme\/theme-utils\";\r\nimport { splitParagraphs, textBlocks, type ParaReveal } from \"..\/..\/..\/utils\/builds\";\r\nimport { buildEnterStyle } from \"..\/..\/Slide\/builds-style\";\r\nimport { PRESENTATION_EDITOR_ACTIONS, normalizeSlideMarkdown } from \".\/editor-preset\";\r\n\r\nexport interface TextElementRendererProps {\r\n    element: TextElement;\r\n    theme?: Theme;\r\n    \/** Rendered slide width in px (for font-size scaling). *\/\r\n    slideWidthPx: number;\r\n    \/**\r\n     * Edit mode \u2014 when true, the element is potentially editable.\r\n     * The textarea actually becomes pointer-interactive only when both\r\n     * `editing` and `selected` are true, so the first click on an\r\n     * unselected text element selects it (handled by the parent Slide)\r\n     * rather than landing on the textarea.\r\n     *\/\r\n    editing?: boolean;\r\n    \/** Element is selected \u2014 gates whether the textarea grabs pointer events. *\/\r\n    selected?: boolean;\r\n    \/** Called when the user edits the content (only fires when the textarea is focusable). *\/\r\n    onContentChange?: (content: string) => void;\r\n    \/**\r\n     * By-paragraph reveal state, set by `<Slide>` in viewer mode when this text\r\n     * element has a `byParagraph` animation. When present, the renderer splits\r\n     * `content` on `\"\\n\"` and shows only the first `revealed` paragraphs,\r\n     * animating the one at `firingParaIndex`. Ignored in editing mode (the full\r\n     * text always renders so authors can position\/edit it).\r\n     *\/\r\n    paraReveal?: ParaReveal;\r\n}\r\n\r\n\/**\r\n * Renderer for `text` elements. Three formats:\r\n *\r\n *   - `\"markdown\"` (default) \u2014 parsed via react-fancy's ContentRenderer.\r\n *     Bullets, bold\/italic, code spans, links, headings.\r\n *   - `\"html\"` \u2014 parsed sanitized HTML via ContentRenderer's html path.\r\n *   - `\"plain\"` \u2014 raw text rendered into a single block; preserves newlines\r\n *     via `white-space: pre-wrap`.\r\n *\r\n * In editing mode + when the element is selected, the renderer swaps to a\r\n * textarea showing the raw source. Edits flow back via `onContentChange`.\r\n *\/\r\nexport function TextElementRenderer({\r\n    element,\r\n    theme,\r\n    slideWidthPx,\r\n    editing = false,\r\n    selected = false,\r\n    onContentChange,\r\n    paraReveal,\r\n}: TextElementRendererProps) {\r\n    const t = resolveTheme(theme);\r\n    const style = element.style ?? {};\r\n    const designWidth = t.slideWidth ?? 1920;\r\n    const scale = slideWidthPx \/ designWidth;\r\n    const format = element.format ?? \"markdown\";\r\n    const scopeId = useId();\r\n\r\n    const css: CSSProperties = {\r\n        fontFamily: style.fontFamily ?? t.fonts?.body,\r\n        fontSize: `${(style.fontSize ?? 28) * scale}px`,\r\n        fontWeight: weight(style.weight) ?? 400,\r\n        fontStyle: style.italic ? \"italic\" : \"normal\",\r\n        textDecoration: style.underline ? \"underline\" : \"none\",\r\n        color: style.color ?? t.colors?.text,\r\n        textAlign: style.align ?? \"left\",\r\n        lineHeight: style.lineHeight ?? 1.4,\r\n        display: \"flex\",\r\n        flexDirection: \"column\",\r\n        justifyContent:\r\n            style.verticalAlign === \"middle\" ? \"center\" : style.verticalAlign === \"bottom\" ? \"flex-end\" : \"flex-start\",\r\n        width: \"100%\",\r\n        height: \"100%\",\r\n        padding: 0,\r\n        margin: 0,\r\n        outline: \"none\",\r\n        background: \"transparent\",\r\n        whiteSpace: format === \"plain\" ? \"pre-wrap\" : \"normal\",\r\n        wordBreak: \"break-word\",\r\n        overflow: \"hidden\",\r\n    };\r\n\r\n    \/\/ Edit mode is gated by selection: an unselected element in the editor\r\n    \/\/ still shows the parsed markdown so the user sees the real layout.\r\n    \/\/ Clicking selects \u2192 the react-fancy Editor appears, a WYSIWYG rich-text\r\n    \/\/ surface tuned for presentations (bold \/ italic \/ heading \/ bullets).\r\n    \/\/ Edits emit markdown, normalized to the line-based paragraph model the\r\n    \/\/ slide content + dark-slide writer commit to. The Editor is keyed by the\r\n    \/\/ element id because it loads its value once on mount (it's uncontrolled\r\n    \/\/ after that), so selecting a different element remounts it with the right\r\n    \/\/ content. Scoped CSS strips the Editor's card chrome and matches the\r\n    \/\/ element's scaled typography so editing looks like the live slide.\r\n    if (editing && selected) {\r\n        const fontPx = Math.round((style.fontSize ?? 28) * scale);\r\n        const lh = style.lineHeight ?? 1.4;\r\n        const editScope = scopeId;\r\n        return (\r\n            <div\r\n                data-fs-edit-scope={editScope}\r\n                style={{\r\n                    width: \"100%\",\r\n                    height: \"100%\",\r\n                    pointerEvents: \"auto\",\r\n                    cursor: \"text\",\r\n                    textAlign: style.align ?? \"left\",\r\n                    color: style.color ?? t.colors?.text,\r\n                    fontFamily: style.fontFamily ?? t.fonts?.body,\r\n                }}\r\n                \/\/ Keep editing interactions (toolbar clicks, text selection) from\r\n                \/\/ bubbling to the Slide's element drag \/ deselect handlers.\r\n                onPointerDown={(e) => e.stopPropagation()}\r\n            >\r\n                <style>{`\r\n                    [data-fs-edit-scope=\"${editScope}\"] [data-react-fancy-editor] {\r\n                        border: none; background: transparent; border-radius: 0;\r\n                        height: 100%; display: flex; flex-direction: column; overflow: hidden;\r\n                    }\r\n                    [data-fs-edit-scope=\"${editScope}\"] [data-react-fancy-editor-toolbar] {\r\n                        background: rgba(244,244,245,0.85); border-radius: 6px 6px 0 0;\r\n                        padding: 2px 4px;\r\n                    }\r\n                    [data-fs-edit-scope=\"${editScope}\"] [data-react-fancy-editor-content] {\r\n                        flex: 1; min-height: 0; padding: 4px 2px; overflow: auto;\r\n                        font-size: ${fontPx}px; line-height: ${lh};\r\n                    }\r\n                    [data-fs-edit-scope=\"${editScope}\"] [data-react-fancy-editor-content] :is(p, ul, ol, li) { font-size: inherit; margin: 0; }\r\n                    [data-fs-edit-scope=\"${editScope}\"] [data-react-fancy-editor-content] :where(p, li) + :where(p, li, ul, ol) { margin-top: 0.4em; }\r\n                    [data-fs-edit-scope=\"${editScope}\"] [data-react-fancy-editor-content] h1 { font-size: 1.6em; font-weight: 700; margin: 0; }\r\n                    [data-fs-edit-scope=\"${editScope}\"] [data-react-fancy-editor-content] h2 { font-size: 1.35em; font-weight: 700; margin: 0; }\r\n                    [data-fs-edit-scope=\"${editScope}\"] [data-react-fancy-editor-content] h3 { font-size: 1.15em; font-weight: 600; margin: 0; }\r\n                `}<\/style>\r\n                <Editor\r\n                    key={element.id}\r\n                    value={element.content}\r\n                    onChange={(md) => onContentChange?.(normalizeSlideMarkdown(md))}\r\n                    outputFormat=\"markdown\"\r\n                    lineSpacing={lh}\r\n                >\r\n                    <Editor.Toolbar actions={PRESENTATION_EDITOR_ACTIONS} \/>\r\n                    <Editor.Content \/>\r\n                <\/Editor>\r\n            <\/div>\r\n        );\r\n    }\r\n\r\n    \/\/ Scope the ContentRenderer's prose styles so element-level typography\r\n    \/\/ (fontSize, weight, align) wins over the global prose CSS. We render a\r\n    \/\/ tiny inline style block that targets only this instance via the\r\n    \/\/ generated useId scope.\r\n    \/\/\r\n    \/\/ CRITICAL: ContentRenderer wraps its content in `text-sm` plus per-tag\r\n    \/\/ Tailwind utilities (`[&_h1]:text-2xl`, etc). Those have higher\r\n    \/\/ specificity than the inline `fontSize` we set on the wrapper, so\r\n    \/\/ without forced overrides the slide text ignores `style.fontSize` AND\r\n    \/\/ ignores the slide's resolution scaling \u2014 that's why thumbnails were\r\n    \/\/ rendering text at ~14px instead of (fontSize \u00d7 scale)px.\r\n    \/\/\r\n    \/\/ We double the attribute-selector to outrank Tailwind's\r\n    \/\/ `[&_h2]:text-xl`-style utilities, and re-express heading sizes in\r\n    \/\/ `em` so they remain proportional as the slide scales.\r\n    const proseScope = `[data-fs-text-scope=\"${scopeId}\"]`;\r\n    const doubleScope = `${proseScope}${proseScope}`;\r\n    const proseStyle = (\r\n        <style>{`\r\n            ${proseScope} > div { width: 100%; height: 100%; font-size: inherit; }\r\n            ${doubleScope} :is(p, ul, ol, li, blockquote, h1, h2, h3, h4, h5, h6, pre, code, strong, em, a) {\r\n                font-size: inherit;\r\n            }\r\n            ${doubleScope} h1 { font-size: 1.6em; font-weight: 700; }\r\n            ${doubleScope} h2 { font-size: 1.35em; font-weight: 700; }\r\n            ${doubleScope} h3 { font-size: 1.15em; font-weight: 600; }\r\n            ${proseScope} :where(p, ul, ol, h1, h2, h3, h4, h5, h6, pre, blockquote) {\r\n                margin: 0;\r\n                padding: 0;\r\n            }\r\n            ${proseScope} :where(p, li) + :where(p, li, ul, ol) { margin-top: 0.4em; }\r\n            ${proseScope} > [data-fancy-slides-paragraph] + [data-fancy-slides-paragraph]:not(:empty) { margin-top: 0.4em; }\r\n            ${proseScope} :where(ul, ol) { padding-left: 1.4em; }\r\n            ${proseScope} :where(strong) { font-weight: ${Math.max(700, weight(style.weight) ?? 400 + 200)}; }\r\n            ${proseScope} :where(a) { color: inherit; text-decoration: underline; }\r\n            ${proseScope} :where(code) { font-family: ${t.fonts?.mono ?? \"monospace\"}; }\r\n        `}<\/style>\r\n    );\r\n\r\n    \/\/ Render one chunk of content in the element's format. Reused for the whole\r\n    \/\/ element and for each paragraph in a by-paragraph build.\r\n    const renderChunk = (content: string) =>\r\n        format === \"plain\" ? content : <ContentRenderer value={content} format={format === \"html\" ? \"html\" : \"markdown\"} \/>;\r\n\r\n    \/\/ \u2500\u2500\u2500 By-paragraph build reveal \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n    \/\/ When `<Slide>` hands us a paraReveal, split the content and show only the\r\n    \/\/ first `revealed` paragraphs, animating the one that just fired. We always\r\n    \/\/ render the prose scope wrapper so markdown\/html paragraphs keep their\r\n    \/\/ typography. Each line of markdown renders through the same path, so a\r\n    \/\/ bullet line (\"- \u2026\") renders as its own list item.\r\n    if (paraReveal) {\r\n        const paras = splitParagraphs(element.content);\r\n        return (\r\n            <div data-fs-text-scope={scopeId} style={css}>\r\n                {proseStyle}\r\n                {paras.map((para, i) => {\r\n                    if (i >= paraReveal.revealed) return null; \/\/ not yet built\r\n                    const firing = i === paraReveal.firingParaIndex && !!element.animation;\r\n                    const enter = firing\r\n                        ? buildEnterStyle(element.animation!, element.animation!.delay ?? 0)\r\n                        : null;\r\n                    return (\r\n                        <div\r\n                            key={i}\r\n                            className={firing ? \"fs-build-enter\" : undefined}\r\n                            style={{ whiteSpace: format === \"plain\" ? \"pre-wrap\" : \"normal\", ...enter }}\r\n                            data-fancy-slides-paragraph={i}\r\n                        >\r\n                            {renderChunk(para)}\r\n                        <\/div>\r\n                    );\r\n                })}\r\n            <\/div>\r\n        );\r\n    }\r\n\r\n    if (format === \"plain\") {\r\n        return <div style={css}>{element.content}<\/div>;\r\n    }\r\n\r\n    \/\/ \u2500\u2500\u2500 Static (non-build) render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n    \/\/ Render line-by-line, mirroring the by-paragraph path above, so a single\r\n    \/\/ `\"\\n\"` is a hard line\/paragraph break \u2014 the canonical line-based model the\r\n    \/\/ rest of the system commits to (`splitParagraphs`, `normalizeSlideMarkdown`,\r\n    \/\/ dark-slide's `explode(\"\\n\", \u2026)` pptx export). Passing the whole multi-line\r\n    \/\/ string to a markdown renderer would instead treat a lone `\"\\n\"` as a\r\n    \/\/ CommonMark soft break (a space), collapsing bullet lists and\r\n    \/\/ \"label + description\" blocks onto one wrapped line.\r\n    const lines = textBlocks(element.content, format);\r\n    return (\r\n        <div data-fs-text-scope={scopeId} style={css}>\r\n            {proseStyle}\r\n            {lines.map((line, i) => (\r\n                <div key={i} data-fancy-slides-paragraph={i}>\r\n                    {renderChunk(line)}\r\n                <\/div>\r\n            ))}\r\n        <\/div>\r\n    );\r\n}\r\n\r\nfunction weight(w: \"normal\" | \"medium\" | \"semibold\" | \"bold\" | number | undefined): number | undefined {\r\n    if (typeof w === \"number\") return w;\r\n    if (w === \"normal\") return 400;\r\n    if (w === \"medium\") return 500;\r\n    if (w === \"semibold\") return 600;\r\n    if (w === \"bold\") return 700;\r\n    return undefined;\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/text-element\/TextElement.tsx"},{"path":"components\/fancy\/text-element\/editor-preset.ts","content":"import type { EditorAction } from \"@particle-academy\/react-fancy\";\r\n\r\n\/**\r\n * Presentation-tuned toolbar preset for the react-fancy `Editor` used to edit\r\n * slide text inline. Only commands that round-trip cleanly through the editor's\r\n * `htmlToMarkdown` output are included \u2014 bold (`**`), italic (`*`), heading\r\n * (`## `), and bullet list (`- `). Box-level typography (alignment, color, font\r\n * size, line height) is NOT here: those are per-element `TextStyle` properties\r\n * edited in the ElementInspector, and `text-align`\/color spans don't survive the\r\n * markdown the slide content commits to.\r\n *\/\r\nexport const PRESENTATION_EDITOR_ACTIONS: EditorAction[] = [\r\n    { icon: \"B\", label: \"Bold\", command: \"bold\" },\r\n    { icon: \"I\", label: \"Italic\", command: \"italic\" },\r\n    { icon: \"H\", label: \"Heading\", command: \"formatBlock\", commandArg: \"<h2>\" },\r\n    { icon: \"P\", label: \"Paragraph\", command: \"formatBlock\", commandArg: \"<p>\" },\r\n    { icon: \"\u2022\", label: \"Bullet list\", command: \"insertUnorderedList\" },\r\n];\r\n\r\n\/**\r\n * Normalize the `Editor`'s markdown output to the *line-based* paragraph model\r\n * the slide content commits to. The editor emits a blank line (`\\n\\n`) between\r\n * `<p>` blocks, but `splitParagraphs` (and the dark-slide pptx writer) treat a\r\n * single `\\n` as one paragraph \/ build unit, and a blank interior line as its\r\n * own (phantom) build. Collapsing runs of newlines to a single `\\n` keeps\r\n * \"by paragraph\" reveals and per-paragraph pptx builds correct \u2014 bullets are\r\n * already one-per-`\\n`, so they're unaffected. The round-trip invariant\r\n * (`content \u2192 Editor \u2192 normalize \u2192 content` preserves the `\\n` paragraph count)\r\n * is covered by a unit test.\r\n *\/\r\nexport function normalizeSlideMarkdown(md: string): string {\r\n    return md\r\n        .replace(\/\\r\\n\/g, \"\\n\")\r\n        .replace(\/\\n{2,}\/g, \"\\n\")\r\n        .replace(\/[ \\t]+$\/gm, \"\")\r\n        .trim();\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/text-element\/editor-preset.ts"},{"path":"components\/fancy\/text-element\/index.ts","content":"export { TextElementRenderer } from \".\/TextElement\";\r\nexport type { TextElementRendererProps } from \".\/TextElement\";\r\nexport { PRESENTATION_EDITOR_ACTIONS, normalizeSlideMarkdown } from \".\/editor-preset\";\r\n","type":"registry:ui","target":"components\/fancy\/text-element\/index.ts"}]}