{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "author": "Pytah", "categories": ["editor"], "cssVars": { "theme": { "color-highlight": "var(--highlight)", "color-highlight-foreground": "var(--highlight-foreground)" }, "light": { "highlight": "oklch(0.97 0.05 90)", "highlight-foreground": "oklch(0.145 0 0)" }, "dark": { "highlight": "oklch(0.35 0.06 85)", "highlight-foreground": "oklch(0.985 0 0)" } }, "dependencies": [ "@base-ui/react@^1.3.0", "@lexical/clipboard@^0.42.0", "@lexical/code@^0.42.0", "@lexical/extension@^0.42.0", "@lexical/history@^0.42.0", "@lexical/html@^0.42.0", "@lexical/link@^0.42.0", "@lexical/list@^0.42.0", "@lexical/markdown@^0.42.0", "@lexical/react@^0.42.0", "@lexical/rich-text@^0.42.0", "@lexical/selection@^0.42.0", "@lexical/table@^0.42.0", "@lexical/utils@^0.42.0", "class-variance-authority@^0.7.1", "clsx@^2.1.1", "cmdk@^1.1.1", "lexical@^0.42.0", "lucide-react@^1.7.0", "tailwind-merge@^3.5.0" ], "description": "Copy/paste-ready Lexical editor with slash commands, floating controls, tables, images, embeds, and markdown/html interoperability.", "docs": "Install into a React 19+ + Tailwind CSS 4.x + shadcn/ui 4.x project.\n\nThe item writes to your configured `components`, `ui`, and `lib` aliases.\nIt expects the standard `@/` alias contract from `components.json`.\nYour global Tailwind v4 stylesheet should import tw-animate-css.\nThe editor is currently validated against Lexical 0.42.x.\n\nRender it with:\n```tsx\nimport { Editor } from \"@/components/editor/editor\"\n\nexport function Page() {\n return \n}\n```", "files": [ { "content": "import type { LexicalEditor } from \"lexical\";\nimport { HTML_EXAMPLE, MARKDOWN_EXAMPLE } from \"./constants\";\nimport {\n createEmptyEditorState,\n loadMarkdownContent,\n replaceEditorHtmlContent,\n} from \"./utils\";\n\nexport const copyEditorOutput = async (value: string) => {\n await navigator.clipboard.writeText(value);\n};\n\nexport const resetEditorContent = (editor: LexicalEditor) => {\n createEmptyEditorState(editor);\n};\n\nexport const loadEditorHtmlExample = (editor: LexicalEditor) => {\n replaceEditorHtmlContent(editor, HTML_EXAMPLE);\n};\n\nexport const loadEditorMarkdownExample = (editor: LexicalEditor) => {\n loadMarkdownContent(editor, MARKDOWN_EXAMPLE);\n};\n", "path": "registry/pytah/editor/components/editor/core/actions.ts", "target": "src/components/editor/core/actions.ts", "type": "registry:file" }, { "content": "/** Shape of a single color entry in the palette. */\nexport interface ColorSwatch {\n /** Human-readable label (used as aria-label on the swatch button). */\n label: string;\n /** CSS color value, e.g. \"#ef4444\". */\n value: string;\n}\n\n/**\n * Default color palette shared by the text color and background color pickers.\n *\n * To customise the available swatches, edit this array — the order determines\n * the rendering order in the grid. Both pickers import this constant as their\n * default, but each `` instance also accepts a custom `palette`\n * prop if you need diverging sets.\n */\nexport const COLOR_PALETTE: ColorSwatch[] = [\n { label: \"Black\", value: \"#1a1a1a\" },\n { label: \"Dark gray\", value: \"#525252\" },\n { label: \"Gray\", value: \"#a3a3a3\" },\n { label: \"White\", value: \"#ffffff\" },\n { label: \"Red\", value: \"#ef4444\" },\n { label: \"Orange\", value: \"#f97316\" },\n { label: \"Yellow\", value: \"#eab308\" },\n { label: \"Green\", value: \"#22c55e\" },\n { label: \"Cyan\", value: \"#06b6d4\" },\n { label: \"Blue\", value: \"#3b82f6\" },\n { label: \"Violet\", value: \"#8b5cf6\" },\n { label: \"Pink\", value: \"#ec4899\" },\n { label: \"Light red\", value: \"#fca5a5\" },\n { label: \"Light yellow\", value: \"#fde68a\" },\n { label: \"Light green\", value: \"#bbf7d0\" },\n { label: \"Light blue\", value: \"#bfdbfe\" },\n];\n", "path": "registry/pytah/editor/components/editor/core/colors.ts", "target": "src/components/editor/core/colors.ts", "type": "registry:file" }, { "content": "import { deepStrictEqual, strictEqual } from \"node:assert/strict\";\nimport { describe, test } from \"node:test\";\nimport {\n DEFAULT_EDITOR_CHROME,\n DEFAULT_EDITOR_FEATURES,\n renderEditorSlot,\n resolveEditorChrome,\n resolveEditorFeatures,\n shouldRenderEditorShell,\n} from \"./composition\";\n\ndescribe(\"editor composition\", () => {\n test(\"resolves feature flags over defaults\", () => {\n deepStrictEqual(resolveEditorFeatures(), DEFAULT_EDITOR_FEATURES);\n deepStrictEqual(\n resolveEditorFeatures({\n floatingToolbar: false,\n history: false,\n }),\n {\n ...DEFAULT_EDITOR_FEATURES,\n floatingToolbar: false,\n history: false,\n }\n );\n });\n\n test(\"resolves chrome options over defaults\", () => {\n deepStrictEqual(resolveEditorChrome(), DEFAULT_EDITOR_CHROME);\n deepStrictEqual(resolveEditorChrome({ header: false, outputs: false }), {\n ...DEFAULT_EDITOR_CHROME,\n header: false,\n outputs: false,\n });\n });\n\n test(\"renders slots as values or callbacks\", () => {\n strictEqual(renderEditorSlot(\"header\", { value: 1 }), \"header\");\n strictEqual(\n renderEditorSlot(({ value }: { value: number }) => value * 2, {\n value: 3,\n }),\n 6\n );\n strictEqual(renderEditorSlot(undefined, { value: 1 }), undefined);\n });\n\n test(\"allows custom shell slot in minimal mode\", () => {\n strictEqual(\n shouldRenderEditorShell({\n chromeShell: false,\n minimal: true,\n shellSlot: \"custom-shell\",\n }),\n true\n );\n strictEqual(\n shouldRenderEditorShell({\n chromeShell: true,\n minimal: true,\n shellSlot: undefined,\n }),\n false\n );\n });\n});\n", "path": "registry/pytah/editor/components/editor/core/composition.test.ts", "target": "src/components/editor/core/composition.test.ts", "type": "registry:file" }, { "content": "import type { ReactNode } from \"react\";\nimport type { EditorChromeOptions, EditorFeatureFlags } from \"./types\";\n\nexport const DEFAULT_EDITOR_FEATURES = {\n collapsible: true,\n draggableBlocks: true,\n floatingLinkEditor: true,\n floatingToolbar: true,\n focusOnMount: true,\n history: true,\n images: true,\n layouts: true,\n markdownShortcuts: true,\n seedContent: true,\n slashCommand: true,\n tabIndentation: true,\n tables: true,\n youtube: true,\n} as const satisfies Required;\n\nexport const DEFAULT_EDITOR_CHROME = {\n actionBar: true,\n footer: true,\n header: true,\n outputs: true,\n shell: true,\n} as const satisfies Required;\n\nexport type ResolvedEditorFeatureFlags = Required;\nexport type ResolvedEditorChromeOptions = Required;\n\nexport const resolveEditorFeatures = (\n features?: EditorFeatureFlags\n): ResolvedEditorFeatureFlags => {\n return {\n ...DEFAULT_EDITOR_FEATURES,\n ...features,\n };\n};\n\nexport const resolveEditorChrome = (\n chrome?: EditorChromeOptions\n): ResolvedEditorChromeOptions => {\n return {\n ...DEFAULT_EDITOR_CHROME,\n ...chrome,\n };\n};\n\nexport const renderEditorSlot = (\n slot: ReactNode | ((context: T) => ReactNode) | undefined,\n context: T\n): ReactNode | undefined => {\n if (typeof slot === \"function\") {\n return slot(context);\n }\n\n return slot;\n};\n\nexport const shouldRenderEditorShell = ({\n chromeShell,\n minimal,\n shellSlot,\n}: {\n chromeShell: boolean;\n minimal: boolean;\n shellSlot:\n | ReactNode\n | ((context: { children: ReactNode }) => ReactNode)\n | undefined;\n}) => {\n if (shellSlot !== undefined) {\n return true;\n }\n\n if (minimal) {\n return false;\n }\n\n return chromeShell;\n};\n", "path": "registry/pytah/editor/components/editor/core/composition.ts", "target": "src/components/editor/core/composition.ts", "type": "registry:file" }, { "content": "import { deepStrictEqual, strictEqual } from \"node:assert/strict\";\nimport { describe, test } from \"node:test\";\nimport { ParagraphNode } from \"lexical\";\nimport { createEditorConfig, DEFAULT_EDITOR_NODES } from \"./config\";\n\ndescribe(\"editor config\", () => {\n test(\"builds config with defaults\", () => {\n const config = createEditorConfig({ editable: true });\n\n strictEqual(config.editable, true);\n strictEqual(config.namespace, \"PytahEditor\");\n deepStrictEqual(config.nodes, DEFAULT_EDITOR_NODES);\n strictEqual(typeof config.onError, \"function\");\n });\n\n test(\"appends extra nodes without dropping built-ins\", () => {\n const config = createEditorConfig({\n editable: false,\n namespace: \"CustomEditor\",\n nodes: [ParagraphNode],\n });\n\n strictEqual(config.editable, false);\n strictEqual(config.namespace, \"CustomEditor\");\n strictEqual(config.nodes?.length, DEFAULT_EDITOR_NODES.length + 1);\n strictEqual(config.nodes?.at(-1), ParagraphNode);\n });\n});\n", "path": "registry/pytah/editor/components/editor/core/config.test.ts", "target": "src/components/editor/core/config.test.ts", "type": "registry:file" }, { "content": "import { CodeHighlightNode, CodeNode } from \"@lexical/code\";\nimport { HorizontalRuleNode } from \"@lexical/extension\";\nimport { AutoLinkNode, LinkNode } from \"@lexical/link\";\nimport { ListItemNode, ListNode } from \"@lexical/list\";\nimport type { InitialConfigType } from \"@lexical/react/LexicalComposer\";\nimport { HeadingNode, QuoteNode } from \"@lexical/rich-text\";\nimport { TableCellNode, TableNode, TableRowNode } from \"@lexical/table\";\nimport { CollapsibleContainerNode } from \"./nodes/collapsible/container-node\";\nimport { CollapsibleContentNode } from \"./nodes/collapsible/content-node\";\nimport { CollapsibleTitleNode } from \"./nodes/collapsible/title-node\";\nimport { ImageNode } from \"./nodes/image/node\";\nimport { LayoutContainerNode } from \"./nodes/layout/container-node\";\nimport { LayoutItemNode } from \"./nodes/layout/item-node\";\nimport { YouTubeNode } from \"./nodes/youtube/node\";\nimport { editorTheme } from \"./theme\";\n\nfunction onError(error: Error) {\n console.error(\"[Editor]\", error);\n}\n\nexport const DEFAULT_EDITOR_NODES = [\n HeadingNode,\n QuoteNode,\n ListNode,\n ListItemNode,\n CodeNode,\n CodeHighlightNode,\n LinkNode,\n AutoLinkNode,\n HorizontalRuleNode,\n ImageNode,\n YouTubeNode,\n CollapsibleContainerNode,\n CollapsibleTitleNode,\n CollapsibleContentNode,\n LayoutContainerNode,\n LayoutItemNode,\n TableNode,\n TableRowNode,\n TableCellNode,\n];\n\ninterface CreateEditorConfigOptions {\n editable: boolean;\n namespace?: string;\n nodes?: NonNullable;\n}\n\nexport const createEditorConfig = ({\n editable,\n namespace = \"PytahEditor\",\n nodes,\n}: CreateEditorConfigOptions): InitialConfigType => {\n return {\n editable,\n namespace,\n nodes: nodes ? [...DEFAULT_EDITOR_NODES, ...nodes] : DEFAULT_EDITOR_NODES,\n onError,\n theme: editorTheme,\n };\n};\n", "path": "registry/pytah/editor/components/editor/core/config.ts", "target": "src/components/editor/core/config.ts", "type": "registry:file" }, { "content": "import {\n FileTextIcon,\n KeyboardIcon,\n ListTreeIcon,\n SparklesIcon,\n} from \"lucide-react\";\n\nexport const DEFAULT_PLACEHOLDER =\n \"Type / for commands, or just start writing...\";\n\nexport const WORD_SEPARATOR_PATTERN = /\\s+/;\n\nexport const FEATURE_ITEMS = [\n {\n icon: SparklesIcon,\n label: \"Slash commands\",\n description: \"Quick insert menu inspired by Notion.\",\n },\n {\n icon: KeyboardIcon,\n label: \"Markdown shortcuts\",\n description: \"Type #, -, > and more to format while writing.\",\n },\n {\n icon: ListTreeIcon,\n label: \"Block toolbar\",\n description: \"Change block type, alignment and indentation fast.\",\n },\n {\n icon: FileTextIcon,\n label: \"Markdown export\",\n description: \"Always keep HTML, markdown and plain text in sync.\",\n },\n] as const;\n\nexport const DEFAULT_EDITOR_MARKDOWN = [\n \"# Welcome to Pytah\",\n \"\",\n \"A **copy-paste-ready** editor built with [Lexical](https://lexical.dev), shadcn and Base UI. Everything below was loaded from a single markdown string.\",\n \"\",\n \"## Rich text formatting\",\n \"\",\n \"Highlight any text to open the floating toolbar, or use markdown shortcuts while you type:\",\n \"\",\n \"- **Bold text** with `**double asterisks**`\",\n \"- *Italic text* with `*single asterisks*`\",\n \"- ~~Strikethrough~~ with `~~tildes~~`\",\n \"- ==Highlighted text== with `==double equals==`\",\n \"- `Inline code` with `` `backticks` ``\",\n \"- [Links](https://github.com) with `[text](url)`\",\n \"\",\n \"## Block types\",\n \"\",\n \"### Bullet lists\",\n \"\",\n \"- First item with some detail\",\n \"- Second item — lists support **rich** *formatting* too\",\n \"- Third item\",\n \"\",\n \"### Numbered lists\",\n \"\",\n \"1. Open the slash command menu by typing `/`\",\n \"1. Pick a block type from the list\",\n \"1. Keep writing — the block converts instantly\",\n \"\",\n \"### Checklists\",\n \"\",\n \"- [x] Set up the editor\",\n \"- [x] Add slash commands\",\n \"- [ ] Ship to production\",\n \"\",\n \"> **Tip:** Type `>` at the start of a line for a blockquote, or `---` for a divider.\",\n \"\",\n \"---\",\n \"\",\n \"## Code blocks\",\n \"\",\n \"Fenced code blocks support syntax highlighting. Type ` ``` ` followed by a language hint:\",\n \"\",\n \"```tsx\",\n 'import { Editor } from \"@/components/editor/editor\";',\n \"\",\n \"export function App() {\",\n \" return ;\",\n \"}\",\n \"```\",\n \"\",\n \"## Tables\",\n \"\",\n \"| Feature | Shortcut | Status |\",\n \"| --- | --- | --- |\",\n \"| Headings | `# `, `## `, `### ` | Ready |\",\n \"| Lists | `- `, `1. `, `- [ ] ` | Ready |\",\n \"| Code blocks | ` ``` ` | Ready |\",\n \"| Images | `![alt](url)` | Ready |\",\n \"| Tables | Markdown GFM syntax | Ready |\",\n \"\",\n \"## Images\",\n \"\",\n \"![Pytah editor](https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&q=80)\",\n \"\",\n \"## More blocks\",\n \"\",\n \"Type `/` on an empty line to insert **collapsible toggles**, **multi-column layouts**, **YouTube embeds**, and more. The slash menu supports fuzzy search — try `/col` or `/vid`.\",\n \"\",\n \"---\",\n \"\",\n \"*Built with Lexical, shadcn, Base UI and Tailwind CSS v4.*\",\n].join(\"\\n\");\n\nexport const MARKDOWN_EXAMPLE = [\n \"# Markdown import\",\n \"\",\n \"Paste markdown here and keep editing visually.\",\n \"\",\n \"- bullet one\",\n \"- bullet two\",\n \"\",\n \"```ts\",\n \"const ready = true;\",\n \"```\",\n].join(\"\\n\");\n\nexport const HTML_EXAMPLE = [\n \"

Imported HTML

\",\n \"

This content was inserted as HTML and normalized by Lexical.

\",\n \"
  • Lists stay editable
  • Formatting remains semantic
\",\n \"
Great editors should be easy to paste into and out of.
\",\n].join(\"\");\n", "path": "registry/pytah/editor/components/editor/core/constants.ts", "target": "src/components/editor/core/constants.ts", "type": "registry:file" }, { "content": "import { addClassNamesToElement, IS_CHROME } from \"@lexical/utils\";\nimport {\n $getSiblingCaret,\n $isElementNode,\n $rewindSiblingCaret,\n type DOMConversionMap,\n type DOMConversionOutput,\n type DOMExportOutput,\n type EditorConfig,\n ElementNode,\n isHTMLElement,\n type LexicalEditor,\n type LexicalNode,\n type LexicalUpdateJSON,\n type NodeKey,\n type RangeSelection,\n type SerializedElementNode,\n type Spread,\n} from \"lexical\";\nimport { setDomHiddenUntilFound } from \"./dom-utils\";\n\nexport type SerializedCollapsibleContainerNode = Spread<\n {\n open: boolean;\n },\n SerializedElementNode\n>;\n\nconst applyContainerOpenState = (dom: HTMLElement, open: boolean) => {\n dom.dataset.open = open ? \"true\" : \"false\";\n\n const titleDom = dom.firstElementChild;\n if (isHTMLElement(titleDom)) {\n titleDom.dataset.open = open ? \"true\" : \"false\";\n }\n};\n\nconst convertDetailsElement = (domNode: HTMLElement): DOMConversionOutput => {\n const detailsElement = domNode as HTMLDetailsElement;\n return {\n node: $createCollapsibleContainerNode(detailsElement.open ?? true),\n };\n};\n\nconst convertCollapsibleContainerElement = (\n domNode: HTMLElement\n): DOMConversionOutput => {\n return {\n node: $createCollapsibleContainerNode(domNode.dataset.open !== \"false\"),\n };\n};\n\nexport class CollapsibleContainerNode extends ElementNode {\n __open: boolean;\n\n constructor(open: boolean, key?: NodeKey) {\n super(key);\n this.__open = open;\n }\n\n static getType(): string {\n return \"collapsible-container\";\n }\n\n static clone(node: CollapsibleContainerNode): CollapsibleContainerNode {\n return new CollapsibleContainerNode(node.__open, node.__key);\n }\n\n static importDOM(): DOMConversionMap | null {\n return {\n details: () => ({\n conversion: convertDetailsElement,\n priority: 1,\n }),\n div: (domNode: HTMLElement) => {\n if (!domNode.hasAttribute(\"data-lexical-collapsible-container\")) {\n return null;\n }\n\n return {\n conversion: convertCollapsibleContainerElement,\n priority: 2,\n };\n },\n };\n }\n\n static importJSON(\n serializedNode: SerializedCollapsibleContainerNode\n ): CollapsibleContainerNode {\n return $createCollapsibleContainerNode().updateFromJSON(serializedNode);\n }\n\n createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {\n let dom: HTMLElement;\n\n if (IS_CHROME) {\n dom = document.createElement(\"div\");\n } else {\n const detailsDom = document.createElement(\"details\");\n detailsDom.addEventListener(\"toggle\", () => {\n const open = editor.getEditorState().read(() => this.getOpen());\n if (detailsDom.open !== open) {\n editor.update(() => {\n this.toggleOpen();\n });\n }\n });\n dom = detailsDom;\n }\n\n dom.setAttribute(\"data-lexical-collapsible-container\", \"true\");\n applyContainerOpenState(dom, this.__open);\n\n if (this.__open) {\n dom.setAttribute(\"open\", \"\");\n }\n\n if (typeof config.theme.collapsibleContainer === \"string\") {\n addClassNamesToElement(dom, config.theme.collapsibleContainer);\n }\n\n return dom;\n }\n\n updateDOM(prevNode: this, dom: HTMLElement): boolean {\n if (prevNode.__open === this.__open) {\n return false;\n }\n\n applyContainerOpenState(dom, this.__open);\n\n if (this.__open) {\n dom.setAttribute(\"open\", \"\");\n } else {\n dom.removeAttribute(\"open\");\n }\n\n if (IS_CHROME) {\n const contentDom = dom.children[1];\n if (!isHTMLElement(contentDom)) {\n throw new Error(\"Expected collapsible content DOM element\");\n }\n\n if (this.__open) {\n contentDom.hidden = false;\n } else {\n setDomHiddenUntilFound(contentDom);\n }\n } else if (dom instanceof HTMLDetailsElement) {\n dom.open = this.__open;\n }\n\n return false;\n }\n\n exportDOM(): DOMExportOutput {\n const element = document.createElement(\"details\");\n element.setAttribute(\"data-lexical-collapsible-container\", \"true\");\n element.dataset.open = this.__open ? \"true\" : \"false\";\n\n if (this.__open) {\n element.setAttribute(\"open\", \"\");\n }\n\n return { element };\n }\n\n exportJSON(): SerializedCollapsibleContainerNode {\n return {\n ...super.exportJSON(),\n open: this.__open,\n type: \"collapsible-container\",\n version: 1,\n };\n }\n\n updateFromJSON(\n serializedNode: LexicalUpdateJSON\n ): this {\n return super.updateFromJSON(serializedNode).setOpen(serializedNode.open);\n }\n\n collapseAtStart(selection: RangeSelection): boolean {\n const nodesToInsert: LexicalNode[] = [];\n\n for (const child of this.getChildren()) {\n if ($isElementNode(child)) {\n nodesToInsert.push(...child.getChildren());\n }\n }\n\n const caret = $rewindSiblingCaret($getSiblingCaret(this, \"previous\"));\n caret.splice(1, nodesToInsert);\n\n const [firstChild] = nodesToInsert;\n if (firstChild) {\n firstChild.selectStart().deleteCharacter(true);\n }\n\n return selection.isCollapsed();\n }\n\n isShadowRoot(): boolean {\n return true;\n }\n\n canBeEmpty(): boolean {\n return false;\n }\n\n getOpen(): boolean {\n return this.getLatest().__open;\n }\n\n setOpen(open: boolean): this {\n const writable = this.getWritable();\n writable.__open = open;\n return writable;\n }\n\n toggleOpen(): this {\n return this.setOpen(!this.getOpen());\n }\n}\n\nexport function $createCollapsibleContainerNode(\n open = true\n): CollapsibleContainerNode {\n return new CollapsibleContainerNode(open);\n}\n\nexport function $isCollapsibleContainerNode(\n node: LexicalNode | null | undefined\n): node is CollapsibleContainerNode {\n return node instanceof CollapsibleContainerNode;\n}\n", "path": "registry/pytah/editor/components/editor/core/nodes/collapsible/container-node.ts", "target": "src/components/editor/core/nodes/collapsible/container-node.ts", "type": "registry:file" }, { "content": "import { addClassNamesToElement, IS_CHROME } from \"@lexical/utils\";\nimport type {\n DOMConversionMap,\n DOMConversionOutput,\n DOMExportOutput,\n EditorConfig,\n LexicalEditor,\n LexicalNode,\n SerializedElementNode,\n} from \"lexical\";\nimport { ElementNode } from \"lexical\";\nimport { $isCollapsibleContainerNode } from \"./container-node\";\nimport { domOnBeforeMatch, setDomHiddenUntilFound } from \"./dom-utils\";\n\nexport type SerializedCollapsibleContentNode = SerializedElementNode;\n\nconst convertCollapsibleContentElement = (): DOMConversionOutput => {\n return {\n node: $createCollapsibleContentNode(),\n };\n};\n\nexport class CollapsibleContentNode extends ElementNode {\n static getType(): string {\n return \"collapsible-content\";\n }\n\n static clone(node: CollapsibleContentNode): CollapsibleContentNode {\n return new CollapsibleContentNode(node.__key);\n }\n\n static importDOM(): DOMConversionMap | null {\n return {\n div: (domNode: HTMLElement) => {\n if (!domNode.hasAttribute(\"data-lexical-collapsible-content\")) {\n return null;\n }\n\n return {\n conversion: convertCollapsibleContentElement,\n priority: 2,\n };\n },\n };\n }\n\n static importJSON(\n serializedNode: SerializedCollapsibleContentNode\n ): CollapsibleContentNode {\n return $createCollapsibleContentNode().updateFromJSON(serializedNode);\n }\n\n createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {\n const dom = document.createElement(\"div\");\n dom.setAttribute(\"data-lexical-collapsible-content\", \"true\");\n\n if (typeof config.theme.collapsibleContent === \"string\") {\n addClassNamesToElement(dom, config.theme.collapsibleContent);\n }\n\n if (IS_CHROME) {\n editor.getEditorState().read(() => {\n const containerNode = this.getParentOrThrow();\n if (!$isCollapsibleContainerNode(containerNode)) {\n throw new Error(\n \"Collapsible content expects a collapsible container\"\n );\n }\n\n if (!containerNode.getOpen()) {\n setDomHiddenUntilFound(dom);\n }\n });\n\n domOnBeforeMatch(dom, () => {\n editor.update(() => {\n const containerNode = this.getParentOrThrow().getLatest();\n if (!$isCollapsibleContainerNode(containerNode)) {\n throw new Error(\n \"Collapsible content expects a collapsible container\"\n );\n }\n\n if (!containerNode.getOpen()) {\n containerNode.toggleOpen();\n }\n });\n });\n }\n\n return dom;\n }\n\n updateDOM(): boolean {\n return false;\n }\n\n exportDOM(): DOMExportOutput {\n const element = document.createElement(\"div\");\n element.setAttribute(\"data-lexical-collapsible-content\", \"true\");\n return { element };\n }\n\n exportJSON(): SerializedCollapsibleContentNode {\n return {\n ...super.exportJSON(),\n type: \"collapsible-content\",\n version: 1,\n };\n }\n\n isShadowRoot(): boolean {\n return true;\n }\n\n canBeEmpty(): boolean {\n return false;\n }\n}\n\nexport function $createCollapsibleContentNode(): CollapsibleContentNode {\n return new CollapsibleContentNode();\n}\n\nexport function $isCollapsibleContentNode(\n node: LexicalNode | null | undefined\n): node is CollapsibleContentNode {\n return node instanceof CollapsibleContentNode;\n}\n", "path": "registry/pytah/editor/components/editor/core/nodes/collapsible/content-node.ts", "target": "src/components/editor/core/nodes/collapsible/content-node.ts", "type": "registry:file" }, { "content": "export const setDomHiddenUntilFound = (dom: HTMLElement) => {\n dom.setAttribute(\"hidden\", \"until-found\");\n};\n\nexport const domOnBeforeMatch = (dom: HTMLElement, callback: () => void) => {\n const beforeMatchDom = dom as HTMLElement & {\n onbeforematch: null | (() => void);\n };\n beforeMatchDom.onbeforematch = callback;\n};\n", "path": "registry/pytah/editor/components/editor/core/nodes/collapsible/dom-utils.ts", "target": "src/components/editor/core/nodes/collapsible/dom-utils.ts", "type": "registry:file" }, { "content": "import { addClassNamesToElement, IS_CHROME } from \"@lexical/utils\";\nimport type {\n DOMConversionMap,\n DOMConversionOutput,\n DOMExportOutput,\n EditorConfig,\n LexicalEditor,\n LexicalNode,\n RangeSelection,\n SerializedElementNode,\n} from \"lexical\";\nimport { $createParagraphNode, $isElementNode, ElementNode } from \"lexical\";\nimport { $isCollapsibleContainerNode } from \"./container-node\";\nimport { $isCollapsibleContentNode } from \"./content-node\";\n\nexport type SerializedCollapsibleTitleNode = SerializedElementNode;\n\nconst convertSummaryElement = (): DOMConversionOutput => {\n return {\n node: $createCollapsibleTitleNode(),\n };\n};\n\nexport class CollapsibleTitleNode extends ElementNode {\n static getType(): string {\n return \"collapsible-title\";\n }\n\n static clone(node: CollapsibleTitleNode): CollapsibleTitleNode {\n return new CollapsibleTitleNode(node.__key);\n }\n\n static importDOM(): DOMConversionMap | null {\n return {\n summary: () => ({\n conversion: convertSummaryElement,\n priority: 1,\n }),\n };\n }\n\n static importJSON(\n serializedNode: SerializedCollapsibleTitleNode\n ): CollapsibleTitleNode {\n return $createCollapsibleTitleNode().updateFromJSON(serializedNode);\n }\n\n createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {\n const dom = document.createElement(\"summary\");\n dom.setAttribute(\"data-lexical-collapsible-trigger\", \"true\");\n\n const open = editor.getEditorState().read(() => {\n const container = this.getParent();\n return $isCollapsibleContainerNode(container)\n ? container.getOpen()\n : true;\n });\n dom.dataset.open = open ? \"true\" : \"false\";\n\n if (typeof config.theme.collapsibleTitle === \"string\") {\n addClassNamesToElement(dom, config.theme.collapsibleTitle);\n }\n\n if (IS_CHROME) {\n dom.addEventListener(\"click\", () => {\n editor.update(() => {\n const container = this.getLatest().getParentOrThrow();\n if (!$isCollapsibleContainerNode(container)) {\n throw new Error(\n \"Collapsible title expects a collapsible container\"\n );\n }\n\n container.toggleOpen();\n });\n });\n }\n\n return dom;\n }\n\n updateDOM(): boolean {\n return false;\n }\n\n exportDOM(): DOMExportOutput {\n return { element: document.createElement(\"summary\") };\n }\n\n exportJSON(): SerializedCollapsibleTitleNode {\n return {\n ...super.exportJSON(),\n type: \"collapsible-title\",\n version: 1,\n };\n }\n\n insertNewAfter(_: RangeSelection, restoreSelection = true): ElementNode {\n const containerNode = this.getParentOrThrow();\n if (!$isCollapsibleContainerNode(containerNode)) {\n throw new Error(\"Collapsible title expects a collapsible container\");\n }\n\n if (containerNode.getOpen()) {\n const contentNode = this.getNextSibling();\n if (!$isCollapsibleContentNode(contentNode)) {\n throw new Error(\n \"Collapsible title expects a collapsible content sibling\"\n );\n }\n\n const firstChild = contentNode.getFirstChild();\n if ($isElementNode(firstChild)) {\n return firstChild;\n }\n\n const paragraph = $createParagraphNode();\n contentNode.append(paragraph);\n return paragraph;\n }\n\n const paragraph = $createParagraphNode();\n containerNode.insertAfter(paragraph, restoreSelection);\n return paragraph;\n }\n\n canBeEmpty(): boolean {\n return false;\n }\n}\n\nexport function $createCollapsibleTitleNode(): CollapsibleTitleNode {\n return new CollapsibleTitleNode();\n}\n\nexport function $isCollapsibleTitleNode(\n node: LexicalNode | null | undefined\n): node is CollapsibleTitleNode {\n return node instanceof CollapsibleTitleNode;\n}\n", "path": "registry/pytah/editor/components/editor/core/nodes/collapsible/title-node.ts", "target": "src/components/editor/core/nodes/collapsible/title-node.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { addClassNamesToElement } from \"@lexical/utils\";\nimport type {\n DOMConversionMap,\n DOMConversionOutput,\n DOMExportOutput,\n EditorConfig,\n LexicalNode,\n LexicalUpdateJSON,\n NodeKey,\n SerializedLexicalNode,\n Spread,\n} from \"lexical\";\nimport { DecoratorNode } from \"lexical\";\nimport type { JSX } from \"react\";\nimport { ImageComponent } from \"../../../plugins/image/component\";\n\nexport type SerializedImageNode = Spread<\n {\n alignment?: ImageAlignment;\n altText: string;\n height: number | \"inherit\";\n src: string;\n width: number | \"inherit\";\n },\n SerializedLexicalNode\n>;\n\nexport type ImageAlignment = \"left\" | \"center\" | \"right\";\n\nexport interface ImagePayload {\n alignment?: ImageAlignment;\n altText: string;\n height?: number | \"inherit\";\n key?: NodeKey;\n src: string;\n width?: number | \"inherit\";\n}\n\nconst getImageAlignment = (domNode: HTMLImageElement): ImageAlignment => {\n const align = domNode.getAttribute(\"align\");\n\n if (align === \"center\" || align === \"right\" || align === \"left\") {\n return align;\n }\n\n const { marginLeft, marginRight } = domNode.style;\n\n if (marginLeft === \"auto\" && marginRight === \"auto\") {\n return \"center\";\n }\n\n if (marginLeft === \"auto\") {\n return \"right\";\n }\n\n return \"left\";\n};\n\nconst convertImageElement = (domNode: Node): DOMConversionOutput | null => {\n if (!(domNode instanceof HTMLImageElement)) {\n return null;\n }\n\n const src = domNode.getAttribute(\"src\");\n if (!src || src.startsWith(\"file:///\")) {\n return null;\n }\n\n return {\n node: $createImageNode({\n alignment: getImageAlignment(domNode),\n altText: domNode.getAttribute(\"alt\") ?? \"\",\n height: domNode.height || undefined,\n src,\n width: domNode.width || undefined,\n }),\n };\n};\n\nexport class ImageNode extends DecoratorNode {\n __alignment: ImageAlignment;\n __altText: string;\n __height: number | \"inherit\";\n __src: string;\n __width: number | \"inherit\";\n\n constructor(\n src: string,\n altText: string,\n alignment: ImageAlignment = \"left\",\n width: number | \"inherit\" = \"inherit\",\n height: number | \"inherit\" = \"inherit\",\n key?: NodeKey\n ) {\n super(key);\n this.__src = src;\n this.__altText = altText;\n this.__alignment = alignment;\n this.__width = width;\n this.__height = height;\n }\n\n static getType(): string {\n return \"image\";\n }\n\n static clone(node: ImageNode): ImageNode {\n return new ImageNode(\n node.__src,\n node.__altText,\n node.__alignment,\n node.__width,\n node.__height,\n node.__key\n );\n }\n\n static importDOM(): DOMConversionMap | null {\n return {\n img: () => ({\n conversion: convertImageElement,\n priority: 2,\n }),\n };\n }\n\n static importJSON(serializedNode: SerializedImageNode): ImageNode {\n return $createImageNode({\n alignment: serializedNode.alignment,\n altText: serializedNode.altText,\n height: serializedNode.height,\n src: serializedNode.src,\n width: serializedNode.width,\n }).updateFromJSON(serializedNode);\n }\n\n updateFromJSON(serializedNode: LexicalUpdateJSON): this {\n return super\n .updateFromJSON(serializedNode)\n .setAlignment(serializedNode.alignment ?? \"left\")\n .setAltText(serializedNode.altText)\n .setHeight(serializedNode.height)\n .setSrc(serializedNode.src)\n .setWidth(serializedNode.width);\n }\n\n exportDOM(): DOMExportOutput {\n const element = document.createElement(\"img\");\n element.setAttribute(\"src\", this.__src);\n element.setAttribute(\"alt\", this.__altText);\n element.style.display = \"block\";\n\n if (this.__alignment === \"center\") {\n element.style.marginLeft = \"auto\";\n element.style.marginRight = \"auto\";\n } else if (this.__alignment === \"right\") {\n element.style.marginLeft = \"auto\";\n element.style.marginRight = \"0\";\n }\n\n if (this.__width !== \"inherit\") {\n element.width = this.__width;\n }\n\n if (this.__height !== \"inherit\") {\n element.height = this.__height;\n }\n\n return { element };\n }\n\n exportJSON(): SerializedImageNode {\n return {\n ...super.exportJSON(),\n alignment: this.__alignment,\n altText: this.__altText,\n height: this.__height,\n src: this.__src,\n type: \"image\",\n version: 1,\n width: this.__width,\n };\n }\n\n createDOM(config: EditorConfig): HTMLElement {\n const span = document.createElement(\"span\");\n\n if (typeof config.theme.image === \"string\") {\n addClassNamesToElement(span, config.theme.image);\n }\n\n return span;\n }\n\n updateDOM(): false {\n return false;\n }\n\n decorate(): JSX.Element {\n return (\n \n );\n }\n\n isInline(): false {\n return false;\n }\n\n getSrc(): string {\n return this.getLatest().__src;\n }\n\n getAltText(): string {\n return this.getLatest().__altText;\n }\n\n getAlignment(): ImageAlignment {\n return this.getLatest().__alignment;\n }\n\n getWidth(): number | \"inherit\" {\n return this.getLatest().__width;\n }\n\n getHeight(): number | \"inherit\" {\n return this.getLatest().__height;\n }\n\n setAltText(altText: string): this {\n const writable = this.getWritable();\n writable.__altText = altText;\n return writable;\n }\n\n setAlignment(alignment: ImageAlignment): this {\n const writable = this.getWritable();\n writable.__alignment = alignment;\n return writable;\n }\n\n setWidth(width: number | \"inherit\"): this {\n const writable = this.getWritable();\n writable.__width = width;\n return writable;\n }\n\n setWidthAndHeight(\n width: number | \"inherit\",\n height: number | \"inherit\"\n ): this {\n const writable = this.getWritable();\n writable.__width = width;\n writable.__height = height;\n return writable;\n }\n\n setHeight(height: number | \"inherit\"): this {\n const writable = this.getWritable();\n writable.__height = height;\n return writable;\n }\n\n setSrc(src: string): this {\n const writable = this.getWritable();\n writable.__src = src;\n return writable;\n }\n}\n\nexport function $createImageNode({\n alignment,\n altText,\n height,\n key,\n src,\n width,\n}: ImagePayload): ImageNode {\n return new ImageNode(\n src,\n altText,\n alignment ?? \"left\",\n width ?? \"inherit\",\n height ?? \"inherit\",\n key\n );\n}\n\nexport function $isImageNode(\n node: LexicalNode | null | undefined\n): node is ImageNode {\n return node instanceof ImageNode;\n}\n", "path": "registry/pytah/editor/components/editor/core/nodes/image/node.tsx", "target": "src/components/editor/core/nodes/image/node.tsx", "type": "registry:file" }, { "content": "import { addClassNamesToElement } from \"@lexical/utils\";\nimport type {\n DOMConversionMap,\n DOMConversionOutput,\n DOMExportOutput,\n EditorConfig,\n LexicalNode,\n LexicalUpdateJSON,\n NodeKey,\n SerializedElementNode,\n Spread,\n} from \"lexical\";\nimport { ElementNode } from \"lexical\";\n\nexport type SerializedLayoutContainerNode = Spread<\n {\n templateColumns: string;\n },\n SerializedElementNode\n>;\n\nconst convertLayoutContainerElement = (\n domNode: HTMLElement\n): DOMConversionOutput | null => {\n const templateColumns = domNode.style.gridTemplateColumns;\n if (!templateColumns) {\n return null;\n }\n\n return {\n node: $createLayoutContainerNode(templateColumns),\n };\n};\n\nexport class LayoutContainerNode extends ElementNode {\n __templateColumns: string;\n\n constructor(templateColumns: string, key?: NodeKey) {\n super(key);\n this.__templateColumns = templateColumns;\n }\n\n static getType(): string {\n return \"layout-container\";\n }\n\n static clone(node: LayoutContainerNode): LayoutContainerNode {\n return new LayoutContainerNode(node.__templateColumns, node.__key);\n }\n\n static importDOM(): DOMConversionMap | null {\n return {\n div: (domNode: HTMLElement) => {\n if (!domNode.hasAttribute(\"data-lexical-layout-container\")) {\n return null;\n }\n\n return {\n conversion: convertLayoutContainerElement,\n priority: 2,\n };\n },\n };\n }\n\n static importJSON(\n serializedNode: SerializedLayoutContainerNode\n ): LayoutContainerNode {\n return $createLayoutContainerNode().updateFromJSON(serializedNode);\n }\n\n createDOM(config: EditorConfig): HTMLElement {\n const dom = document.createElement(\"div\");\n dom.setAttribute(\"data-lexical-layout-container\", \"true\");\n dom.style.gridTemplateColumns = this.__templateColumns;\n\n if (typeof config.theme.layoutContainer === \"string\") {\n addClassNamesToElement(dom, config.theme.layoutContainer);\n }\n\n return dom;\n }\n\n updateDOM(prevNode: this, dom: HTMLElement): boolean {\n if (prevNode.__templateColumns !== this.__templateColumns) {\n dom.style.gridTemplateColumns = this.__templateColumns;\n }\n\n return false;\n }\n\n exportDOM(): DOMExportOutput {\n const element = document.createElement(\"div\");\n element.setAttribute(\"data-lexical-layout-container\", \"true\");\n element.style.gridTemplateColumns = this.__templateColumns;\n\n return { element };\n }\n\n exportJSON(): SerializedLayoutContainerNode {\n return {\n ...super.exportJSON(),\n templateColumns: this.__templateColumns,\n type: \"layout-container\",\n version: 1,\n };\n }\n\n updateFromJSON(\n serializedNode: LexicalUpdateJSON\n ): this {\n return super\n .updateFromJSON(serializedNode)\n .setTemplateColumns(serializedNode.templateColumns);\n }\n\n getTemplateColumns(): string {\n return this.getLatest().__templateColumns;\n }\n\n setTemplateColumns(templateColumns: string): this {\n const self = this.getWritable();\n self.__templateColumns = templateColumns;\n return self;\n }\n\n isShadowRoot(): boolean {\n return true;\n }\n\n canBeEmpty(): boolean {\n return false;\n }\n}\n\nexport function $createLayoutContainerNode(\n templateColumns = \"\"\n): LayoutContainerNode {\n return new LayoutContainerNode(templateColumns);\n}\n\nexport function $isLayoutContainerNode(\n node: LexicalNode | null | undefined\n): node is LayoutContainerNode {\n return node instanceof LayoutContainerNode;\n}\n", "path": "registry/pytah/editor/components/editor/core/nodes/layout/container-node.ts", "target": "src/components/editor/core/nodes/layout/container-node.ts", "type": "registry:file" }, { "content": "import { addClassNamesToElement } from \"@lexical/utils\";\nimport type {\n DOMConversionMap,\n DOMConversionOutput,\n EditorConfig,\n LexicalNode,\n SerializedElementNode,\n} from \"lexical\";\nimport { $isParagraphNode, ElementNode } from \"lexical\";\n\nexport type SerializedLayoutItemNode = SerializedElementNode;\n\nconst convertLayoutItemElement = (): DOMConversionOutput => {\n return {\n node: $createLayoutItemNode(),\n };\n};\n\nexport function $isEmptyLayoutItemNode(node: LexicalNode): boolean {\n if (!$isLayoutItemNode(node) || node.getChildrenSize() !== 1) {\n return false;\n }\n\n const firstChild = node.getFirstChild();\n return $isParagraphNode(firstChild) && firstChild.isEmpty();\n}\n\nexport class LayoutItemNode extends ElementNode {\n static getType(): string {\n return \"layout-item\";\n }\n\n static clone(node: LayoutItemNode): LayoutItemNode {\n return new LayoutItemNode(node.__key);\n }\n\n static importDOM(): DOMConversionMap | null {\n return {\n div: (domNode: HTMLElement) => {\n if (!domNode.hasAttribute(\"data-lexical-layout-item\")) {\n return null;\n }\n\n return {\n conversion: convertLayoutItemElement,\n priority: 2,\n };\n },\n };\n }\n\n static importJSON(serializedNode: SerializedLayoutItemNode): LayoutItemNode {\n return $createLayoutItemNode().updateFromJSON(serializedNode);\n }\n\n createDOM(config: EditorConfig): HTMLElement {\n const dom = document.createElement(\"div\");\n dom.setAttribute(\"data-lexical-layout-item\", \"true\");\n\n if (typeof config.theme.layoutItem === \"string\") {\n addClassNamesToElement(dom, config.theme.layoutItem);\n }\n\n return dom;\n }\n\n updateDOM(): boolean {\n return false;\n }\n\n collapseAtStart(): boolean {\n const parent = this.getParentOrThrow();\n\n if (\n this.is(parent.getFirstChild()) &&\n parent.getChildren().every($isEmptyLayoutItemNode)\n ) {\n parent.remove();\n return true;\n }\n\n return false;\n }\n\n isShadowRoot(): boolean {\n return true;\n }\n}\n\nexport function $createLayoutItemNode(): LayoutItemNode {\n return new LayoutItemNode();\n}\n\nexport function $isLayoutItemNode(\n node: LexicalNode | null | undefined\n): node is LayoutItemNode {\n return node instanceof LayoutItemNode;\n}\n", "path": "registry/pytah/editor/components/editor/core/nodes/layout/item-node.ts", "target": "src/components/editor/core/nodes/layout/item-node.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { BlockWithAlignableContents } from \"@lexical/react/LexicalBlockWithAlignableContents\";\nimport {\n DecoratorBlockNode,\n type SerializedDecoratorBlockNode,\n} from \"@lexical/react/LexicalDecoratorBlockNode\";\nimport type {\n DOMConversionMap,\n DOMConversionOutput,\n DOMExportOutput,\n EditorConfig,\n ElementFormatType,\n LexicalEditor,\n LexicalNode,\n NodeKey,\n Spread,\n} from \"lexical\";\nimport type { JSX } from \"react\";\n\ntype YouTubeComponentProps = Readonly<{\n className: Readonly<{\n base: string;\n focus: string;\n }>;\n format: ElementFormatType | null;\n nodeKey: NodeKey;\n videoId: string;\n}>;\n\nfunction YouTubeComponent({\n className,\n format,\n nodeKey,\n videoId,\n}: YouTubeComponentProps) {\n return (\n \n \n \n );\n}\n\nexport type SerializedYouTubeNode = Spread<\n {\n videoId: string;\n },\n SerializedDecoratorBlockNode\n>;\n\nconst convertYouTubeElement = (\n domNode: HTMLElement\n): DOMConversionOutput | null => {\n const videoId = domNode.getAttribute(\"data-lexical-youtube\");\n if (!videoId) {\n return null;\n }\n\n return {\n node: $createYouTubeNode(videoId),\n };\n};\n\nexport class YouTubeNode extends DecoratorBlockNode {\n __videoId: string;\n\n static getType(): string {\n return \"youtube\";\n }\n\n static clone(node: YouTubeNode): YouTubeNode {\n return new YouTubeNode(node.__videoId, node.__format, node.__key);\n }\n\n static importJSON(serializedNode: SerializedYouTubeNode): YouTubeNode {\n return $createYouTubeNode(serializedNode.videoId).updateFromJSON(\n serializedNode\n );\n }\n\n static importDOM(): DOMConversionMap | null {\n return {\n iframe: (domNode: HTMLElement) => {\n if (!domNode.hasAttribute(\"data-lexical-youtube\")) {\n return null;\n }\n\n return {\n conversion: convertYouTubeElement,\n priority: 1,\n };\n },\n };\n }\n\n constructor(videoId: string, format?: ElementFormatType, key?: NodeKey) {\n super(format, key);\n this.__videoId = videoId;\n }\n\n exportJSON(): SerializedYouTubeNode {\n return {\n ...super.exportJSON(),\n videoId: this.__videoId,\n type: \"youtube\",\n version: 1,\n };\n }\n\n exportDOM(): DOMExportOutput {\n const element = document.createElement(\"iframe\");\n element.setAttribute(\"data-lexical-youtube\", this.__videoId);\n element.setAttribute(\n \"src\",\n `https://www.youtube-nocookie.com/embed/${this.__videoId}`\n );\n element.setAttribute(\n \"allow\",\n \"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\"\n );\n element.setAttribute(\"allowfullscreen\", \"true\");\n element.setAttribute(\"frameborder\", \"0\");\n element.setAttribute(\"title\", \"YouTube video\");\n return { element };\n }\n\n updateDOM(): false {\n return false;\n }\n\n getVideoId(): string {\n return this.getLatest().__videoId;\n }\n\n getTextContent(): string {\n return `https://www.youtube.com/watch?v=${this.__videoId}`;\n }\n\n decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {\n const embedBlockTheme = config.theme.embedBlock ?? {};\n const className = {\n base: embedBlockTheme.base ?? \"\",\n focus: embedBlockTheme.focus ?? \"\",\n };\n\n return (\n \n );\n }\n}\n\nexport function $createYouTubeNode(videoId: string): YouTubeNode {\n return new YouTubeNode(videoId);\n}\n\nexport function $isYouTubeNode(\n node: LexicalNode | null | undefined\n): node is YouTubeNode {\n return node instanceof YouTubeNode;\n}\n", "path": "registry/pytah/editor/components/editor/core/nodes/youtube/node.tsx", "target": "src/components/editor/core/nodes/youtube/node.tsx", "type": "registry:file" }, { "content": "import type { EditorThemeClasses } from \"lexical\";\n\nexport const editorTheme: EditorThemeClasses = {\n embedBlock: {\n base: \"my-4\",\n focus: \"outline-none\",\n },\n root: \"outline-none min-h-[200px] px-1\",\n paragraph: \"mb-1 leading-7 text-foreground\",\n heading: {\n h1: \"text-4xl font-extrabold tracking-tight mb-4 mt-8 text-foreground first:mt-0\",\n h2: \"text-3xl font-semibold tracking-tight mb-3 mt-6 text-foreground first:mt-0\",\n h3: \"text-2xl font-semibold tracking-tight mb-2 mt-4 text-foreground first:mt-0\",\n },\n quote:\n \"border-l-2 border-foreground/20 pl-4 italic text-muted-foreground my-3\",\n list: {\n ul: \"list-disc ml-6 mb-2\",\n ol: \"list-decimal ml-6 mb-2\",\n listitem: \"mb-0.5\",\n nested: {\n listitem: \"list-none\",\n },\n listitemChecked:\n \"mb-0.5 list-none outline-none focus:outline-none focus-visible:outline-none before:mr-2 before:inline-flex before:size-4 before:items-center before:justify-center before:rounded-sm before:border before:border-primary before:bg-primary before:text-[10px] before:text-primary-foreground before:content-['✓']\",\n listitemUnchecked:\n \"mb-0.5 list-none outline-none focus:outline-none focus-visible:outline-none before:mr-2 before:inline-flex before:size-4 before:items-center before:justify-center before:rounded-sm before:border before:border-border before:bg-background before:content-['']\",\n },\n link: \"text-primary underline underline-offset-4 cursor-pointer hover:text-primary/80\",\n hr: \"my-6 h-px cursor-pointer border-0 bg-border transition-colors\",\n hrSelected: \"bg-primary h-0.5\",\n image: \"block\",\n collapsibleContainer:\n \"group/collapsible my-4 overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-xs\",\n collapsibleTitle:\n \"relative block cursor-pointer list-none border-b border-border/70 px-4 py-3 pl-10 font-medium outline-none marker:content-none [&::-webkit-details-marker]:hidden [&>p]:mb-0 before:absolute before:left-4 before:top-1/2 before:-translate-y-1/2 before:text-xs before:text-muted-foreground before:transition-transform before:content-['▸'] data-[open=true]:before:rotate-90\",\n collapsibleContent: \"px-4 py-3 pl-10 text-foreground [&>p:last-child]:mb-0\",\n layoutContainer:\n \"my-4 grid gap-3 rounded-xl border border-border/70 bg-muted/20 p-3 md:gap-4\",\n layoutItem:\n \"min-w-0 rounded-lg border border-dashed border-border/80 bg-background/80 p-3\",\n table:\n \"my-4 w-full border-collapse overflow-hidden rounded-lg border border-border text-sm\",\n tableAddColumns: \"bg-muted hover:bg-muted/80\",\n tableAddRows: \"bg-muted hover:bg-muted/80\",\n tableCell:\n \"relative min-w-32 border border-border px-3 py-2 align-top outline-none [&_*]:mb-0\",\n tableCellActionButton:\n \"rounded-full border border-border bg-background shadow-sm hover:bg-muted\",\n tableCellActionButtonContainer: \"absolute right-1.5 top-1.5 z-10\",\n tableCellHeader:\n \"min-w-32 border border-border bg-muted/60 px-3 py-2 text-left font-semibold align-top outline-none [&_*]:mb-0\",\n tableRow: \"even:bg-muted/10\",\n tableScrollableWrapper:\n \"editor-table-scroll-wrapper my-4 w-full overflow-x-auto\",\n tableCellSelected: \"!border-primary bg-primary/10\",\n tableSelection: \"bg-primary/10\",\n text: {\n bold: \"font-bold\",\n italic: \"italic\",\n underline: \"underline underline-offset-4\",\n strikethrough: \"line-through\",\n underlineStrikethrough: \"underline line-through\",\n highlight: \"rounded-sm bg-highlight px-0.5 text-highlight-foreground\",\n code: \"bg-muted text-foreground px-1.5 py-0.5 rounded-md font-mono text-[0.875em]\",\n },\n code: \"!bg-muted rounded-lg p-4 font-mono text-sm my-3 block overflow-x-auto dark:!bg-muted/50\",\n codeHighlight: {\n atrule: \"text-sky-700 dark:text-sky-300\",\n attr: \"text-sky-700 dark:text-sky-300\",\n boolean: \"text-pink-700 dark:text-pink-300\",\n builtin: \"text-emerald-700 dark:text-emerald-300\",\n cdata: \"text-slate-500 dark:text-slate-400\",\n char: \"text-emerald-700 dark:text-emerald-300\",\n class: \"text-rose-700 dark:text-rose-300\",\n \"class-name\": \"text-rose-700 dark:text-rose-300\",\n comment: \"text-slate-500 italic dark:text-slate-400\",\n constant: \"text-pink-700 dark:text-pink-300\",\n deleted: \"text-pink-700 dark:text-pink-300\",\n doctype: \"text-slate-500 dark:text-slate-400\",\n entity: \"text-amber-700 dark:text-amber-300\",\n function: \"text-rose-700 dark:text-rose-300\",\n important: \"text-orange-700 dark:text-orange-300\",\n inserted: \"text-emerald-700 dark:text-emerald-300\",\n keyword: \"text-sky-700 dark:text-sky-300\",\n namespace: \"text-orange-700 dark:text-orange-300\",\n number: \"text-pink-700 dark:text-pink-300\",\n operator: \"text-amber-700 dark:text-amber-300\",\n prolog: \"text-slate-500 dark:text-slate-400\",\n property: \"text-pink-700 dark:text-pink-300\",\n punctuation: \"text-slate-500 dark:text-slate-400\",\n regex: \"text-orange-700 dark:text-orange-300\",\n selector: \"text-emerald-700 dark:text-emerald-300\",\n string: \"text-emerald-700 dark:text-emerald-300\",\n symbol: \"text-pink-700 dark:text-pink-300\",\n tag: \"text-pink-700 dark:text-pink-300\",\n unchanged: \"text-foreground\",\n url: \"text-amber-700 dark:text-amber-300\",\n variable: \"text-orange-700 dark:text-orange-300\",\n },\n};\n", "path": "registry/pytah/editor/components/editor/core/theme.ts", "target": "src/components/editor/core/theme.ts", "type": "registry:file" }, { "content": "import type { InitialConfigType } from \"@lexical/react/LexicalComposer\";\nimport type { LexicalEditor } from \"lexical\";\nimport type { ReactNode } from \"react\";\n\nexport interface EditorSnapshot {\n html: string;\n markdown: string;\n text: string;\n}\n\nexport interface EditorActionBarControls {\n onLoadHtml: () => void;\n onLoadMarkdown: () => void;\n onReset: () => void;\n}\n\nexport interface EditorFooterContext {\n snapshot: EditorSnapshot;\n}\n\nexport interface EditorOutputContext {\n onCopyHtml: () => void;\n onCopyMarkdown: () => void;\n snapshot: EditorSnapshot;\n}\n\nexport interface EditorShellContext {\n children: ReactNode;\n}\n\nexport interface EditorFeatureFlags {\n collapsible?: boolean;\n draggableBlocks?: boolean;\n floatingLinkEditor?: boolean;\n floatingToolbar?: boolean;\n focusOnMount?: boolean;\n history?: boolean;\n images?: boolean;\n layouts?: boolean;\n markdownShortcuts?: boolean;\n seedContent?: boolean;\n slashCommand?: boolean;\n tabIndentation?: boolean;\n tables?: boolean;\n youtube?: boolean;\n}\n\nexport interface EditorChromeOptions {\n actionBar?: boolean;\n footer?: boolean;\n header?: boolean;\n outputs?: boolean;\n shell?: boolean;\n}\n\nexport interface EditorPluginSlots {\n afterDefault?: ReactNode;\n afterEditable?: ReactNode;\n beforeDefault?: ReactNode;\n beforeEditable?: ReactNode;\n}\n\nexport interface EditorChromeSlots {\n actionBar?: ReactNode | ((controls: EditorActionBarControls) => ReactNode);\n footer?: ReactNode | ((context: EditorFooterContext) => ReactNode);\n header?: ReactNode;\n outputs?: ReactNode | ((context: EditorOutputContext) => ReactNode);\n shell?: ReactNode | ((context: EditorShellContext) => ReactNode);\n topToolbar?: ReactNode;\n}\n\n/**\n * Controls which toolbar is shown above the editor content area.\n *\n * - `false` — no toolbar is rendered (default, fully opt-in)\n * - `\"basic\"` — block-type selector, undo/redo, alignment, and indent controls\n * - `\"full\"` — everything in \"basic\" plus inline formatting, text/bg colour\n * pickers, and a link toggle in one single row\n */\nexport type EditorToolbar = false | \"basic\" | \"full\";\n\nexport interface EditorProps {\n chrome?: EditorChromeOptions;\n className?: string;\n contentClassName?: string;\n editable?: boolean;\n extraNodes?: NonNullable;\n features?: EditorFeatureFlags;\n initialHtml?: string;\n initialMarkdown?: string;\n minimal?: boolean;\n namespace?: string;\n onChange?: (snapshot: EditorSnapshot, editor: LexicalEditor) => void;\n placeholder?: string;\n pluginSlots?: EditorPluginSlots;\n slots?: EditorChromeSlots;\n /** @default false */\n toolbar?: EditorToolbar;\n}\n", "path": "registry/pytah/editor/components/editor/core/types.ts", "target": "src/components/editor/core/types.ts", "type": "registry:file" }, { "content": "import { $generateHtmlFromNodes, $generateNodesFromDOM } from \"@lexical/html\";\nimport {\n $convertFromMarkdownString,\n $convertToMarkdownString,\n} from \"@lexical/markdown\";\nimport {\n $createParagraphNode,\n $createTextNode,\n $getRoot,\n type LexicalEditor,\n} from \"lexical\";\nimport { EDITOR_MARKDOWN_TRANSFORMERS } from \"../plugins/markdown/transformers\";\nimport type { EditorSnapshot } from \"./types\";\n\nexport const createEmptyEditorState = (editor: LexicalEditor) => {\n editor.update(() => {\n const root = $getRoot();\n root.clear();\n const paragraph = $createParagraphNode();\n paragraph.append($createTextNode(\"\"));\n root.append(paragraph);\n root.selectStart();\n });\n};\n\nexport const readEditorSnapshot = (editor: LexicalEditor): EditorSnapshot => {\n let snapshot: EditorSnapshot = {\n html: \"\",\n markdown: \"\",\n text: \"\",\n };\n\n editor.getEditorState().read(() => {\n snapshot = {\n html: $generateHtmlFromNodes(editor),\n markdown: $convertToMarkdownString(EDITOR_MARKDOWN_TRANSFORMERS),\n text: $getRoot().getTextContent(),\n };\n });\n\n return snapshot;\n};\n\nexport const readEditorTextContent = (editor: LexicalEditor): string => {\n let textContent = \"\";\n\n editor.getEditorState().read(() => {\n textContent = $getRoot().getTextContent();\n });\n\n return textContent;\n};\n\nexport const loadMarkdownContent = (\n editor: LexicalEditor,\n markdown: string\n) => {\n editor.update(() => {\n $convertFromMarkdownString(markdown, EDITOR_MARKDOWN_TRANSFORMERS);\n $getRoot().selectEnd();\n });\n};\n\nexport const replaceEditorHtmlContent = (\n editor: LexicalEditor,\n html: string\n) => {\n editor.update(() => {\n const parser = new DOMParser();\n const dom = parser.parseFromString(html, \"text/html\");\n const nodes = $generateNodesFromDOM(editor, dom);\n const root = $getRoot();\n\n root.clear();\n\n if (nodes.length === 0) {\n const paragraph = $createParagraphNode();\n paragraph.append($createTextNode(\"\"));\n root.append(paragraph);\n paragraph.selectEnd();\n return;\n }\n\n root.append(...nodes);\n root.selectEnd();\n });\n};\n", "path": "registry/pytah/editor/components/editor/core/utils.ts", "target": "src/components/editor/core/utils.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport type { EditorProps } from \"./core/types\";\nimport { Editor } from \"./editor\";\nimport { EditorTableOfContents } from \"./plugins/toc/sidebar\";\n\ninterface EditorWithTocProps extends EditorProps {\n /** Replace the default TOC sidebar with a custom element. */\n toc?: ReactNode;\n /** Additional className for the TOC sidebar. */\n tocClassName?: string;\n}\n\n/**\n * A composition wrapper that renders the `Editor` alongside a sticky\n * Table of Contents sidebar.\n *\n * The TOC is rendered **inside** the LexicalComposer tree (via `slots.shell`)\n * so it can read heading state from the editor. The consumer controls the\n * outer page layout — this component only provides the two-column structure.\n */\nexport function EditorWithToc({\n slots,\n toc,\n tocClassName,\n ...props\n}: EditorWithTocProps) {\n return (\n (\n <>\n
{children}
\n \n \n ),\n }}\n />\n );\n}\n", "path": "registry/pytah/editor/components/editor/editor-with-toc.tsx", "target": "src/components/editor/editor-with-toc.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { LexicalComposer } from \"@lexical/react/LexicalComposer\";\nimport type { LexicalEditor } from \"lexical\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n copyEditorOutput,\n loadEditorHtmlExample,\n loadEditorMarkdownExample,\n resetEditorContent,\n} from \"./core/actions\";\nimport {\n type ResolvedEditorChromeOptions,\n renderEditorSlot,\n resolveEditorChrome,\n resolveEditorFeatures,\n shouldRenderEditorShell,\n} from \"./core/composition\";\nimport { createEditorConfig } from \"./core/config\";\nimport { DEFAULT_PLACEHOLDER } from \"./core/constants\";\nimport type {\n EditorActionBarControls,\n EditorChromeSlots,\n EditorOutputContext,\n EditorProps,\n EditorSnapshot,\n} from \"./core/types\";\nimport { readEditorSnapshot } from \"./core/utils\";\nimport { EditorHeader, EditorShell } from \"./ui/chrome\";\nimport { EditorContent } from \"./ui/content\";\nimport { EditorActionBar, EditorOutputGrid } from \"./ui/panels\";\n\nconst getEditorHeader = (\n minimal: boolean,\n chrome: ResolvedEditorChromeOptions,\n slots?: EditorChromeSlots\n): ReactNode => {\n if (minimal || !chrome.header) {\n return null;\n }\n\n return slots?.header === undefined ? : slots.header;\n};\n\nconst getEditorActionBar = (\n minimal: boolean,\n chrome: ResolvedEditorChromeOptions,\n controls: EditorActionBarControls,\n slots?: EditorChromeSlots\n): ReactNode => {\n if (minimal || !chrome.actionBar) {\n return null;\n }\n\n return slots?.actionBar === undefined ? (\n \n ) : (\n (renderEditorSlot(slots.actionBar, controls) ?? null)\n );\n};\n\nconst getEditorShell = (\n minimal: boolean,\n chrome: ResolvedEditorChromeOptions,\n content: ReactNode,\n slots?: EditorChromeSlots\n): ReactNode => {\n if (\n !shouldRenderEditorShell({\n chromeShell: chrome.shell,\n minimal,\n shellSlot: slots?.shell,\n })\n ) {\n return content;\n }\n\n return slots?.shell === undefined ? (\n {content}\n ) : (\n (renderEditorSlot(slots.shell, { children: content }) ?? null)\n );\n};\n\nconst getEditorOutputs = (\n minimal: boolean,\n chrome: ResolvedEditorChromeOptions,\n context: EditorOutputContext,\n slots?: EditorChromeSlots\n): ReactNode => {\n if (minimal || !chrome.outputs) {\n return null;\n }\n\n return slots?.outputs === undefined ? (\n \n ) : (\n (renderEditorSlot(slots.outputs, context) ?? null)\n );\n};\n\nexport function Editor({\n className,\n chrome,\n contentClassName,\n editable = true,\n extraNodes,\n features,\n initialHtml,\n initialMarkdown,\n minimal = false,\n namespace,\n onChange,\n placeholder = DEFAULT_PLACEHOLDER,\n pluginSlots,\n slots,\n toolbar = false,\n}: EditorProps) {\n const [textContent, setTextContent] = useState(\"\");\n const [serializedSnapshot, setSerializedSnapshot] = useState({\n html: \"\",\n markdown: \"\",\n text: \"\",\n });\n const [editorInstance, setEditorInstance] = useState(\n null\n );\n\n const resolvedChrome = resolveEditorChrome(chrome);\n const resolvedFeatures = resolveEditorFeatures(features);\n\n const initialConfig = createEditorConfig({\n editable,\n namespace,\n nodes: extraNodes,\n });\n\n const snapshot = useMemo(() => {\n return {\n ...serializedSnapshot,\n text: textContent,\n };\n }, [serializedSnapshot, textContent]);\n\n const handleSnapshotChange = useCallback(\n (nextText: string, editor: LexicalEditor) => {\n setTextContent(nextText);\n setEditorInstance((currentEditor) => currentEditor ?? editor);\n },\n []\n );\n\n const handleSnapshotReady = useCallback(\n (nextSnapshot: EditorSnapshot, editor: LexicalEditor) => {\n setSerializedSnapshot((currentSnapshot) => {\n if (\n currentSnapshot.html === nextSnapshot.html &&\n currentSnapshot.markdown === nextSnapshot.markdown &&\n currentSnapshot.text === nextSnapshot.text\n ) {\n return currentSnapshot;\n }\n\n return nextSnapshot;\n });\n setEditorInstance((currentEditor) => currentEditor ?? editor);\n onChange?.(nextSnapshot, editor);\n },\n [onChange]\n );\n\n const handleCopyMarkdown = useCallback(async () => {\n await copyEditorOutput(serializedSnapshot.markdown);\n }, [serializedSnapshot.markdown]);\n\n const handleCopyHtml = useCallback(async () => {\n await copyEditorOutput(serializedSnapshot.html);\n }, [serializedSnapshot.html]);\n\n const handleReset = useCallback(() => {\n if (!editorInstance) {\n return;\n }\n\n resetEditorContent(editorInstance);\n const nextSnapshot = readEditorSnapshot(editorInstance);\n setTextContent(nextSnapshot.text);\n setSerializedSnapshot(nextSnapshot);\n onChange?.(nextSnapshot, editorInstance);\n }, [editorInstance, onChange]);\n\n const handleLoadHtmlExample = useCallback(() => {\n if (!editorInstance) {\n return;\n }\n\n loadEditorHtmlExample(editorInstance);\n const nextSnapshot = readEditorSnapshot(editorInstance);\n setTextContent(nextSnapshot.text);\n setSerializedSnapshot(nextSnapshot);\n onChange?.(nextSnapshot, editorInstance);\n }, [editorInstance, onChange]);\n\n const handleLoadMarkdownExample = useCallback(() => {\n if (!editorInstance) {\n return;\n }\n\n loadEditorMarkdownExample(editorInstance);\n const nextSnapshot = readEditorSnapshot(editorInstance);\n setTextContent(nextSnapshot.text);\n setSerializedSnapshot(nextSnapshot);\n onChange?.(nextSnapshot, editorInstance);\n }, [editorInstance, onChange]);\n\n const actionBarControls: EditorActionBarControls = {\n onLoadHtml: handleLoadHtmlExample,\n onLoadMarkdown: handleLoadMarkdownExample,\n onReset: handleReset,\n };\n\n const defaultContent = (\n \n );\n\n const headerContent = getEditorHeader(minimal, resolvedChrome, slots);\n const actionBarContent = getEditorActionBar(\n minimal,\n resolvedChrome,\n actionBarControls,\n slots\n );\n\n const shellChildren = (\n <>\n {headerContent}\n {actionBarContent}\n {defaultContent}\n \n );\n\n const editorBody = getEditorShell(\n minimal,\n resolvedChrome,\n minimal ? defaultContent : shellChildren,\n slots\n );\n\n const outputContent = getEditorOutputs(\n minimal,\n resolvedChrome,\n {\n onCopyHtml: handleCopyHtml,\n onCopyMarkdown: handleCopyMarkdown,\n snapshot,\n },\n slots\n );\n\n return (\n
\n \n {editorBody}\n \n\n {outputContent}\n
\n );\n}\n", "path": "registry/pytah/editor/components/editor/editor.tsx", "target": "src/components/editor/editor.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport type { LexicalEditor } from \"lexical\";\nimport { CheckIcon, ChevronDownIcon } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\nimport { BLOCK_ICONS, BLOCK_LABELS, BLOCK_OPTIONS } from \"./options\";\nimport type { BlockTypeValue } from \"./types\";\nimport { applyBlockType, getCurrentBlockOption } from \"./utils\";\n\ninterface BlockTypeDropProps {\n blockType: BlockTypeValue;\n className?: string;\n editor: LexicalEditor;\n onBlockTypeChange?: (value: BlockTypeValue) => void;\n}\n\nexport function BlockTypeDrop({\n blockType,\n className,\n editor,\n onBlockTypeChange,\n}: BlockTypeDropProps) {\n const currentOption = getCurrentBlockOption(blockType, BLOCK_OPTIONS);\n const CurrentIcon = BLOCK_ICONS[currentOption?.value ?? \"paragraph\"];\n\n const handleChange = (value: BlockTypeValue) => {\n applyBlockType(editor, value);\n onBlockTypeChange?.(value);\n };\n\n return (\n \n }>\n \n {currentOption?.label ?? BLOCK_LABELS.paragraph}\n \n \n\n \n {BLOCK_OPTIONS.map((option) => {\n const Icon = BLOCK_ICONS[option.value];\n const isSelected = option.value === blockType;\n\n return (\n handleChange(option.value)}\n >\n \n \n \n \n \n {option.label}\n \n \n {option.description}\n \n \n {isSelected && (\n \n )}\n \n );\n })}\n \n \n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/block-type-toolbar/block-type-drop.tsx", "target": "src/components/editor/plugins/block-type-toolbar/block-type-drop.tsx", "type": "registry:file" }, { "content": "import {\n Heading1Icon,\n Heading2Icon,\n Heading3Icon,\n ListIcon,\n ListOrderedIcon,\n PilcrowIcon,\n QuoteIcon,\n SquareCheckIcon,\n TableIcon,\n TextIcon,\n} from \"lucide-react\";\nimport type { BlockOption, BlockTypeValue } from \"./types\";\n\nexport const BLOCK_OPTIONS: BlockOption[] = [\n {\n description: \"Regular paragraph\",\n label: \"Text\",\n value: \"paragraph\",\n },\n {\n description: \"Main section title\",\n label: \"Heading 1\",\n value: \"h1\",\n },\n {\n description: \"Section heading\",\n label: \"Heading 2\",\n value: \"h2\",\n },\n {\n description: \"Subsection heading\",\n label: \"Heading 3\",\n value: \"h3\",\n },\n {\n description: \"Unordered list\",\n label: \"Bullet list\",\n value: \"bullet\",\n },\n {\n description: \"Ordered list\",\n label: \"Numbered list\",\n value: \"number\",\n },\n {\n description: \"Todo items with checkboxes\",\n label: \"Checklist\",\n value: \"check\",\n },\n {\n description: \"Blockquote\",\n label: \"Quote\",\n value: \"quote\",\n },\n {\n description: \"Monospace code block\",\n label: \"Code block\",\n value: \"code\",\n },\n {\n description: \"Simple editable table\",\n label: \"Table\",\n value: \"table\",\n },\n];\n\nexport const BLOCK_LABELS: Record = {\n bullet: \"Bullet list\",\n check: \"Checklist\",\n code: \"Code block\",\n h1: \"Heading 1\",\n h2: \"Heading 2\",\n h3: \"Heading 3\",\n number: \"Numbered list\",\n paragraph: \"Text\",\n quote: \"Quote\",\n table: \"Table\",\n};\n\nexport const BLOCK_ICONS = {\n bullet: ListIcon,\n check: SquareCheckIcon,\n code: PilcrowIcon,\n h1: Heading1Icon,\n h2: Heading2Icon,\n h3: Heading3Icon,\n number: ListOrderedIcon,\n paragraph: TextIcon,\n quote: QuoteIcon,\n table: TableIcon,\n} as const;\n", "path": "registry/pytah/editor/components/editor/plugins/block-type-toolbar/options.ts", "target": "src/components/editor/plugins/block-type-toolbar/options.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { mergeRegister } from \"@lexical/utils\";\nimport {\n COMMAND_PRIORITY_LOW,\n FORMAT_ELEMENT_COMMAND,\n INDENT_CONTENT_COMMAND,\n OUTDENT_CONTENT_COMMAND,\n REDO_COMMAND,\n SELECTION_CHANGE_COMMAND,\n UNDO_COMMAND,\n} from \"lexical\";\nimport {\n AlignCenterIcon,\n AlignLeftIcon,\n AlignRightIcon,\n IndentDecreaseIcon,\n IndentIncreaseIcon,\n RedoIcon,\n UndoIcon,\n} from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { BlockTypeDrop } from \"./block-type-drop\";\nimport type { BlockTypeValue } from \"./types\";\nimport { getBlockTypeFromSelection } from \"./utils\";\n\nexport function BlockTypeToolbarPlugin() {\n const [editor] = useLexicalComposerContext();\n const [currentBlockType, setCurrentBlockType] =\n useState(\"paragraph\");\n\n useEffect(() => {\n function updateCurrentBlockType() {\n editor.getEditorState().read(() => {\n const blockType = getBlockTypeFromSelection();\n const resolvedBlockType = blockType ?? \"paragraph\";\n\n setCurrentBlockType((current) =>\n current === resolvedBlockType ? current : resolvedBlockType\n );\n });\n }\n\n return mergeRegister(\n editor.registerCommand(\n SELECTION_CHANGE_COMMAND,\n () => {\n updateCurrentBlockType();\n return false;\n },\n COMMAND_PRIORITY_LOW\n ),\n editor.registerUpdateListener(updateCurrentBlockType)\n );\n }, [editor]);\n\n return (\n
\n \n\n \n\n editor.dispatchCommand(UNDO_COMMAND, undefined)}\n size=\"icon-sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n editor.dispatchCommand(REDO_COMMAND, undefined)}\n size=\"icon-sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n\n \n\n editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, \"left\")}\n size=\"icon-sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, \"center\")}\n size=\"icon-sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, \"right\")}\n size=\"icon-sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n\n \n\n \n editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)\n }\n size=\"icon-sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n \n editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)\n }\n size=\"icon-sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n
\n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/block-type-toolbar/plugin.tsx", "target": "src/components/editor/plugins/block-type-toolbar/plugin.tsx", "type": "registry:file" }, { "content": "export type BlockTypeValue =\n | \"paragraph\"\n | \"h1\"\n | \"h2\"\n | \"h3\"\n | \"bullet\"\n | \"number\"\n | \"check\"\n | \"quote\"\n | \"code\"\n | \"table\";\n\nexport interface BlockOption {\n description: string;\n label: string;\n value: BlockTypeValue;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/block-type-toolbar/types.ts", "target": "src/components/editor/plugins/block-type-toolbar/types.ts", "type": "registry:file" }, { "content": "import { $createCodeNode, $isCodeNode } from \"@lexical/code\";\nimport {\n $isListNode,\n INSERT_CHECK_LIST_COMMAND,\n INSERT_ORDERED_LIST_COMMAND,\n INSERT_UNORDERED_LIST_COMMAND,\n REMOVE_LIST_COMMAND,\n} from \"@lexical/list\";\nimport {\n $createHeadingNode,\n $createQuoteNode,\n $isHeadingNode,\n $isQuoteNode,\n} from \"@lexical/rich-text\";\nimport { $setBlocksType } from \"@lexical/selection\";\nimport { $isTableNode, INSERT_TABLE_COMMAND } from \"@lexical/table\";\nimport {\n $createParagraphNode,\n $findMatchingParent,\n $getSelection,\n $isParagraphNode,\n $isRangeSelection,\n type LexicalEditor,\n type LexicalNode,\n} from \"lexical\";\nimport { DEFAULT_INSERT_TABLE_PAYLOAD } from \"../table-behavior/constants\";\nimport type { BlockOption, BlockTypeValue } from \"./types\";\n\nconst HEADING_VALUES = [\"h1\", \"h2\", \"h3\"] as const;\n\nconst isHeadingValue = (\n value: BlockTypeValue\n): value is (typeof HEADING_VALUES)[number] => {\n return HEADING_VALUES.includes(value as (typeof HEADING_VALUES)[number]);\n};\n\nconst findSelectedTableAncestor = (node: LexicalNode) => {\n return $findMatchingParent(node, (parentNode) => $isTableNode(parentNode));\n};\n\nexport const getBlockTypeFromSelection = (): BlockTypeValue | null => {\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return null;\n }\n\n const anchorNode = selection.anchor.getNode();\n\n if (findSelectedTableAncestor(anchorNode)) {\n return \"table\";\n }\n\n const topLevelElement = anchorNode.getTopLevelElementOrThrow();\n\n if ($isHeadingNode(topLevelElement)) {\n const headingTag = topLevelElement.getTag();\n if (headingTag === \"h1\" || headingTag === \"h2\" || headingTag === \"h3\") {\n return headingTag;\n }\n\n return null;\n }\n\n if ($isQuoteNode(topLevelElement)) {\n return \"quote\";\n }\n\n if ($isCodeNode(topLevelElement)) {\n return \"code\";\n }\n\n if ($isListNode(topLevelElement)) {\n const listType = topLevelElement.getListType();\n if (\n listType === \"bullet\" ||\n listType === \"number\" ||\n listType === \"check\"\n ) {\n return listType;\n }\n\n return null;\n }\n\n if ($isParagraphNode(topLevelElement)) {\n return \"paragraph\";\n }\n\n return null;\n};\n\nexport const applyBlockType = (\n editor: LexicalEditor,\n blockType: BlockTypeValue\n) => {\n editor.update(() => {\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return;\n }\n\n if (blockType === \"paragraph\") {\n $setBlocksType(selection, () => $createParagraphNode());\n return;\n }\n\n if (isHeadingValue(blockType)) {\n $setBlocksType(selection, () => $createHeadingNode(blockType));\n return;\n }\n\n if (blockType === \"quote\") {\n $setBlocksType(selection, () => $createQuoteNode());\n return;\n }\n\n if (blockType === \"code\") {\n $setBlocksType(selection, () => $createCodeNode());\n return;\n }\n\n if (blockType === \"table\") {\n editor.dispatchCommand(\n INSERT_TABLE_COMMAND,\n DEFAULT_INSERT_TABLE_PAYLOAD\n );\n return;\n }\n\n editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);\n\n if (blockType === \"bullet\") {\n editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);\n return;\n }\n\n if (blockType === \"check\") {\n editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);\n return;\n }\n\n editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);\n });\n};\n\nexport const getCurrentBlockOption = (\n currentBlockType: BlockTypeValue,\n options: BlockOption[]\n): BlockOption => {\n return (\n options.find((option) => option.value === currentBlockType) ?? options[0]\n );\n};\n", "path": "registry/pytah/editor/components/editor/plugins/block-type-toolbar/utils.ts", "target": "src/components/editor/plugins/block-type-toolbar/utils.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { CodeNode } from \"@lexical/code\";\nimport { registerCodeHighlighting } from \"@lexical/code-shiki\";\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { $nodesOfType } from \"lexical\";\nimport { useEffect } from \"react\";\nimport { useTheme } from \"@/components/theme-provider\";\n\nconst CODE_BLOCK_THEME_BY_MODE = {\n dark: \"github-dark\",\n light: \"github-light\",\n} as const;\n\nexport function CodeHighlightPlugin() {\n const [editor] = useLexicalComposerContext();\n const { resolvedTheme } = useTheme();\n const codeBlockTheme = CODE_BLOCK_THEME_BY_MODE[resolvedTheme];\n\n useEffect(() => {\n return registerCodeHighlighting(editor);\n }, [editor]);\n\n useEffect(() => {\n return editor.registerNodeTransform(CodeNode, (codeNode) => {\n if (codeNode.getTheme() !== codeBlockTheme) {\n codeNode.setTheme(codeBlockTheme);\n }\n });\n }, [codeBlockTheme, editor]);\n\n useEffect(() => {\n editor.update(\n () => {\n for (const codeNode of $nodesOfType(CodeNode)) {\n if (codeNode.getTheme() !== codeBlockTheme) {\n codeNode.setTheme(codeBlockTheme);\n }\n }\n },\n { discrete: true }\n );\n }, [codeBlockTheme, editor]);\n\n return null;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/code-highlight/plugin.tsx", "target": "src/components/editor/plugins/code-highlight/plugin.tsx", "type": "registry:file" }, { "content": "import { createCommand } from \"lexical\";\n\nexport interface InsertCollapsiblePayload {\n targetNodeKey?: string;\n}\n\nexport const INSERT_COLLAPSIBLE_COMMAND = createCommand<\n InsertCollapsiblePayload | undefined\n>(\"INSERT_COLLAPSIBLE_COMMAND\");\n", "path": "registry/pytah/editor/components/editor/plugins/collapsible/commands.ts", "target": "src/components/editor/plugins/collapsible/commands.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { $findMatchingParent, mergeRegister } from \"@lexical/utils\";\nimport {\n $createParagraphNode,\n $getSelection,\n $isRangeSelection,\n COMMAND_PRIORITY_LOW,\n INSERT_PARAGRAPH_COMMAND,\n KEY_ARROW_DOWN_COMMAND,\n KEY_ARROW_LEFT_COMMAND,\n KEY_ARROW_RIGHT_COMMAND,\n KEY_ARROW_UP_COMMAND,\n} from \"lexical\";\nimport { useEffect } from \"react\";\nimport {\n $isCollapsibleContainerNode,\n CollapsibleContainerNode,\n} from \"../../core/nodes/collapsible/container-node\";\nimport {\n $isCollapsibleContentNode,\n CollapsibleContentNode,\n} from \"../../core/nodes/collapsible/content-node\";\nimport {\n $isCollapsibleTitleNode,\n CollapsibleTitleNode,\n} from \"../../core/nodes/collapsible/title-node\";\nimport {\n INSERT_COLLAPSIBLE_COMMAND,\n type InsertCollapsiblePayload,\n} from \"./commands\";\nimport { insertCollapsible } from \"./utils\";\n\nconst shouldInsertParagraphBeforeCollapsible = () => {\n const selection = $getSelection();\n if (\n !(\n $isRangeSelection(selection) &&\n selection.isCollapsed() &&\n selection.anchor.offset === 0\n )\n ) {\n return false;\n }\n\n const container = $findMatchingParent(\n selection.anchor.getNode(),\n $isCollapsibleContainerNode\n );\n if (!$isCollapsibleContainerNode(container)) {\n return false;\n }\n\n const parent = container.getParent();\n return !!(\n parent &&\n parent.getFirstChild() === container &&\n selection.anchor.key === container.getFirstDescendant()?.getKey()\n );\n};\n\nconst shouldInsertParagraphAfterCollapsible = () => {\n const selection = $getSelection();\n if (!($isRangeSelection(selection) && selection.isCollapsed())) {\n return false;\n }\n\n const container = $findMatchingParent(\n selection.anchor.getNode(),\n $isCollapsibleContainerNode\n );\n if (!$isCollapsibleContainerNode(container)) {\n return false;\n }\n\n const parent = container.getParent();\n if (!(parent && parent.getLastChild() === container)) {\n return false;\n }\n\n const titleParagraph = container.getFirstDescendant();\n const contentParagraph = container.getLastDescendant();\n\n return !!(\n (contentParagraph &&\n selection.anchor.key === contentParagraph.getKey() &&\n selection.anchor.offset === contentParagraph.getTextContentSize()) ||\n (titleParagraph &&\n selection.anchor.key === titleParagraph.getKey() &&\n selection.anchor.offset === titleParagraph.getTextContentSize())\n );\n};\n\nexport function CollapsiblePlugin() {\n const [editor] = useLexicalComposerContext();\n\n useEffect(() => {\n if (\n !editor.hasNodes([\n CollapsibleContainerNode,\n CollapsibleTitleNode,\n CollapsibleContentNode,\n ])\n ) {\n throw new Error(\n \"CollapsiblePlugin requires collapsible nodes to be registered\"\n );\n }\n\n return mergeRegister(\n editor.registerNodeTransform(CollapsibleContentNode, (node) => {\n const parent = node.getParent();\n if ($isCollapsibleContainerNode(parent)) {\n return;\n }\n\n for (const child of node.getChildren()) {\n node.insertBefore(child);\n }\n node.remove();\n }),\n editor.registerNodeTransform(CollapsibleTitleNode, (node) => {\n const parent = node.getParent();\n if ($isCollapsibleContainerNode(parent)) {\n return;\n }\n\n node.replace($createParagraphNode().append(...node.getChildren()));\n }),\n editor.registerNodeTransform(CollapsibleContainerNode, (node) => {\n const children = node.getChildren();\n if (\n children.length === 2 &&\n $isCollapsibleTitleNode(children[0]) &&\n $isCollapsibleContentNode(children[1])\n ) {\n return;\n }\n\n for (const child of children) {\n node.insertBefore(child);\n }\n node.remove();\n }),\n editor.registerCommand(\n KEY_ARROW_UP_COMMAND,\n () => {\n if (!shouldInsertParagraphBeforeCollapsible()) {\n return false;\n }\n\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return false;\n }\n\n const container = $findMatchingParent(\n selection.anchor.getNode(),\n $isCollapsibleContainerNode\n );\n if (!$isCollapsibleContainerNode(container)) {\n return false;\n }\n\n container.insertBefore($createParagraphNode());\n return false;\n },\n COMMAND_PRIORITY_LOW\n ),\n editor.registerCommand(\n KEY_ARROW_LEFT_COMMAND,\n () => {\n if (!shouldInsertParagraphBeforeCollapsible()) {\n return false;\n }\n\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return false;\n }\n\n const container = $findMatchingParent(\n selection.anchor.getNode(),\n $isCollapsibleContainerNode\n );\n if (!$isCollapsibleContainerNode(container)) {\n return false;\n }\n\n container.insertBefore($createParagraphNode());\n return false;\n },\n COMMAND_PRIORITY_LOW\n ),\n editor.registerCommand(\n KEY_ARROW_DOWN_COMMAND,\n () => {\n if (!shouldInsertParagraphAfterCollapsible()) {\n return false;\n }\n\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return false;\n }\n\n const container = $findMatchingParent(\n selection.anchor.getNode(),\n $isCollapsibleContainerNode\n );\n if (!$isCollapsibleContainerNode(container)) {\n return false;\n }\n\n container.insertAfter($createParagraphNode());\n return false;\n },\n COMMAND_PRIORITY_LOW\n ),\n editor.registerCommand(\n KEY_ARROW_RIGHT_COMMAND,\n () => {\n if (!shouldInsertParagraphAfterCollapsible()) {\n return false;\n }\n\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return false;\n }\n\n const container = $findMatchingParent(\n selection.anchor.getNode(),\n $isCollapsibleContainerNode\n );\n if (!$isCollapsibleContainerNode(container)) {\n return false;\n }\n\n container.insertAfter($createParagraphNode());\n return false;\n },\n COMMAND_PRIORITY_LOW\n ),\n editor.registerCommand(\n INSERT_PARAGRAPH_COMMAND,\n () => {\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return false;\n }\n\n const titleNode = $findMatchingParent(\n selection.anchor.getNode(),\n $isCollapsibleTitleNode\n );\n if (!$isCollapsibleTitleNode(titleNode)) {\n return false;\n }\n\n const container = titleNode.getParent();\n if (!$isCollapsibleContainerNode(container)) {\n return false;\n }\n\n if (!container.getOpen()) {\n container.toggleOpen();\n }\n\n titleNode.getNextSibling()?.selectEnd();\n return true;\n },\n COMMAND_PRIORITY_LOW\n ),\n editor.registerCommand(\n INSERT_COLLAPSIBLE_COMMAND,\n (payload?: InsertCollapsiblePayload) => {\n return insertCollapsible(payload?.targetNodeKey);\n },\n COMMAND_PRIORITY_LOW\n )\n );\n }, [editor]);\n\n return null;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/collapsible/plugin.tsx", "target": "src/components/editor/plugins/collapsible/plugin.tsx", "type": "registry:file" }, { "content": "import { $insertNodeToNearestRoot } from \"@lexical/utils\";\nimport {\n $createParagraphNode,\n $getNodeByKey,\n $getSelection,\n $isElementNode,\n $isRangeSelection,\n type ElementNode,\n} from \"lexical\";\nimport { $createCollapsibleContainerNode } from \"../../core/nodes/collapsible/container-node\";\nimport { $createCollapsibleContentNode } from \"../../core/nodes/collapsible/content-node\";\nimport { $createCollapsibleTitleNode } from \"../../core/nodes/collapsible/title-node\";\n\nconst createCollapsibleStructure = () => {\n const titleParagraph = $createParagraphNode();\n const contentParagraph = $createParagraphNode();\n const titleNode = $createCollapsibleTitleNode();\n const contentNode = $createCollapsibleContentNode();\n const containerNode = $createCollapsibleContainerNode(true);\n\n titleNode.append(titleParagraph);\n contentNode.append(contentParagraph);\n containerNode.append(titleNode, contentNode);\n\n return {\n containerNode,\n titleParagraph,\n };\n};\n\nexport const replaceElementWithCollapsible = (targetElement: ElementNode) => {\n const { containerNode, titleParagraph } = createCollapsibleStructure();\n targetElement.replace(containerNode);\n titleParagraph.selectEnd();\n};\n\nexport const insertCollapsible = (targetNodeKey?: string) => {\n if (targetNodeKey) {\n const targetNode = $getNodeByKey(targetNodeKey);\n if (!$isElementNode(targetNode)) {\n return false;\n }\n\n replaceElementWithCollapsible(targetNode);\n return true;\n }\n\n const selection = $getSelection();\n if ($isRangeSelection(selection)) {\n const targetElement = selection.anchor\n .getNode()\n .getTopLevelElementOrThrow();\n replaceElementWithCollapsible(targetElement);\n return true;\n }\n\n const { containerNode, titleParagraph } = createCollapsibleStructure();\n $insertNodeToNearestRoot(containerNode);\n titleParagraph.selectEnd();\n return true;\n};\n", "path": "registry/pytah/editor/components/editor/plugins/collapsible/utils.ts", "target": "src/components/editor/plugins/collapsible/utils.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { useEffect } from \"react\";\n\ninterface EditablePluginProps {\n editable: boolean;\n}\n\nexport function EditablePlugin({ editable }: EditablePluginProps) {\n const [editor] = useLexicalComposerContext();\n\n useEffect(() => {\n editor.setEditable(editable);\n }, [editor, editable]);\n\n return null;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/core/editable.tsx", "target": "src/components/editor/plugins/core/editable.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { OnChangePlugin } from \"@lexical/react/LexicalOnChangePlugin\";\nimport type { LexicalEditor } from \"lexical\";\nimport { useEffect } from \"react\";\nimport type { EditorSnapshot } from \"../../core/types\";\nimport {\n createEmptyEditorState,\n loadMarkdownContent,\n readEditorSnapshot,\n readEditorTextContent,\n replaceEditorHtmlContent,\n} from \"../../core/utils\";\n\nexport interface EditorStatePluginProps {\n initialHtml?: string;\n initialMarkdown?: string;\n onChange?: (textContent: string, editor: LexicalEditor) => void;\n onSnapshotReady?: (snapshot: EditorSnapshot, editor: LexicalEditor) => void;\n}\n\nexport function EditorStatePlugin({\n initialHtml,\n initialMarkdown,\n onChange,\n onSnapshotReady,\n}: EditorStatePluginProps) {\n const [editor] = useLexicalComposerContext();\n\n useEffect(() => {\n if (initialMarkdown) {\n loadMarkdownContent(editor, initialMarkdown);\n return;\n }\n\n if (initialHtml) {\n replaceEditorHtmlContent(editor, initialHtml);\n return;\n }\n\n createEmptyEditorState(editor);\n }, [editor, initialHtml, initialMarkdown]);\n\n return (\n {\n onChange?.(readEditorTextContent(activeEditor), activeEditor);\n onSnapshotReady?.(readEditorSnapshot(activeEditor), activeEditor);\n }}\n />\n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/core/editor-state.tsx", "target": "src/components/editor/plugins/core/editor-state.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { useEffect } from \"react\";\n\nexport function FocusOnMountPlugin() {\n const [editor] = useLexicalComposerContext();\n\n useEffect(() => {\n const animationFrameId = window.requestAnimationFrame(() => {\n editor.focus();\n });\n\n return () => {\n window.cancelAnimationFrame(animationFrameId);\n };\n }, [editor]);\n\n return null;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/core/focus-on-mount.tsx", "target": "src/components/editor/plugins/core/focus-on-mount.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport {\n $createHorizontalRuleNode,\n $isHorizontalRuleNode,\n HorizontalRuleNode,\n INSERT_HORIZONTAL_RULE_COMMAND,\n} from \"@lexical/extension\";\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport {\n $insertNodeToNearestRoot,\n addClassNamesToElement,\n mergeRegister,\n removeClassNamesFromElement,\n} from \"@lexical/utils\";\nimport {\n $createNodeSelection,\n $getNodeFromDOMNode,\n $getSelection,\n $isNodeSelection,\n $isRangeSelection,\n $setSelection,\n CLICK_COMMAND,\n COMMAND_PRIORITY_EDITOR,\n COMMAND_PRIORITY_LOW,\n type LexicalNode,\n} from \"lexical\";\nimport { useEffect } from \"react\";\n\nconst toggleNodeSelection = (node: LexicalNode, shiftKey = false) => {\n const selection = $getSelection();\n const wasSelected = node.isSelected();\n const key = node.getKey();\n const nodeSelection =\n shiftKey && $isNodeSelection(selection)\n ? selection\n : $createNodeSelection();\n\n if (!($isNodeSelection(selection) && shiftKey)) {\n $setSelection(nodeSelection);\n }\n\n if (wasSelected) {\n nodeSelection.delete(key);\n return;\n }\n\n nodeSelection.add(key);\n};\n\nconst getClickedHorizontalRuleNode = (target: EventTarget | null) => {\n if (!(target instanceof Node)) {\n return null;\n }\n\n const node = $getNodeFromDOMNode(target);\n return $isHorizontalRuleNode(node) ? node : null;\n};\n\nconst syncHorizontalRuleSelectionClass = (\n editor: ReturnType[0],\n nodeKey: string,\n selectedClassName: string\n) => {\n const element = editor.getElementByKey(nodeKey);\n if (!element) {\n return;\n }\n\n const node = $getNodeFromDOMNode(element);\n if (!$isHorizontalRuleNode(node)) {\n return;\n }\n\n if (node.isSelected()) {\n addClassNamesToElement(element, selectedClassName);\n return;\n }\n\n removeClassNamesFromElement(element, selectedClassName);\n};\n\nconst registerHorizontalRuleInsertCommand = (\n editor: ReturnType[0]\n) => {\n return editor.registerCommand(\n INSERT_HORIZONTAL_RULE_COMMAND,\n () => {\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return false;\n }\n\n $insertNodeToNearestRoot($createHorizontalRuleNode());\n return true;\n },\n COMMAND_PRIORITY_EDITOR\n );\n};\n\nconst registerHorizontalRuleClickCommand = (\n editor: ReturnType[0]\n) => {\n return editor.registerCommand(\n CLICK_COMMAND,\n (event) => {\n const node = getClickedHorizontalRuleNode(event.target);\n if (!node) {\n return false;\n }\n\n toggleNodeSelection(node, event.shiftKey);\n return true;\n },\n COMMAND_PRIORITY_LOW\n );\n};\n\nconst registerHorizontalRuleMutationListener = (\n editor: ReturnType[0],\n selectedClassName: string\n) => {\n return editor.registerMutationListener(HorizontalRuleNode, (nodes) => {\n editor.read(() => {\n for (const [nodeKey, mutation] of nodes) {\n if (mutation === \"destroyed\") {\n continue;\n }\n\n syncHorizontalRuleSelectionClass(editor, nodeKey, selectedClassName);\n }\n });\n });\n};\n\nexport function HorizontalRulePlugin() {\n const [editor] = useLexicalComposerContext();\n\n useEffect(() => {\n const hrSelectedClassName = editor._config.theme.hrSelected ?? \"selected\";\n\n return mergeRegister(\n registerHorizontalRuleInsertCommand(editor),\n registerHorizontalRuleClickCommand(editor),\n registerHorizontalRuleMutationListener(editor, hrSelectedClassName)\n );\n }, [editor]);\n\n return null;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/core/horizontal-rule.tsx", "target": "src/components/editor/plugins/core/horizontal-rule.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport type { LexicalEditor } from \"lexical\";\nimport { useEffect } from \"react\";\nimport { DEFAULT_EDITOR_MARKDOWN } from \"../../core/constants\";\nimport { loadMarkdownContent, readEditorSnapshot } from \"../../core/utils\";\n\ninterface SeedContentPluginProps {\n editor: LexicalEditor | null;\n}\n\nexport function SeedContentPlugin({ editor }: SeedContentPluginProps) {\n useEffect(() => {\n if (!editor) {\n return;\n }\n\n const snapshot = readEditorSnapshot(editor);\n if (snapshot.text.trim()) {\n return;\n }\n\n loadMarkdownContent(editor, DEFAULT_EDITOR_MARKDOWN);\n }, [editor]);\n\n return null;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/core/seed-content.tsx", "target": "src/components/editor/plugins/core/seed-content.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { DraggableBlockPlugin_EXPERIMENTAL } from \"@lexical/react/LexicalDraggableBlockPlugin\";\nimport { useLexicalEditable } from \"@lexical/react/useLexicalEditable\";\nimport { GripVerticalIcon } from \"lucide-react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nconst DRAG_MENU_CLASS_NAME = \"editor-draggable-block-menu\";\n\nexport function DraggableBlockPlugin() {\n const [editor] = useLexicalComposerContext();\n const isEditable = useLexicalEditable();\n const menuRef = useRef(null);\n const targetLineRef = useRef(null);\n const [anchorElem, setAnchorElem] = useState(null);\n\n useEffect(() => {\n return editor.registerRootListener((rootElement) => {\n setAnchorElem(rootElement?.parentElement ?? null);\n });\n }, [editor]);\n\n const isOnMenu = useCallback(\n (element: HTMLElement) =>\n Boolean(element.closest(`.${DRAG_MENU_CLASS_NAME}`)),\n []\n );\n\n if (!(isEditable && anchorElem)) {\n return null;\n }\n\n return (\n \n
\n \n
\n \n }\n menuRef={menuRef}\n targetLineComponent={\n \n }\n targetLineRef={targetLineRef}\n />\n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/draggable-block/plugin.tsx", "target": "src/components/editor/plugins/draggable-block/plugin.tsx", "type": "registry:file" }, { "content": "import {\n $createLinkNode,\n $isAutoLinkNode,\n TOGGLE_LINK_COMMAND,\n} from \"@lexical/link\";\nimport { $patchStyleText } from \"@lexical/selection\";\nimport {\n $getSelection,\n $isRangeSelection,\n FORMAT_TEXT_COMMAND,\n type LexicalEditor,\n} from \"lexical\";\nimport {\n isValidEditorLinkUrl,\n normalizeEditorLinkUrl,\n} from \"../link-behavior/utils\";\nimport {\n getFloatingToolbarSelectedNode,\n getSelectedLinkNode,\n} from \"./selection\";\n\nexport const submitToolbarLink = (\n editor: LexicalEditor,\n linkUrl: string\n): string => {\n const normalizedLinkUrl = normalizeEditorLinkUrl(linkUrl);\n\n if (isValidEditorLinkUrl(normalizedLinkUrl)) {\n editor.update(() => {\n editor.dispatchCommand(TOGGLE_LINK_COMMAND, normalizedLinkUrl);\n\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return;\n }\n\n const selectedNode = getFloatingToolbarSelectedNode(selection);\n const linkNode = getSelectedLinkNode(selectedNode);\n if (!$isAutoLinkNode(linkNode)) {\n return;\n }\n\n const replacementLinkNode = $createLinkNode(linkNode.getURL(), {\n rel: linkNode.__rel,\n target: linkNode.__target,\n title: linkNode.__title,\n });\n linkNode.replace(replacementLinkNode, true);\n });\n } else {\n editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);\n }\n\n return \"\";\n};\n\nexport const clearToolbarLink = (editor: LexicalEditor) => {\n editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);\n};\n\nexport const toggleToolbarFormat = (\n editor: LexicalEditor,\n format:\n | \"bold\"\n | \"italic\"\n | \"underline\"\n | \"strikethrough\"\n | \"code\"\n | \"highlight\"\n) => {\n editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);\n};\n\n/**\n * Applies an inline `color` CSS property to the current Lexical selection.\n * Passing an empty string removes the property from all selected text nodes.\n */\nexport const applyTextColor = (editor: LexicalEditor, color: string) => {\n editor.update(() => {\n const selection = $getSelection();\n if (selection !== null) {\n $patchStyleText(selection, { color: color || null });\n }\n });\n};\n\n/**\n * Applies an inline `background-color` CSS property to the current Lexical\n * selection. Passing an empty string removes the property.\n */\nexport const applyBgColor = (editor: LexicalEditor, color: string) => {\n editor.update(() => {\n const selection = $getSelection();\n if (selection !== null) {\n $patchStyleText(selection, { \"background-color\": color || null });\n }\n });\n};\n", "path": "registry/pytah/editor/components/editor/plugins/floating-toolbar/actions.ts", "target": "src/components/editor/plugins/floating-toolbar/actions.ts", "type": "registry:file" }, { "content": "import type {\n FloatingToolbarFormatState,\n FloatingToolbarPosition,\n} from \"./types\";\n\nexport const EMPTY_TOOLBAR_POSITION: FloatingToolbarPosition = {\n left: 0,\n top: 0,\n};\n\nexport const DEFAULT_FORMAT_STATE: FloatingToolbarFormatState = {\n isBold: false,\n isCode: false,\n isHighlight: false,\n isItalic: false,\n isLink: false,\n isStrikethrough: false,\n isSubscript: false,\n isSuperscript: false,\n isUnderline: false,\n bgColor: \"\",\n textColor: \"\",\n};\n", "path": "registry/pytah/editor/components/editor/plugins/floating-toolbar/constants.ts", "target": "src/components/editor/plugins/floating-toolbar/constants.ts", "type": "registry:file" }, { "content": "import { createCommand } from \"lexical\";\n\nexport const OPEN_FLOATING_LINK_EDITOR_COMMAND = createCommand(\n \"OPEN_FLOATING_LINK_EDITOR_COMMAND\"\n);\n", "path": "registry/pytah/editor/components/editor/plugins/floating-toolbar/link-command.ts", "target": "src/components/editor/plugins/floating-toolbar/link-command.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { TOGGLE_LINK_COMMAND } from \"@lexical/link\";\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { mergeRegister } from \"@lexical/utils\";\nimport { COMMAND_PRIORITY_LOW, SELECTION_CHANGE_COMMAND } from \"lexical\";\nimport {\n BaselineIcon,\n BoldIcon,\n CodeIcon,\n HighlighterIcon,\n ItalicIcon,\n LinkIcon,\n PaintBucketIcon,\n StrikethroughIcon,\n UnderlineIcon,\n} from \"lucide-react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Toggle } from \"@/components/ui/toggle\";\nimport { cn } from \"@/lib/utils\";\nimport { ColorSwatches } from \"../../ui/color-swatches\";\nimport { LINK_PLACEHOLDER_URL } from \"../link-behavior/utils\";\nimport { applyBgColor, applyTextColor, toggleToolbarFormat } from \"./actions\";\nimport { DEFAULT_FORMAT_STATE, EMPTY_TOOLBAR_POSITION } from \"./constants\";\nimport { OPEN_FLOATING_LINK_EDITOR_COMMAND } from \"./link-command\";\nimport {\n areFloatingToolbarFormatsEqual,\n areFloatingToolbarPositionsEqual,\n readFloatingToolbarState,\n} from \"./selection\";\nimport type {\n FloatingToolbarFormatState,\n FloatingToolbarPosition,\n} from \"./types\";\n\nconst TOOLBAR_FORMAT_ACTIONS = [\n { format: \"bold\", icon: BoldIcon, key: \"isBold\" },\n { format: \"italic\", icon: ItalicIcon, key: \"isItalic\" },\n { format: \"underline\", icon: UnderlineIcon, key: \"isUnderline\" },\n { format: \"strikethrough\", icon: StrikethroughIcon, key: \"isStrikethrough\" },\n { format: \"highlight\", icon: HighlighterIcon, key: \"isHighlight\" },\n { format: \"code\", icon: CodeIcon, key: \"isCode\" },\n] as const;\n\nexport function FloatingToolbarPlugin() {\n const [editor] = useLexicalComposerContext();\n const toolbarRef = useRef(null);\n\n const [isVisible, setIsVisible] = useState(false);\n const [position, setPosition] = useState(\n EMPTY_TOOLBAR_POSITION\n );\n const [formats, setFormats] =\n useState(DEFAULT_FORMAT_STATE);\n\n /*\n * When a color picker popover is open we skip visibility/position updates so\n * the floating toolbar stays alive while the user browses swatches. A ref\n * (rather than state) is used to avoid re-registering the update listener on\n * every open/close cycle.\n */\n const isColorPickerOpenRef = useRef(false);\n\n const updateToolbar = useCallback(() => {\n editor.getEditorState().read(() => {\n const toolbarState = readFloatingToolbarState();\n\n setFormats((currentFormats) => {\n return areFloatingToolbarFormatsEqual(\n currentFormats,\n toolbarState.formats\n )\n ? currentFormats\n : toolbarState.formats;\n });\n\n if (!isColorPickerOpenRef.current) {\n setIsVisible((currentIsVisible) => {\n return currentIsVisible === toolbarState.isVisible\n ? currentIsVisible\n : toolbarState.isVisible;\n });\n setPosition((currentPosition) => {\n return areFloatingToolbarPositionsEqual(\n currentPosition,\n toolbarState.position\n )\n ? currentPosition\n : toolbarState.position;\n });\n }\n });\n }, [editor]);\n\n useEffect(() => {\n return mergeRegister(\n editor.registerCommand(\n SELECTION_CHANGE_COMMAND,\n () => {\n updateToolbar();\n return false;\n },\n COMMAND_PRIORITY_LOW\n ),\n editor.registerUpdateListener(() => {\n updateToolbar();\n })\n );\n }, [editor, updateToolbar]);\n\n const handleLinkToggle = useCallback(() => {\n if (formats.isLink) {\n editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);\n return;\n }\n\n editor.dispatchCommand(TOGGLE_LINK_COMMAND, LINK_PLACEHOLDER_URL);\n editor.dispatchCommand(OPEN_FLOATING_LINK_EDITOR_COMMAND, undefined);\n }, [editor, formats.isLink]);\n\n const handleColorPickerOpenChange = useCallback((open: boolean) => {\n isColorPickerOpenRef.current = open;\n }, []);\n\n if (!isVisible) {\n return null;\n }\n\n return createPortal(\n \n \n {TOOLBAR_FORMAT_ACTIONS.map((action) => {\n const Icon = action.icon;\n\n return (\n toggleToolbarFormat(editor, action.format)}\n pressed={formats[action.key]}\n size=\"sm\"\n >\n \n \n );\n })}\n\n \n\n {/* Text color — uses `color` CSS property */}\n applyTextColor(editor, color)}\n onOpenChange={handleColorPickerOpenChange}\n />\n\n {/* Background color — uses `background-color` CSS property */}\n applyBgColor(editor, color)}\n onOpenChange={handleColorPickerOpenChange}\n />\n\n \n\n \n \n \n \n ,\n document.body\n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/floating-toolbar/plugin.tsx", "target": "src/components/editor/plugins/floating-toolbar/plugin.tsx", "type": "registry:file" }, { "content": "import { $isCodeNode } from \"@lexical/code\";\nimport { $isAutoLinkNode, $isLinkNode } from \"@lexical/link\";\nimport { $getSelectionStyleValueForProperty } from \"@lexical/selection\";\nimport {\n $getSelection,\n $isLineBreakNode,\n $isRangeSelection,\n type LexicalNode,\n type RangeSelection,\n} from \"lexical\";\nimport { DEFAULT_FORMAT_STATE, EMPTY_TOOLBAR_POSITION } from \"./constants\";\nimport type {\n FloatingToolbarFormatState,\n FloatingToolbarPosition,\n FloatingToolbarState,\n} from \"./types\";\n\nexport const areFloatingToolbarFormatsEqual = (\n left: FloatingToolbarFormatState,\n right: FloatingToolbarFormatState\n) => {\n return (\n left.bgColor === right.bgColor &&\n left.isBold === right.isBold &&\n left.isCode === right.isCode &&\n left.isHighlight === right.isHighlight &&\n left.isItalic === right.isItalic &&\n left.isLink === right.isLink &&\n left.isStrikethrough === right.isStrikethrough &&\n left.isSubscript === right.isSubscript &&\n left.isSuperscript === right.isSuperscript &&\n left.isUnderline === right.isUnderline &&\n left.textColor === right.textColor\n );\n};\n\nexport const areFloatingToolbarPositionsEqual = (\n left: FloatingToolbarPosition,\n right: FloatingToolbarPosition\n) => {\n return left.left === right.left && left.top === right.top;\n};\n\nexport const getFloatingToolbarSelectedNode = (selection: RangeSelection) => {\n const anchorNode = selection.anchor.getNode();\n const focusNode = selection.focus.getNode();\n\n if (anchorNode === focusNode) {\n return anchorNode;\n }\n\n return selection.isBackward() ? anchorNode : focusNode;\n};\n\nexport const getSelectedLinkNode = (node: LexicalNode) => {\n const parent = node.getParent();\n\n if ($isLinkNode(parent) || $isAutoLinkNode(parent)) {\n return parent;\n }\n\n if ($isLinkNode(node) || $isAutoLinkNode(node)) {\n return node;\n }\n\n return null;\n};\n\nexport const isSelectionWithinSingleLink = (selection: RangeSelection) => {\n const focusNode = getFloatingToolbarSelectedNode(selection);\n const focusLinkNode = getSelectedLinkNode(focusNode);\n\n if (!focusLinkNode) {\n return false;\n }\n\n const invalidNode = selection\n .getNodes()\n .filter((node) => !$isLineBreakNode(node))\n .find((node) => {\n const linkNode = getSelectedLinkNode(node);\n\n if (focusLinkNode && !focusLinkNode.is(linkNode)) {\n return true;\n }\n\n return $isAutoLinkNode(linkNode) && linkNode.getIsUnlinked();\n });\n\n return invalidNode === undefined;\n};\n\nconst getToolbarPosition = (): FloatingToolbarPosition | null => {\n const nativeSelection = window.getSelection();\n if (!(nativeSelection && nativeSelection.rangeCount > 0)) {\n return null;\n }\n\n const range = nativeSelection.getRangeAt(0);\n const rectangle = range.getBoundingClientRect();\n\n if (rectangle.width === 0 && rectangle.height === 0) {\n return null;\n }\n\n return {\n left: rectangle.left + rectangle.width / 2,\n top: rectangle.top - 10,\n };\n};\n\nconst getFormatState = (\n selection: RangeSelection,\n node: LexicalNode\n): FloatingToolbarFormatState => {\n const linkNode = getSelectedLinkNode(node);\n\n return {\n isBold: selection.hasFormat(\"bold\"),\n isCode: selection.hasFormat(\"code\"),\n isHighlight: selection.hasFormat(\"highlight\"),\n isItalic: selection.hasFormat(\"italic\"),\n isLink: linkNode !== null,\n isStrikethrough: selection.hasFormat(\"strikethrough\"),\n isSubscript: selection.hasFormat(\"subscript\"),\n isSuperscript: selection.hasFormat(\"superscript\"),\n isUnderline: selection.hasFormat(\"underline\"),\n bgColor: $getSelectionStyleValueForProperty(\n selection,\n \"background-color\",\n \"\"\n ),\n textColor: $getSelectionStyleValueForProperty(selection, \"color\", \"\"),\n };\n};\n\n/**\n * Reads inline format state at the current cursor or selection without any\n * visibility or position checks. Intended for static (non-floating) toolbars\n * that always need to reflect the current editor state.\n */\nexport const readInlineFormats = (): FloatingToolbarFormatState => {\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return DEFAULT_FORMAT_STATE;\n }\n\n const node = getFloatingToolbarSelectedNode(selection);\n return getFormatState(selection, node);\n};\n\nexport const readFloatingToolbarState = (): FloatingToolbarState => {\n const selection = $getSelection();\n if (!($isRangeSelection(selection) && !selection.isCollapsed())) {\n return {\n formats: DEFAULT_FORMAT_STATE,\n isVisible: false,\n linkUrl: \"\",\n position: EMPTY_TOOLBAR_POSITION,\n };\n }\n\n const node = getFloatingToolbarSelectedNode(selection);\n const parent = node.getParent();\n const isInsideCodeBlock =\n $isCodeNode(node) || (parent && $isCodeNode(parent));\n\n if (isInsideCodeBlock) {\n return {\n formats: DEFAULT_FORMAT_STATE,\n isVisible: false,\n linkUrl: \"\",\n position: EMPTY_TOOLBAR_POSITION,\n };\n }\n\n const position = getToolbarPosition();\n if (!position) {\n return {\n formats: DEFAULT_FORMAT_STATE,\n isVisible: false,\n linkUrl: \"\",\n position: EMPTY_TOOLBAR_POSITION,\n };\n }\n\n const linkNode = isSelectionWithinSingleLink(selection)\n ? getSelectedLinkNode(node)\n : null;\n\n return {\n formats: getFormatState(selection, node),\n isVisible: true,\n linkUrl: linkNode?.getURL() ?? \"\",\n position,\n };\n};\n", "path": "registry/pytah/editor/components/editor/plugins/floating-toolbar/selection.ts", "target": "src/components/editor/plugins/floating-toolbar/selection.ts", "type": "registry:file" }, { "content": "export interface FloatingToolbarFormatState {\n /** Current background-color CSS value of the selection, or \"\" if none/mixed. */\n bgColor: string;\n isBold: boolean;\n isCode: boolean;\n isHighlight: boolean;\n isItalic: boolean;\n isLink: boolean;\n isStrikethrough: boolean;\n isSubscript: boolean;\n isSuperscript: boolean;\n isUnderline: boolean;\n /** Current color CSS value of the selection, or \"\" if none/mixed. */\n textColor: string;\n}\n\nexport interface FloatingToolbarPosition {\n left: number;\n top: number;\n}\n\nexport interface FloatingToolbarState {\n formats: FloatingToolbarFormatState;\n isVisible: boolean;\n linkUrl: string;\n position: FloatingToolbarPosition;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/floating-toolbar/types.ts", "target": "src/components/editor/plugins/floating-toolbar/types.ts", "type": "registry:file" }, { "content": "import {\n AlignCenterIcon,\n AlignJustifyIcon,\n AlignLeftIcon,\n AlignRightIcon,\n BoldIcon,\n CodeIcon,\n HighlighterIcon,\n ItalicIcon,\n StrikethroughIcon,\n UnderlineIcon,\n} from \"lucide-react\";\n\nexport const INLINE_FORMAT_ACTIONS = [\n { format: \"bold\", icon: BoldIcon, key: \"isBold\", label: \"Bold\" },\n { format: \"italic\", icon: ItalicIcon, key: \"isItalic\", label: \"Italic\" },\n {\n format: \"strikethrough\",\n icon: StrikethroughIcon,\n key: \"isStrikethrough\",\n label: \"Strikethrough\",\n },\n { format: \"code\", icon: CodeIcon, key: \"isCode\", label: \"Inline code\" },\n {\n format: \"underline\",\n icon: UnderlineIcon,\n key: \"isUnderline\",\n label: \"Underline\",\n },\n {\n format: \"highlight\",\n icon: HighlighterIcon,\n key: \"isHighlight\",\n label: \"Highlight\",\n },\n] as const;\n\nexport const ALIGN_ACTIONS = [\n { align: \"left\" as const, icon: AlignLeftIcon, label: \"Align left\" },\n { align: \"center\" as const, icon: AlignCenterIcon, label: \"Align center\" },\n { align: \"right\" as const, icon: AlignRightIcon, label: \"Align right\" },\n { align: \"justify\" as const, icon: AlignJustifyIcon, label: \"Justify\" },\n];\n", "path": "registry/pytah/editor/components/editor/plugins/full-toolbar/constants.ts", "target": "src/components/editor/plugins/full-toolbar/constants.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { INSERT_HORIZONTAL_RULE_COMMAND } from \"@lexical/extension\";\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { INSERT_TABLE_COMMAND } from \"@lexical/table\";\nimport { ChevronDownIcon, PlusIcon, TableIcon } from \"lucide-react\";\nimport { useEffect, useRef } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\nimport { INSERT_COLLAPSIBLE_COMMAND } from \"../collapsible/commands\";\nimport { DEFAULT_INSERT_TABLE_PAYLOAD } from \"../table-behavior/constants\";\nimport type { FullToolbarUiAction } from \"./types\";\n\ninterface InsertPopoverProps {\n activeInsertIndex: number;\n dispatchUi: (action: FullToolbarUiAction) => void;\n insertOpen: boolean;\n onOpenChange: (open: boolean) => void;\n}\n\nexport function InsertPopover({\n activeInsertIndex,\n dispatchUi,\n insertOpen,\n onOpenChange,\n}: InsertPopoverProps) {\n const [editor] = useLexicalComposerContext();\n const insertOptionRefs = useRef>([]);\n\n const insertTable = () => {\n editor.dispatchCommand(INSERT_TABLE_COMMAND, DEFAULT_INSERT_TABLE_PAYLOAD);\n dispatchUi({ type: \"set-insert-open\", payload: { open: false } });\n };\n\n const insertDivider = () => {\n editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined);\n dispatchUi({ type: \"set-insert-open\", payload: { open: false } });\n };\n\n const insertCollapsible = () => {\n editor.dispatchCommand(INSERT_COLLAPSIBLE_COMMAND, undefined);\n dispatchUi({ type: \"set-insert-open\", payload: { open: false } });\n };\n\n const insertActions = [\n {\n icon: ,\n label: \"Table\",\n onSelect: insertTable,\n },\n {\n icon: (\n \n -\n \n ),\n label: \"Divider\",\n onSelect: insertDivider,\n },\n {\n icon: ,\n label: \"Collapsible\",\n onSelect: insertCollapsible,\n },\n ];\n\n const focusInsertOption = (index: number) => {\n const optionCount = insertActions.length;\n const nextIndex = (index + optionCount) % optionCount;\n\n dispatchUi({ type: \"set-active-insert-index\", payload: nextIndex });\n insertOptionRefs.current[nextIndex]?.focus();\n };\n\n useEffect(() => {\n if (!insertOpen) {\n return;\n }\n\n const animationFrameId = requestAnimationFrame(() => {\n insertOptionRefs.current[activeInsertIndex]?.focus();\n });\n\n return () => {\n cancelAnimationFrame(animationFrameId);\n };\n }, [activeInsertIndex, insertOpen]);\n\n const handleInsertListKeyDown = (\n event: React.KeyboardEvent\n ) => {\n switch (event.key) {\n case \"ArrowDown\": {\n event.preventDefault();\n focusInsertOption(activeInsertIndex + 1);\n return;\n }\n case \"ArrowUp\": {\n event.preventDefault();\n focusInsertOption(activeInsertIndex - 1);\n return;\n }\n case \"Home\": {\n event.preventDefault();\n focusInsertOption(0);\n return;\n }\n case \"End\": {\n event.preventDefault();\n focusInsertOption(insertActions.length - 1);\n return;\n }\n case \"Enter\":\n case \" \": {\n event.preventDefault();\n insertActions[activeInsertIndex]?.onSelect();\n return;\n }\n default: {\n return;\n }\n }\n };\n\n return (\n \n \n }\n >\n \n Insert\n \n \n \n {insertActions.map((action, optionIndex) => {\n const isActive = optionIndex === activeInsertIndex;\n\n return (\n \n dispatchUi({\n type: \"set-active-insert-index\",\n payload: optionIndex,\n })\n }\n onMouseEnter={() =>\n dispatchUi({\n type: \"set-active-insert-index\",\n payload: optionIndex,\n })\n }\n ref={(element) => {\n insertOptionRefs.current[optionIndex] = element;\n }}\n role=\"option\"\n tabIndex={optionIndex === activeInsertIndex ? 0 : -1}\n type=\"button\"\n >\n {action.icon}\n {action.label}\n \n );\n })}\n \n \n \n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/full-toolbar/insert-popover.tsx", "target": "src/components/editor/plugins/full-toolbar/insert-popover.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { TOGGLE_LINK_COMMAND } from \"@lexical/link\";\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport {\n FORMAT_ELEMENT_COMMAND,\n FORMAT_TEXT_COMMAND,\n INDENT_CONTENT_COMMAND,\n OUTDENT_CONTENT_COMMAND,\n REDO_COMMAND,\n UNDO_COMMAND,\n} from \"lexical\";\nimport {\n BaselineIcon,\n IndentDecreaseIcon,\n IndentIncreaseIcon,\n LinkIcon,\n PaintBucketIcon,\n RedoIcon,\n SubscriptIcon,\n SuperscriptIcon,\n UndoIcon,\n} from \"lucide-react\";\nimport { useReducer } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Toggle } from \"@/components/ui/toggle\";\nimport { cn } from \"@/lib/utils\";\nimport { ColorSwatches } from \"../../ui/color-swatches\";\nimport { BlockTypeDrop } from \"../block-type-toolbar/block-type-drop\";\nimport type { BlockTypeValue } from \"../block-type-toolbar/types\";\nimport {\n applyBgColor,\n applyTextColor,\n toggleToolbarFormat,\n} from \"../floating-toolbar/actions\";\nimport { OPEN_FLOATING_LINK_EDITOR_COMMAND } from \"../floating-toolbar/link-command\";\nimport { LINK_PLACEHOLDER_URL } from \"../link-behavior/utils\";\nimport { ALIGN_ACTIONS, INLINE_FORMAT_ACTIONS } from \"./constants\";\nimport { InsertPopover } from \"./insert-popover\";\nimport { fullToolbarUiReducer } from \"./reducer\";\nimport { INITIAL_UI_STATE } from \"./types\";\nimport { useToolbarState } from \"./use-toolbar-state\";\n\ninterface FullToolbarPluginProps {\n className?: string;\n}\n\nexport function FullToolbarPlugin({ className }: FullToolbarPluginProps) {\n const [editor] = useLexicalComposerContext();\n const { blockType, formats, setBlockType } = useToolbarState();\n const [uiState, dispatchUi] = useReducer(\n fullToolbarUiReducer,\n INITIAL_UI_STATE\n );\n const { activeInsertIndex, insertOpen } = uiState;\n\n const handleBlockTypeChange = (value: BlockTypeValue) => {\n setBlockType(value);\n };\n\n const handleLinkToggle = () => {\n if (formats.isLink) {\n editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);\n return;\n }\n editor.dispatchCommand(TOGGLE_LINK_COMMAND, LINK_PLACEHOLDER_URL);\n editor.dispatchCommand(OPEN_FLOATING_LINK_EDITOR_COMMAND, undefined);\n };\n\n return (\n \n editor.dispatchCommand(UNDO_COMMAND, undefined)}\n size=\"icon-sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n editor.dispatchCommand(REDO_COMMAND, undefined)}\n size=\"icon-sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n\n \n\n \n\n \n\n {INLINE_FORMAT_ACTIONS.map((action) => {\n const Icon = action.icon;\n return (\n toggleToolbarFormat(editor, action.format)}\n pressed={formats[action.key]}\n size=\"sm\"\n >\n \n \n );\n })}\n\n applyTextColor(editor, color)}\n />\n applyBgColor(editor, color)}\n />\n\n \n \n \n\n \n\n \n editor.dispatchCommand(FORMAT_TEXT_COMMAND, \"superscript\")\n }\n pressed={formats.isSuperscript}\n size=\"sm\"\n >\n \n \n \n editor.dispatchCommand(FORMAT_TEXT_COMMAND, \"subscript\")\n }\n pressed={formats.isSubscript}\n size=\"sm\"\n >\n \n \n\n \n\n {ALIGN_ACTIONS.map((action) => {\n const Icon = action.icon;\n return (\n \n editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, action.align)\n }\n size=\"icon-sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n );\n })}\n\n \n\n \n editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)\n }\n size=\"icon-sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n \n editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)\n }\n size=\"icon-sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n\n \n\n \n dispatchUi({\n type: \"set-insert-open\",\n payload: { activeInsertIndex: 0, open },\n })\n }\n />\n \n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/full-toolbar/plugin.tsx", "target": "src/components/editor/plugins/full-toolbar/plugin.tsx", "type": "registry:file" }, { "content": "import type { FullToolbarUiAction, FullToolbarUiState } from \"./types\";\n\nexport const fullToolbarUiReducer = (\n state: FullToolbarUiState,\n action: FullToolbarUiAction\n): FullToolbarUiState => {\n switch (action.type) {\n case \"set-active-insert-index\": {\n return state.activeInsertIndex === action.payload\n ? state\n : { ...state, activeInsertIndex: action.payload };\n }\n case \"set-insert-open\": {\n return {\n ...state,\n activeInsertIndex:\n action.payload.activeInsertIndex ?? state.activeInsertIndex,\n insertOpen: action.payload.open,\n };\n }\n default: {\n return state;\n }\n }\n};\n", "path": "registry/pytah/editor/components/editor/plugins/full-toolbar/reducer.ts", "target": "src/components/editor/plugins/full-toolbar/reducer.ts", "type": "registry:file" }, { "content": "export interface FullToolbarUiState {\n activeInsertIndex: number;\n insertOpen: boolean;\n}\n\nexport type FullToolbarUiAction =\n | { type: \"set-active-insert-index\"; payload: number }\n | {\n type: \"set-insert-open\";\n payload: { activeInsertIndex?: number; open: boolean };\n };\n\nexport const INITIAL_UI_STATE: FullToolbarUiState = {\n activeInsertIndex: 0,\n insertOpen: false,\n};\n", "path": "registry/pytah/editor/components/editor/plugins/full-toolbar/types.ts", "target": "src/components/editor/plugins/full-toolbar/types.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { mergeRegister } from \"@lexical/utils\";\nimport { COMMAND_PRIORITY_LOW, SELECTION_CHANGE_COMMAND } from \"lexical\";\nimport { useEffect, useState } from \"react\";\nimport type { BlockTypeValue } from \"../block-type-toolbar/types\";\nimport { getBlockTypeFromSelection } from \"../block-type-toolbar/utils\";\nimport { DEFAULT_FORMAT_STATE } from \"../floating-toolbar/constants\";\nimport {\n areFloatingToolbarFormatsEqual,\n readInlineFormats,\n} from \"../floating-toolbar/selection\";\nimport type { FloatingToolbarFormatState } from \"../floating-toolbar/types\";\n\nexport function useToolbarState() {\n const [editor] = useLexicalComposerContext();\n const [blockType, setBlockType] = useState(\"paragraph\");\n const [formats, setFormats] =\n useState(DEFAULT_FORMAT_STATE);\n\n useEffect(() => {\n const update = () => {\n editor.getEditorState().read(() => {\n const nextBlockType = getBlockTypeFromSelection();\n const resolvedBlockType = nextBlockType ?? \"paragraph\";\n\n setBlockType((currentBlockType) =>\n currentBlockType === resolvedBlockType\n ? currentBlockType\n : resolvedBlockType\n );\n\n const nextFormats = readInlineFormats();\n setFormats((currentFormats) =>\n areFloatingToolbarFormatsEqual(currentFormats, nextFormats)\n ? currentFormats\n : nextFormats\n );\n });\n };\n\n update();\n\n return mergeRegister(\n editor.registerCommand(\n SELECTION_CHANGE_COMMAND,\n () => {\n update();\n return false;\n },\n COMMAND_PRIORITY_LOW\n ),\n editor.registerUpdateListener(() => {\n update();\n })\n );\n }, [editor]);\n\n return { blockType, formats, setBlockType };\n}\n", "path": "registry/pytah/editor/components/editor/plugins/full-toolbar/use-toolbar-state.ts", "target": "src/components/editor/plugins/full-toolbar/use-toolbar-state.ts", "type": "registry:file" }, { "content": "import type { NodeKey } from \"lexical\";\nimport { createCommand } from \"lexical\";\nimport type { ImageAlignment } from \"../../core/nodes/image/node\";\n\nexport interface InsertImagePayload {\n alignment?: ImageAlignment;\n altText: string;\n src: string;\n targetNodeKey?: NodeKey;\n}\n\nexport const INSERT_IMAGE_COMMAND = createCommand(\n \"INSERT_IMAGE_COMMAND\"\n);\n", "path": "registry/pytah/editor/components/editor/plugins/image/commands.ts", "target": "src/components/editor/plugins/image/commands.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { useLexicalEditable } from \"@lexical/react/useLexicalEditable\";\nimport { useLexicalNodeSelection } from \"@lexical/react/useLexicalNodeSelection\";\nimport { mergeRegister } from \"@lexical/utils\";\nimport {\n $getNodeByKey,\n $getSelection,\n $isNodeSelection,\n CLICK_COMMAND,\n COMMAND_PRIORITY_LOW,\n DRAGSTART_COMMAND,\n FORMAT_ELEMENT_COMMAND,\n KEY_BACKSPACE_COMMAND,\n KEY_DELETE_COMMAND,\n type NodeKey,\n} from \"lexical\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { $isImageNode, type ImageAlignment } from \"../../core/nodes/image/node\";\nimport { ImageResizer } from \"./resizer\";\n\ninterface ImageComponentProps {\n alignment: ImageAlignment;\n altText: string;\n height: number | \"inherit\";\n nodeKey: NodeKey;\n src: string;\n width: number | \"inherit\";\n}\n\nconst DEFAULT_IMAGE_WIDTH = 640;\n\nexport function ImageComponent({\n alignment,\n altText,\n height,\n nodeKey,\n src,\n width,\n}: ImageComponentProps) {\n const [editor] = useLexicalComposerContext();\n const editable = useLexicalEditable();\n const [isSelected, setSelected, clearSelection] =\n useLexicalNodeSelection(nodeKey);\n const imageRef = useRef(null);\n const [isResizing, setIsResizing] = useState(false);\n const effectiveWidth = width === \"inherit\" ? DEFAULT_IMAGE_WIDTH : width;\n const isInNodeSelection = useMemo(() => {\n if (!isSelected) {\n return false;\n }\n\n return editor.getEditorState().read(() => {\n const selection = $getSelection();\n return $isNodeSelection(selection) && selection.has(nodeKey);\n });\n }, [editor, isSelected, nodeKey]);\n const isFocused = (isSelected || isResizing) && editable;\n let figureClassName = \"my-4 w-fit max-w-full\";\n let alignmentClassName = \"inline-flex max-w-full\";\n\n if (alignment === \"center\") {\n figureClassName = \"my-4 w-full\";\n alignmentClassName = \"flex max-w-full justify-center\";\n } else if (alignment === \"right\") {\n figureClassName = \"my-4 ml-auto w-fit max-w-full\";\n alignmentClassName = \"flex max-w-full justify-end\";\n }\n\n useEffect(() => {\n if (!editable) {\n return;\n }\n\n const removeSelectedImage = (event: KeyboardEvent) => {\n const selection = $getSelection();\n if (!(isSelected && $isNodeSelection(selection))) {\n return false;\n }\n\n event.preventDefault();\n\n editor.update(() => {\n const node = $getNodeByKey(nodeKey);\n if ($isImageNode(node)) {\n node.remove();\n }\n });\n\n return true;\n };\n\n return mergeRegister(\n editor.registerCommand(\n CLICK_COMMAND,\n (event) => {\n if (isResizing) {\n return true;\n }\n\n if (event.target !== imageRef.current) {\n return false;\n }\n\n if (event.shiftKey) {\n setSelected(!isSelected);\n } else {\n clearSelection();\n setSelected(true);\n }\n\n return true;\n },\n COMMAND_PRIORITY_LOW\n ),\n editor.registerCommand(\n DRAGSTART_COMMAND,\n (event) => {\n if (event.target === imageRef.current) {\n event.preventDefault();\n return true;\n }\n\n return false;\n },\n COMMAND_PRIORITY_LOW\n ),\n editor.registerCommand(\n FORMAT_ELEMENT_COMMAND,\n (format) => {\n if (!isSelected) {\n return false;\n }\n\n if (\n !(format === \"left\" || format === \"center\" || format === \"right\")\n ) {\n return false;\n }\n\n editor.update(() => {\n const node = $getNodeByKey(nodeKey);\n if ($isImageNode(node)) {\n node.setAlignment(format as ImageAlignment);\n }\n });\n\n return true;\n },\n COMMAND_PRIORITY_LOW\n ),\n editor.registerCommand(\n KEY_BACKSPACE_COMMAND,\n removeSelectedImage,\n COMMAND_PRIORITY_LOW\n ),\n editor.registerCommand(\n KEY_DELETE_COMMAND,\n removeSelectedImage,\n COMMAND_PRIORITY_LOW\n )\n );\n }, [\n clearSelection,\n editable,\n editor,\n isResizing,\n isSelected,\n nodeKey,\n setSelected,\n ]);\n\n useEffect(() => {\n return () => {\n document.body.style.removeProperty(\"cursor\");\n document.body.style.removeProperty(\"-webkit-user-select\");\n document.body.style.removeProperty(\"user-select\");\n };\n }, []);\n\n return (\n
\n
\n
\n \n \n
\n\n {editable && isInNodeSelection && isFocused ? (\n {\n window.setTimeout(() => {\n setIsResizing(false);\n }, 200);\n\n editor.update(() => {\n const node = $getNodeByKey(nodeKey);\n if ($isImageNode(node)) {\n node.setWidthAndHeight(nextWidth, nextHeight);\n }\n });\n }}\n onResizeStart={() => {\n setIsResizing(true);\n }}\n />\n ) : null}\n
\n \n
\n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/image/component.tsx", "target": "src/components/editor/plugins/image/component.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { DRAG_DROP_PASTE, eventFiles } from \"@lexical/rich-text\";\nimport {\n $createParagraphNode,\n $getNodeByKey,\n $getSelection,\n $insertNodes,\n $isElementNode,\n $isRangeSelection,\n COMMAND_PRIORITY_EDITOR,\n COMMAND_PRIORITY_HIGH,\n DROP_COMMAND,\n PASTE_COMMAND,\n} from \"lexical\";\nimport { useEffect } from \"react\";\nimport { $createImageNode, $isImageNode } from \"../../core/nodes/image/node\";\nimport { INSERT_IMAGE_COMMAND } from \"./commands\";\nimport { getFirstImageFile, readFileAsDataUrl } from \"./utils\";\n\nconst insertParagraphAfterImage = (\n imageNode: ReturnType\n) => {\n if (!$isImageNode(imageNode)) {\n return;\n }\n\n const paragraph = $createParagraphNode();\n imageNode.insertAfter(paragraph);\n paragraph.select();\n};\n\nexport function ImagePlugin() {\n const [editor] = useLexicalComposerContext();\n\n useEffect(() => {\n const insertImage = ({\n alignment,\n altText,\n src,\n targetNodeKey,\n }: {\n alignment?: \"left\" | \"center\" | \"right\";\n altText: string;\n src: string;\n targetNodeKey?: string;\n }) => {\n const trimmedSrc = src.trim();\n if (!trimmedSrc) {\n return false;\n }\n\n const imageNode = $createImageNode({\n alignment,\n altText: altText.trim(),\n src: trimmedSrc,\n });\n\n if (targetNodeKey) {\n const targetNode = $getNodeByKey(targetNodeKey);\n if (!$isElementNode(targetNode)) {\n return false;\n }\n\n targetNode.replace(imageNode);\n insertParagraphAfterImage(imageNode);\n return true;\n }\n\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return false;\n }\n\n $insertNodes([imageNode]);\n insertParagraphAfterImage(imageNode);\n return true;\n };\n\n return editor.registerCommand(\n INSERT_IMAGE_COMMAND,\n (payload) => insertImage(payload),\n COMMAND_PRIORITY_EDITOR\n );\n }, [editor]);\n\n useEffect(() => {\n const insertImageFile = async (file: File) => {\n const src = await readFileAsDataUrl(file);\n\n editor.update(() => {\n editor.dispatchCommand(INSERT_IMAGE_COMMAND, {\n altText: file.name,\n src,\n });\n });\n };\n\n return editor.registerCommand(\n DRAG_DROP_PASTE,\n (files) => {\n const imageFile = getFirstImageFile(files);\n if (!imageFile) {\n return false;\n }\n\n insertImageFile(imageFile).catch(() => undefined);\n return true;\n },\n COMMAND_PRIORITY_HIGH\n );\n }, [editor]);\n\n useEffect(() => {\n return editor.registerCommand(\n PASTE_COMMAND,\n (event) => {\n const [, files] = eventFiles(event);\n const imageFile = getFirstImageFile(files);\n if (!imageFile) {\n return false;\n }\n\n event.preventDefault();\n editor.dispatchCommand(DRAG_DROP_PASTE, [imageFile]);\n return true;\n },\n COMMAND_PRIORITY_HIGH\n );\n }, [editor]);\n\n useEffect(() => {\n return editor.registerCommand(\n DROP_COMMAND,\n (event) => {\n const [, files] = eventFiles(event);\n const imageFile = getFirstImageFile(files);\n if (!imageFile) {\n return false;\n }\n\n event.preventDefault();\n editor.dispatchCommand(DRAG_DROP_PASTE, [imageFile]);\n return true;\n },\n COMMAND_PRIORITY_HIGH\n );\n }, [editor]);\n\n return null;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/image/plugin.tsx", "target": "src/components/editor/plugins/image/plugin.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { calculateZoomLevel } from \"@lexical/utils\";\nimport type { LexicalEditor } from \"lexical\";\nimport { useRef } from \"react\";\n\ntype ResizeDirection = \"ne\" | \"nw\" | \"se\" | \"sw\";\n\ninterface ImageResizerProps {\n editor: LexicalEditor;\n imageRef: { current: HTMLImageElement | null };\n maxWidth?: number;\n onResizeEnd: (width: number, height: number) => void;\n onResizeStart: () => void;\n}\n\ninterface ResizeState {\n currentHeight: number;\n currentWidth: number;\n direction: ResizeDirection;\n isResizing: boolean;\n ratio: number;\n startHeight: number;\n startWidth: number;\n startX: number;\n startY: number;\n}\n\nconst CORNER_DIRECTIONS: ResizeDirection[] = [\"ne\", \"se\", \"sw\", \"nw\"];\n\nconst CORNER_CLASSES: Record = {\n ne: \"right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize\",\n se: \"bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize\",\n sw: \"bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize\",\n nw: \"left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize\",\n};\n\nconst clamp = (value: number, min: number, max: number) =>\n Math.min(Math.max(value, min), max);\n\nconst isEast = (d: ResizeDirection) => d === \"ne\" || d === \"se\";\n\nconst getCursor = (d: ResizeDirection) =>\n d === \"nw\" || d === \"se\" ? \"nwse-resize\" : \"nesw-resize\";\n\nexport function ImageResizer({\n editor,\n imageRef,\n maxWidth,\n onResizeEnd,\n onResizeStart,\n}: ImageResizerProps) {\n const controlWrapperRef = useRef(null);\n const userSelect = useRef({ priority: \"\", value: \"default\" });\n const positioningRef = useRef({\n currentHeight: 0,\n currentWidth: 0,\n direction: \"se\",\n isResizing: false,\n ratio: 0,\n startHeight: 0,\n startWidth: 0,\n startX: 0,\n startY: 0,\n });\n\n const editorRootElement = editor.getRootElement();\n let maxWidthContainer = 100;\n if (maxWidth) {\n maxWidthContainer = maxWidth;\n } else if (editorRootElement) {\n maxWidthContainer = editorRootElement.getBoundingClientRect().width - 20;\n }\n\n const setStartCursor = (direction: ResizeDirection) => {\n const cursor = getCursor(direction);\n editorRootElement?.style.setProperty(\"cursor\", cursor, \"important\");\n document.body.style.setProperty(\"cursor\", cursor, \"important\");\n userSelect.current.value = document.body.style.getPropertyValue(\n \"-webkit-user-select\"\n );\n userSelect.current.priority = document.body.style.getPropertyPriority(\n \"-webkit-user-select\"\n );\n document.body.style.setProperty(\"-webkit-user-select\", \"none\", \"important\");\n };\n\n const setEndCursor = () => {\n editorRootElement?.style.setProperty(\"cursor\", \"text\");\n document.body.style.setProperty(\"cursor\", \"default\");\n document.body.style.setProperty(\n \"-webkit-user-select\",\n userSelect.current.value,\n userSelect.current.priority\n );\n };\n\n const handlePointerMove = (event: PointerEvent) => {\n const image = imageRef.current;\n const positioning = positioningRef.current;\n\n if (!(image && positioning.isResizing)) {\n return;\n }\n\n const zoom = calculateZoomLevel(image);\n // All corners resize both axes while locking aspect ratio.\n let diff = Math.floor(positioning.startX - event.clientX / zoom);\n diff = isEast(positioning.direction) ? -diff : diff;\n\n const width = clamp(positioning.startWidth + diff, 100, maxWidthContainer);\n const height = width / positioning.ratio;\n\n image.style.width = `${width}px`;\n image.style.height = `${height}px`;\n positioning.currentHeight = height;\n positioning.currentWidth = width;\n };\n\n const handlePointerUp = () => {\n const image = imageRef.current;\n const controlWrapper = controlWrapperRef.current;\n const positioning = positioningRef.current;\n\n document.removeEventListener(\"pointermove\", handlePointerMove);\n document.removeEventListener(\"pointerup\", handlePointerUp);\n setEndCursor();\n\n if (!(image && controlWrapper && positioning.isResizing)) {\n return;\n }\n\n const width = positioning.currentWidth;\n const height = positioning.currentHeight;\n\n positioning.startWidth = 0;\n positioning.startHeight = 0;\n positioning.ratio = 0;\n positioning.startX = 0;\n positioning.startY = 0;\n positioning.currentWidth = 0;\n positioning.currentHeight = 0;\n positioning.isResizing = false;\n\n controlWrapper.classList.remove(\"image-control-wrapper--resizing\");\n onResizeEnd(width, height);\n };\n\n const handlePointerDown = (\n event: React.PointerEvent,\n direction: ResizeDirection\n ) => {\n if (!editor.isEditable()) {\n return;\n }\n\n const image = imageRef.current;\n const controlWrapper = controlWrapperRef.current;\n\n if (!(image && controlWrapper)) {\n return;\n }\n\n event.preventDefault();\n\n const { height, width } = image.getBoundingClientRect();\n const zoom = calculateZoomLevel(image);\n const positioning = positioningRef.current;\n\n positioning.startWidth = width;\n positioning.startHeight = height;\n positioning.ratio = width / height;\n positioning.currentWidth = width;\n positioning.currentHeight = height;\n positioning.startX = event.clientX / zoom;\n positioning.startY = event.clientY / zoom;\n positioning.isResizing = true;\n positioning.direction = direction;\n\n setStartCursor(direction);\n onResizeStart();\n\n controlWrapper.classList.add(\"image-control-wrapper--resizing\");\n image.style.height = `${height}px`;\n image.style.width = `${width}px`;\n\n document.addEventListener(\"pointermove\", handlePointerMove);\n document.addEventListener(\"pointerup\", handlePointerUp);\n };\n\n return (\n \n {CORNER_DIRECTIONS.map((direction) => (\n handlePointerDown(event, direction)}\n />\n ))}\n \n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/image/resizer.tsx", "target": "src/components/editor/plugins/image/resizer.tsx", "type": "registry:file" }, { "content": "const IMAGE_MIME_PREFIX = \"image/\";\n\nexport const isImageFile = (file: File): boolean => {\n return file.type.startsWith(IMAGE_MIME_PREFIX);\n};\n\nexport const getFirstImageFile = (files: Iterable): File | null => {\n for (const file of files) {\n if (isImageFile(file)) {\n return file;\n }\n }\n\n return null;\n};\n\nexport const readFileAsDataUrl = async (file: File): Promise => {\n return await new Promise((resolve, reject) => {\n const reader = new FileReader();\n\n reader.addEventListener(\"load\", () => {\n if (typeof reader.result === \"string\") {\n resolve(reader.result);\n return;\n }\n\n reject(new Error(\"Image file could not be read as a data URL.\"));\n });\n\n reader.addEventListener(\"error\", () => {\n reject(reader.error ?? new Error(\"Failed to read image file.\"));\n });\n\n reader.readAsDataURL(file);\n });\n};\n", "path": "registry/pytah/editor/components/editor/plugins/image/utils.ts", "target": "src/components/editor/plugins/image/utils.ts", "type": "registry:file" }, { "content": "import { createCommand } from \"lexical\";\n\nexport interface InsertLayoutPayload {\n targetNodeKey?: string;\n templateColumns: string;\n}\n\nexport const INSERT_LAYOUT_COMMAND = createCommand(\n \"INSERT_LAYOUT_COMMAND\"\n);\n", "path": "registry/pytah/editor/components/editor/plugins/layout/commands.ts", "target": "src/components/editor/plugins/layout/commands.ts", "type": "registry:file" }, { "content": "export interface LayoutPreset {\n description: string;\n label: string;\n value: string;\n}\n\nexport const DEFAULT_LAYOUT_TEMPLATE = \"1fr 1fr\";\n\nexport const LAYOUT_PRESETS: LayoutPreset[] = [\n {\n description: \"Two equal columns\",\n label: \"2 columns (equal width)\",\n value: \"1fr 1fr\",\n },\n {\n description: \"Sidebar plus content\",\n label: \"2 columns (25% - 75%)\",\n value: \"1fr 3fr\",\n },\n {\n description: \"Three equal columns\",\n label: \"3 columns (equal width)\",\n value: \"1fr 1fr 1fr\",\n },\n {\n description: \"Three balanced columns\",\n label: \"3 columns (25% - 50% - 25%)\",\n value: \"1fr 2fr 1fr\",\n },\n {\n description: \"Four equal columns\",\n label: \"4 columns (equal width)\",\n value: \"1fr 1fr 1fr 1fr\",\n },\n];\n", "path": "registry/pytah/editor/components/editor/plugins/layout/constants.ts", "target": "src/components/editor/plugins/layout/constants.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport {\n $getNodeByKey,\n $getSelection,\n $isElementNode,\n $isRangeSelection,\n COMMAND_PRIORITY_EDITOR,\n} from \"lexical\";\nimport { useEffect } from \"react\";\nimport { INSERT_LAYOUT_COMMAND } from \"./commands\";\nimport { applyLayoutPreset } from \"./utils\";\n\nexport function LayoutPlugin() {\n const [editor] = useLexicalComposerContext();\n\n useEffect(() => {\n return editor.registerCommand(\n INSERT_LAYOUT_COMMAND,\n ({ targetNodeKey, templateColumns }) => {\n if (targetNodeKey) {\n const targetNode = $getNodeByKey(targetNodeKey);\n if (!$isElementNode(targetNode)) {\n return false;\n }\n\n applyLayoutPreset(targetNode, templateColumns);\n return true;\n }\n\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return false;\n }\n\n const targetElement = selection.anchor\n .getNode()\n .getTopLevelElementOrThrow();\n applyLayoutPreset(targetElement, templateColumns);\n return true;\n },\n COMMAND_PRIORITY_EDITOR\n );\n }, [editor]);\n\n return null;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/layout/plugin.tsx", "target": "src/components/editor/plugins/layout/plugin.tsx", "type": "registry:file" }, { "content": "import { $createParagraphNode, type ElementNode } from \"lexical\";\nimport { $createLayoutContainerNode } from \"../../core/nodes/layout/container-node\";\nimport { $createLayoutItemNode } from \"../../core/nodes/layout/item-node\";\n\nconst TEMPLATE_COLUMN_SEPARATOR = /\\s+/;\n\nconst getColumnCount = (templateColumns: string) => {\n return templateColumns.split(TEMPLATE_COLUMN_SEPARATOR).filter(Boolean)\n .length;\n};\n\nexport const applyLayoutPreset = (\n targetElement: ElementNode,\n templateColumns: string\n) => {\n const layoutContainer = $createLayoutContainerNode(templateColumns);\n const columnCount = getColumnCount(templateColumns);\n let firstParagraph: ReturnType | null = null;\n\n for (let index = 0; index < columnCount; index += 1) {\n const layoutItem = $createLayoutItemNode();\n const paragraph = $createParagraphNode();\n\n if (firstParagraph === null) {\n firstParagraph = paragraph;\n }\n\n layoutItem.append(paragraph);\n layoutContainer.append(layoutItem);\n }\n\n targetElement.replace(layoutContainer);\n firstParagraph?.selectEnd();\n};\n", "path": "registry/pytah/editor/components/editor/plugins/layout/utils.ts", "target": "src/components/editor/plugins/layout/utils.ts", "type": "registry:file" }, { "content": "import { createLinkMatcherWithRegExp } from \"@lexical/react/LexicalAutoLinkPlugin\";\n\nconst URL_MATCHER_PATTERN = /((https?:\\/\\/|www\\.)[^\\s<]+[^<.,:;\"')\\]\\s])/i;\nconst EMAIL_MATCHER_PATTERN = /(([\\w.+-]+@[\\w-]+\\.[\\w.-]+))/i;\n\nconst normalizeMatchedUrl = (text: string): string => {\n return text.startsWith(\"http\") ? text : `https://${text}`;\n};\n\nconst normalizeMatchedEmail = (text: string): string => {\n return `mailto:${text}`;\n};\n\nexport const AUTO_LINK_MATCHERS = [\n createLinkMatcherWithRegExp(URL_MATCHER_PATTERN, normalizeMatchedUrl),\n createLinkMatcherWithRegExp(EMAIL_MATCHER_PATTERN, normalizeMatchedEmail),\n];\n", "path": "registry/pytah/editor/components/editor/plugins/link-behavior/constants.ts", "target": "src/components/editor/plugins/link-behavior/constants.ts", "type": "registry:file" }, { "content": "import type { LexicalEditor } from \"lexical\";\nimport {\n Edit3Icon,\n ExternalLinkIcon,\n Link2Icon,\n Trash2Icon,\n XIcon,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { cn } from \"@/lib/utils\";\nimport {\n clearToolbarLink,\n submitToolbarLink,\n} from \"../floating-toolbar/actions\";\nimport { sanitizeEditorLinkUrl } from \"./utils\";\n\ninterface FloatingLinkEditorPanelProps {\n editedLinkUrl: string;\n editor: LexicalEditor;\n inputRef: (element: HTMLInputElement | null) => void;\n isLinkEditMode: boolean;\n linkUrl: string;\n onEditedLinkUrlChange: (value: string) => void;\n onRequestCloseEditMode: () => void;\n onRequestEditMode: () => void;\n}\n\nexport function FloatingLinkEditorPanel({\n editedLinkUrl,\n editor,\n inputRef,\n isLinkEditMode,\n linkUrl,\n onEditedLinkUrlChange,\n onRequestCloseEditMode,\n onRequestEditMode,\n}: FloatingLinkEditorPanelProps) {\n if (isLinkEditMode) {\n return (\n \n onEditedLinkUrlChange(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n submitToolbarLink(editor, editedLinkUrl);\n onRequestCloseEditMode();\n return;\n }\n\n if (event.key === \"Escape\") {\n event.preventDefault();\n onRequestCloseEditMode();\n }\n }}\n ref={inputRef}\n value={editedLinkUrl}\n />\n {\n submitToolbarLink(editor, editedLinkUrl);\n onRequestCloseEditMode();\n }}\n size=\"icon-xs\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n \n \n \n \n );\n }\n\n return (\n \n \n {linkUrl}\n \n \n \n \n \n {\n clearToolbarLink(editor);\n onRequestCloseEditMode();\n }}\n size=\"icon-xs\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n {\n window.open(\n sanitizeEditorLinkUrl(linkUrl),\n \"_blank\",\n \"noopener,noreferrer\"\n );\n }}\n size=\"icon-xs\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n \n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/link-behavior/floating-link-editor-panel.tsx", "target": "src/components/editor/plugins/link-behavior/floating-link-editor-panel.tsx", "type": "registry:file" }, { "content": "import type { LexicalEditor } from \"lexical\";\nimport { $getSelection, $isNodeSelection, $isRangeSelection } from \"lexical\";\nimport {\n getFloatingToolbarSelectedNode,\n getSelectedLinkNode,\n isSelectionWithinSingleLink,\n} from \"../floating-toolbar/selection\";\n\nexport interface FloatingLinkEditorPosition {\n left: number;\n top: number;\n}\n\nconst LINK_EDITOR_OFFSET = 12;\n\nexport const EMPTY_POSITION: FloatingLinkEditorPosition = { left: 0, top: 0 };\n\nexport const getLinkEditorPosition = (\n editor: LexicalEditor\n): FloatingLinkEditorPosition | null => {\n const selection = $getSelection();\n const nativeSelection = window.getSelection();\n const rootElement = editor.getRootElement();\n\n if (!(selection && rootElement && editor.isEditable())) {\n return null;\n }\n\n let rectangle: DOMRect | null = null;\n\n if ($isNodeSelection(selection)) {\n const [node] = selection.getNodes();\n const element = node ? editor.getElementByKey(node.getKey()) : null;\n rectangle = element?.getBoundingClientRect() ?? null;\n } else if (\n nativeSelection &&\n rootElement.contains(nativeSelection.anchorNode)\n ) {\n rectangle =\n nativeSelection.focusNode?.parentElement?.getBoundingClientRect() ??\n nativeSelection.getRangeAt(0).getBoundingClientRect();\n }\n\n if (!rectangle) {\n return null;\n }\n\n return {\n left: rectangle.left,\n top: rectangle.bottom + LINK_EDITOR_OFFSET,\n };\n};\n\nexport const readSelectedLinkUrl = () => {\n const selection = $getSelection();\n\n if ($isRangeSelection(selection)) {\n if (!isSelectionWithinSingleLink(selection)) {\n return \"\";\n }\n\n return (\n getSelectedLinkNode(\n getFloatingToolbarSelectedNode(selection)\n )?.getURL() ?? \"\"\n );\n }\n\n if ($isNodeSelection(selection)) {\n const [node] = selection.getNodes();\n return node ? (getSelectedLinkNode(node)?.getURL() ?? \"\") : \"\";\n }\n\n return \"\";\n};\n\nexport const selectionContainsLink = () => {\n const selection = $getSelection();\n\n if ($isRangeSelection(selection)) {\n return isSelectionWithinSingleLink(selection);\n }\n\n if ($isNodeSelection(selection)) {\n const [node] = selection.getNodes();\n return Boolean(node && getSelectedLinkNode(node));\n }\n\n return false;\n};\n", "path": "registry/pytah/editor/components/editor/plugins/link-behavior/floating-link-editor-position.ts", "target": "src/components/editor/plugins/link-behavior/floating-link-editor-position.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { $isLinkNode, TOGGLE_LINK_COMMAND } from \"@lexical/link\";\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { $findMatchingParent, mergeRegister } from \"@lexical/utils\";\nimport {\n $getSelection,\n $isRangeSelection,\n CLICK_COMMAND,\n COMMAND_PRIORITY_HIGH,\n COMMAND_PRIORITY_LOW,\n KEY_DOWN_COMMAND,\n KEY_ESCAPE_COMMAND,\n SELECTION_CHANGE_COMMAND,\n} from \"lexical\";\nimport { useCallback, useEffect, useReducer, useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { OPEN_FLOATING_LINK_EDITOR_COMMAND } from \"../floating-toolbar/link-command\";\nimport {\n areFloatingToolbarPositionsEqual,\n getFloatingToolbarSelectedNode,\n} from \"../floating-toolbar/selection\";\nimport { FloatingLinkEditorPanel } from \"./floating-link-editor-panel\";\nimport {\n EMPTY_POSITION,\n type FloatingLinkEditorPosition,\n getLinkEditorPosition,\n readSelectedLinkUrl,\n selectionContainsLink,\n} from \"./floating-link-editor-position\";\nimport { LINK_PLACEHOLDER_URL } from \"./utils\";\n\ninterface FloatingLinkEditorState {\n editedLinkUrl: string;\n isLink: boolean;\n isLinkEditMode: boolean;\n linkUrl: string;\n position: FloatingLinkEditorPosition;\n}\n\ntype FloatingLinkEditorAction =\n | {\n type: \"sync\";\n payload: {\n isLink: boolean;\n linkUrl: string;\n position: FloatingLinkEditorPosition;\n };\n }\n | {\n type: \"open-edit-mode\";\n payload?: {\n editedLinkUrl?: string;\n };\n }\n | { type: \"close-edit-mode\" }\n | { type: \"close-link-editor\" }\n | { type: \"set-edited-link-url\"; payload: string };\n\nconst INITIAL_STATE: FloatingLinkEditorState = {\n editedLinkUrl: LINK_PLACEHOLDER_URL,\n isLink: false,\n isLinkEditMode: false,\n linkUrl: \"\",\n position: EMPTY_POSITION,\n};\n\nconst floatingLinkEditorReducer = (\n state: FloatingLinkEditorState,\n action: FloatingLinkEditorAction\n): FloatingLinkEditorState => {\n switch (action.type) {\n case \"sync\": {\n const { isLink, linkUrl, position } = action.payload;\n const nextPosition = areFloatingToolbarPositionsEqual(\n state.position,\n position\n )\n ? state.position\n : position;\n\n return {\n ...state,\n editedLinkUrl: state.isLinkEditMode\n ? state.editedLinkUrl\n : linkUrl || LINK_PLACEHOLDER_URL,\n isLink,\n isLinkEditMode:\n position === EMPTY_POSITION ? false : state.isLinkEditMode,\n linkUrl,\n position: nextPosition,\n };\n }\n case \"open-edit-mode\": {\n return {\n ...state,\n editedLinkUrl:\n action.payload?.editedLinkUrl ??\n (state.linkUrl || LINK_PLACEHOLDER_URL),\n isLinkEditMode: true,\n };\n }\n case \"close-edit-mode\": {\n return state.isLinkEditMode ? { ...state, isLinkEditMode: false } : state;\n }\n case \"close-link-editor\": {\n return state.isLink || state.isLinkEditMode\n ? { ...state, isLink: false, isLinkEditMode: false }\n : state;\n }\n case \"set-edited-link-url\": {\n return state.editedLinkUrl === action.payload\n ? state\n : { ...state, editedLinkUrl: action.payload };\n }\n default: {\n return state;\n }\n }\n};\n\nexport function FloatingLinkEditorPlugin() {\n const [editor] = useLexicalComposerContext();\n const editorRef = useRef(null);\n const animationFrameRef = useRef(null);\n const [state, dispatch] = useReducer(\n floatingLinkEditorReducer,\n INITIAL_STATE\n );\n const { editedLinkUrl, isLink, isLinkEditMode, linkUrl, position } = state;\n\n const updateLinkEditor = useCallback(() => {\n const nextIsLink = selectionContainsLink();\n const nextLinkUrl = nextIsLink ? readSelectedLinkUrl() : \"\";\n const nextPosition = getLinkEditorPosition(editor) ?? EMPTY_POSITION;\n\n dispatch({\n type: \"sync\",\n payload: {\n isLink: nextIsLink,\n linkUrl: nextLinkUrl,\n position: nextPosition,\n },\n });\n }, [editor]);\n\n const scheduleLinkEditorUpdate = useCallback(() => {\n if (animationFrameRef.current !== null) {\n return;\n }\n\n animationFrameRef.current = window.requestAnimationFrame(() => {\n animationFrameRef.current = null;\n editor.getEditorState().read(() => {\n updateLinkEditor();\n });\n });\n }, [editor, updateLinkEditor]);\n\n useEffect(() => {\n return mergeRegister(\n editor.registerUpdateListener(() => {\n scheduleLinkEditorUpdate();\n }),\n editor.registerCommand(\n SELECTION_CHANGE_COMMAND,\n () => {\n scheduleLinkEditorUpdate();\n return false;\n },\n COMMAND_PRIORITY_LOW\n ),\n editor.registerCommand(\n OPEN_FLOATING_LINK_EDITOR_COMMAND,\n () => {\n dispatch({\n type: \"open-edit-mode\",\n payload: {\n editedLinkUrl: readSelectedLinkUrl() || LINK_PLACEHOLDER_URL,\n },\n });\n scheduleLinkEditorUpdate();\n return true;\n },\n COMMAND_PRIORITY_HIGH\n ),\n editor.registerCommand(\n KEY_DOWN_COMMAND,\n (event) => {\n const isModifierPressed = event.metaKey || event.ctrlKey;\n if (!(isModifierPressed && event.key.toLowerCase() === \"k\")) {\n return false;\n }\n\n event.preventDefault();\n\n if (selectionContainsLink()) {\n dispatch({ type: \"close-edit-mode\" });\n editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);\n return true;\n }\n\n dispatch({\n type: \"open-edit-mode\",\n payload: { editedLinkUrl: LINK_PLACEHOLDER_URL },\n });\n editor.dispatchCommand(TOGGLE_LINK_COMMAND, LINK_PLACEHOLDER_URL);\n return true;\n },\n COMMAND_PRIORITY_HIGH\n ),\n editor.registerCommand(\n KEY_ESCAPE_COMMAND,\n () => {\n if (!isLink) {\n return false;\n }\n\n dispatch({ type: \"close-link-editor\" });\n return true;\n },\n COMMAND_PRIORITY_HIGH\n ),\n editor.registerCommand(\n CLICK_COMMAND,\n (event) => {\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return false;\n }\n\n const node = getFloatingToolbarSelectedNode(selection);\n const linkNode = $findMatchingParent(node, $isLinkNode);\n if ($isLinkNode(linkNode) && (event.metaKey || event.ctrlKey)) {\n window.open(linkNode.getURL(), \"_blank\", \"noopener,noreferrer\");\n return true;\n }\n\n return false;\n },\n COMMAND_PRIORITY_LOW\n )\n );\n }, [editor, isLink, scheduleLinkEditorUpdate]);\n\n useEffect(() => {\n scheduleLinkEditorUpdate();\n }, [scheduleLinkEditorUpdate]);\n\n useEffect(() => {\n const handleWindowChange = () => {\n scheduleLinkEditorUpdate();\n };\n\n window.addEventListener(\"resize\", handleWindowChange);\n window.addEventListener(\"scroll\", handleWindowChange, true);\n\n return () => {\n window.removeEventListener(\"resize\", handleWindowChange);\n window.removeEventListener(\"scroll\", handleWindowChange, true);\n };\n }, [scheduleLinkEditorUpdate]);\n\n useEffect(() => {\n return () => {\n if (animationFrameRef.current !== null) {\n window.cancelAnimationFrame(animationFrameRef.current);\n }\n };\n }, []);\n\n useEffect(() => {\n const floatingElement = editorRef.current;\n if (!floatingElement) {\n return;\n }\n\n const handleFocusOut = (event: FocusEvent) => {\n if (\n !floatingElement.contains(event.relatedTarget as Node | null) &&\n isLink\n ) {\n dispatch({ type: \"close-link-editor\" });\n }\n };\n\n floatingElement.addEventListener(\"focusout\", handleFocusOut);\n return () => {\n floatingElement.removeEventListener(\"focusout\", handleFocusOut);\n };\n }, [isLink]);\n\n const handleInputRef = useCallback(\n (element: HTMLInputElement | null) => {\n if (!(element && isLinkEditMode)) {\n return;\n }\n\n element.focus();\n element.select();\n },\n [isLinkEditMode]\n );\n\n if (!isLink) {\n return null;\n }\n\n return createPortal(\n \n \n dispatch({ type: \"set-edited-link-url\", payload: value })\n }\n onRequestCloseEditMode={() => dispatch({ type: \"close-edit-mode\" })}\n onRequestEditMode={() => dispatch({ type: \"open-edit-mode\" })}\n />\n ,\n document.body\n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/link-behavior/floating-link-editor.tsx", "target": "src/components/editor/plugins/link-behavior/floating-link-editor.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { AutoLinkPlugin } from \"@lexical/react/LexicalAutoLinkPlugin\";\nimport { ClickableLinkPlugin } from \"@lexical/react/LexicalClickableLinkPlugin\";\nimport { LinkPlugin } from \"@lexical/react/LexicalLinkPlugin\";\nimport { AUTO_LINK_MATCHERS } from \"./constants\";\nimport { isValidEditorLinkUrl } from \"./utils\";\n\ninterface LinkBehaviorPluginProps {\n editable: boolean;\n}\n\nexport function LinkBehaviorPlugin({ editable }: LinkBehaviorPluginProps) {\n return (\n <>\n \n \n \n \n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/link-behavior/plugin.tsx", "target": "src/components/editor/plugins/link-behavior/plugin.tsx", "type": "registry:file" }, { "content": "import { deepStrictEqual, strictEqual } from \"node:assert/strict\";\nimport { describe, test } from \"node:test\";\nimport {\n isValidEditorLinkUrl,\n LINK_PLACEHOLDER_URL,\n normalizeEditorLinkUrl,\n sanitizeEditorLinkUrl,\n} from \"./utils\";\n\ndescribe(\"link behavior utils\", () => {\n test(\"normalizes common editor-friendly inputs\", () => {\n strictEqual(\n normalizeEditorLinkUrl(\" www.example.com \"),\n \"https://www.example.com\"\n );\n strictEqual(\n normalizeEditorLinkUrl(\"hello@example.com\"),\n \"mailto:hello@example.com\"\n );\n strictEqual(\n normalizeEditorLinkUrl(\"tel:+5511999999999\"),\n \"tel:+5511999999999\"\n );\n });\n\n test(\"validates only supported protocols and placeholder\", () => {\n deepStrictEqual(\n [\n isValidEditorLinkUrl(LINK_PLACEHOLDER_URL),\n isValidEditorLinkUrl(\"https://example.com\"),\n isValidEditorLinkUrl(\"mailto:test@example.com\"),\n isValidEditorLinkUrl(\"javascript:alert(1)\"),\n isValidEditorLinkUrl(\"not a url\"),\n isValidEditorLinkUrl(\"\"),\n ],\n [true, true, true, false, false, false]\n );\n });\n\n test(\"sanitizes unsupported protocols to about:blank\", () => {\n deepStrictEqual(\n [\n sanitizeEditorLinkUrl(\"javascript:alert(1)\"),\n sanitizeEditorLinkUrl(\"data:text/html,boom\"),\n sanitizeEditorLinkUrl(\"https://example.com\"),\n ],\n [\"about:blank\", \"about:blank\", \"https://example.com\"]\n );\n });\n});\n", "path": "registry/pytah/editor/components/editor/plugins/link-behavior/utils.test.ts", "target": "src/components/editor/plugins/link-behavior/utils.test.ts", "type": "registry:file" }, { "content": "const ALLOWED_LINK_PROTOCOLS = new Set([\n \"http:\",\n \"https:\",\n \"mailto:\",\n \"sms:\",\n \"tel:\",\n]);\n\nexport const LINK_PLACEHOLDER_URL = \"https://\";\n\nexport const normalizeEditorLinkUrl = (value: string): string => {\n const trimmedValue = value.trim();\n\n if (!trimmedValue) {\n return \"\";\n }\n\n if (trimmedValue.includes(\"@\") && !trimmedValue.includes(\"://\")) {\n return `mailto:${trimmedValue}`;\n }\n\n if (trimmedValue.startsWith(\"tel:\")) {\n return trimmedValue;\n }\n\n if (trimmedValue.startsWith(\"www.\")) {\n return `https://${trimmedValue}`;\n }\n\n return trimmedValue;\n};\n\nexport const isValidEditorLinkUrl = (value: string): boolean => {\n const normalizedValue = normalizeEditorLinkUrl(value);\n if (!normalizedValue) {\n return false;\n }\n\n if (normalizedValue === LINK_PLACEHOLDER_URL) {\n return true;\n }\n\n try {\n const url = new URL(normalizedValue);\n return ALLOWED_LINK_PROTOCOLS.has(url.protocol);\n } catch {\n return false;\n }\n};\n\nexport const sanitizeEditorLinkUrl = (value: string): string => {\n const normalizedValue = normalizeEditorLinkUrl(value);\n\n try {\n const url = new URL(normalizedValue);\n if (!ALLOWED_LINK_PROTOCOLS.has(url.protocol)) {\n return \"about:blank\";\n }\n } catch {\n return normalizedValue;\n }\n\n return normalizedValue;\n};\n", "path": "registry/pytah/editor/components/editor/plugins/link-behavior/utils.ts", "target": "src/components/editor/plugins/link-behavior/utils.ts", "type": "registry:file" }, { "content": "import {\n CHECK_LIST,\n ELEMENT_TRANSFORMERS,\n type ElementTransformer,\n MULTILINE_ELEMENT_TRANSFORMERS,\n type MultilineElementTransformer,\n TEXT_FORMAT_TRANSFORMERS,\n TEXT_MATCH_TRANSFORMERS,\n} from \"@lexical/markdown\";\nimport {\n $createTableCellNode,\n $createTableNode,\n $createTableRowNode,\n $isTableNode,\n TableCellNode,\n TableNode,\n TableRowNode,\n} from \"@lexical/table\";\nimport { $createParagraphNode, $createTextNode } from \"lexical\";\nimport {\n $createImageNode,\n $isImageNode,\n ImageNode,\n} from \"../../core/nodes/image/node\";\nimport {\n $createYouTubeNode,\n $isYouTubeNode,\n YouTubeNode,\n} from \"../../core/nodes/youtube/node\";\nimport { parseYouTubeUrl } from \"../youtube/utils\";\n\nconst IMAGE_REGEXP = /^!\\[([^\\]]*)\\]\\(([^)\\s]+)\\)$/;\nconst YOUTUBE_URL_REGEXP = /^https?:\\/\\/\\S+$/;\nconst TABLE_DIVIDER_LINE_PATTERN = /^\\|(?:\\s*:?-+:?\\s*\\|)+\\s*$/;\nconst TABLE_ROW_PATTERN = /^\\|(.+)\\|\\s*$/;\n\nconst IMAGE_TRANSFORMER: ElementTransformer = {\n dependencies: [ImageNode],\n export: (node) => {\n if (!$isImageNode(node)) {\n return null;\n }\n\n return `![${node.getAltText().replace(/]/g, \"\\\\]\")}](${node.getSrc()})`;\n },\n regExp: IMAGE_REGEXP,\n replace: (parentNode, _children, match) => {\n const [, altText, src] = match;\n parentNode.replace(\n $createImageNode({\n altText,\n src,\n })\n );\n },\n type: \"element\",\n};\n\nconst YOUTUBE_TRANSFORMER: ElementTransformer = {\n dependencies: [YouTubeNode],\n export: (node) => {\n if (!$isYouTubeNode(node)) {\n return null;\n }\n\n return `https://www.youtube.com/watch?v=${node.getVideoId()}`;\n },\n regExp: YOUTUBE_URL_REGEXP,\n replace: (parentNode, _children, match) => {\n const videoId = parseYouTubeUrl(match[0]);\n if (!videoId) {\n return;\n }\n\n parentNode.replace($createYouTubeNode(videoId));\n },\n type: \"element\",\n};\n\nconst splitMarkdownTableCells = (line: string): string[] => {\n return line\n .slice(1, -1)\n .split(\"|\")\n .map((cell) => cell.trim());\n};\n\nconst createTableCell = (\n textContent: string,\n isHeader: boolean\n): TableCellNode => {\n const tableCell = $createTableCellNode(isHeader ? 1 : 0);\n const paragraph = $createParagraphNode();\n\n paragraph.append($createTextNode(textContent));\n tableCell.append(paragraph);\n\n return tableCell;\n};\n\nconst TABLE_TRANSFORMER: MultilineElementTransformer = {\n dependencies: [TableNode, TableRowNode, TableCellNode],\n export: (node) => {\n if (!$isTableNode(node)) {\n return null;\n }\n\n const rows = node.getChildren().filter((child): child is TableRowNode => {\n return child instanceof TableRowNode;\n });\n\n if (rows.length === 0) {\n return null;\n }\n\n const markdownRows = rows.map((row) => {\n const cells = row\n .getChildren()\n .filter((child): child is TableCellNode => {\n return child instanceof TableCellNode;\n });\n\n const cellContents = cells.map((cell) => {\n return cell.getTextContent().replace(/\\|/g, \"\\\\|\").trim();\n });\n\n return `| ${cellContents.join(\" | \")} |`;\n });\n\n const headerCells = rows[0]\n .getChildren()\n .filter(\n (child): child is TableCellNode => child instanceof TableCellNode\n );\n\n const dividerRow = `| ${headerCells.map(() => \"---\").join(\" | \")} |`;\n\n return [markdownRows[0], dividerRow, ...markdownRows.slice(1)].join(\"\\n\");\n },\n handleImportAfterStartMatch: ({ lines, rootNode, startLineIndex }) => {\n const headerLine = lines[startLineIndex];\n const dividerLine = lines[startLineIndex + 1];\n\n if (\n !(\n headerLine &&\n dividerLine &&\n TABLE_ROW_PATTERN.test(headerLine) &&\n TABLE_DIVIDER_LINE_PATTERN.test(dividerLine)\n )\n ) {\n return null;\n }\n\n const headerCells = splitMarkdownTableCells(headerLine);\n const dividerCells = splitMarkdownTableCells(dividerLine);\n\n if (\n headerCells.length === 0 ||\n headerCells.length !== dividerCells.length\n ) {\n return null;\n }\n\n const bodyLines: string[] = [];\n let lineIndex = startLineIndex + 2;\n\n while (\n lineIndex < lines.length &&\n TABLE_ROW_PATTERN.test(lines[lineIndex] ?? \"\")\n ) {\n bodyLines.push(lines[lineIndex] as string);\n lineIndex += 1;\n }\n\n const tableNode = $createTableNode();\n const headerRow = $createTableRowNode();\n\n for (const cellText of headerCells) {\n headerRow.append(createTableCell(cellText, true));\n }\n\n tableNode.append(headerRow);\n\n for (const bodyLine of bodyLines) {\n const bodyCells = splitMarkdownTableCells(bodyLine);\n const rowNode = $createTableRowNode();\n\n for (let cellIndex = 0; cellIndex < headerCells.length; cellIndex += 1) {\n rowNode.append(createTableCell(bodyCells[cellIndex] ?? \"\", false));\n }\n\n tableNode.append(rowNode);\n }\n\n rootNode.append(tableNode);\n return [true, lineIndex - 1];\n },\n regExpEnd: {\n optional: true,\n regExp: /^$/,\n },\n regExpStart: TABLE_ROW_PATTERN,\n replace: () => false,\n type: \"multiline-element\",\n};\n\nexport const EDITOR_MARKDOWN_TRANSFORMERS = [\n TABLE_TRANSFORMER,\n CHECK_LIST,\n ...ELEMENT_TRANSFORMERS,\n ...MULTILINE_ELEMENT_TRANSFORMERS,\n ...TEXT_FORMAT_TRANSFORMERS,\n ...TEXT_MATCH_TRANSFORMERS,\n IMAGE_TRANSFORMER,\n YOUTUBE_TRANSFORMER,\n];\n", "path": "registry/pytah/editor/components/editor/plugins/markdown/transformers.ts", "target": "src/components/editor/plugins/markdown/transformers.ts", "type": "registry:file" }, { "content": "import type { LexicalEditor } from \"lexical\";\nimport type { SlashMenuAnchor } from \"./types\";\n\nconst getFirstTextDescendant = (element: HTMLElement): HTMLElement => {\n let inner = element;\n\n while (inner.firstElementChild instanceof HTMLElement) {\n inner = inner.firstElementChild;\n }\n\n return inner;\n};\n\nexport const getSelectionRectangle = (\n editor: LexicalEditor\n): DOMRect | null => {\n const nativeSelection = window.getSelection();\n const rootElement = editor.getRootElement();\n\n if (\n !(\n nativeSelection &&\n nativeSelection.rangeCount > 0 &&\n rootElement?.contains(nativeSelection.anchorNode)\n )\n ) {\n return null;\n }\n\n const range = nativeSelection.getRangeAt(0);\n const firstClientRect = range.getClientRects().item(0);\n const rectangle =\n nativeSelection.anchorNode === rootElement\n ? getFirstTextDescendant(rootElement).getBoundingClientRect()\n : (firstClientRect ?? range.getBoundingClientRect());\n const fallbackRectangle =\n nativeSelection.focusNode?.parentElement?.getBoundingClientRect();\n const nextRectangle =\n rectangle.width === 0 && rectangle.height === 0 && fallbackRectangle\n ? fallbackRectangle\n : rectangle;\n\n if (nextRectangle.width === 0 && nextRectangle.height === 0) {\n return null;\n }\n\n return nextRectangle;\n};\n\nexport const createSlashMenuAnchor = (\n editor: LexicalEditor\n): SlashMenuAnchor => {\n return {\n getBoundingClientRect: () => {\n const rectangle = getSelectionRectangle(editor);\n\n if (!rectangle) {\n return new DOMRect();\n }\n\n return new DOMRect(\n rectangle.left,\n rectangle.top,\n Math.max(rectangle.width, 1),\n Math.max(rectangle.height, 1)\n );\n },\n getClientRects: () => {\n const rect = getSelectionRectangle(editor);\n\n if (!rect) {\n return {\n item: () => null,\n length: 0,\n [Symbol.iterator](): IterableIterator {\n return [][Symbol.iterator]();\n },\n } as unknown as DOMRectList;\n }\n\n const anchorRect = new DOMRect(\n rect.left,\n rect.top,\n Math.max(rect.width, 1),\n Math.max(rect.height, 1)\n );\n\n return {\n 0: anchorRect,\n item: (index: number) => {\n return index === 0 ? anchorRect : null;\n },\n length: 1,\n *[Symbol.iterator](): IterableIterator {\n yield anchorRect;\n },\n } as unknown as DOMRectList;\n },\n };\n};\n", "path": "registry/pytah/editor/components/editor/plugins/slash-command/anchor.ts", "target": "src/components/editor/plugins/slash-command/anchor.ts", "type": "registry:file" }, { "content": "import { deepStrictEqual } from \"node:assert/strict\";\nimport { describe, test } from \"node:test\";\nimport { resolveEditorFeatures } from \"../../core/composition\";\nimport { getEnabledSlashCommands } from \"./commands\";\n\ndescribe(\"slash command feature gating\", () => {\n test(\"removes commands whose backing features are disabled\", () => {\n const commands = getEnabledSlashCommands(\n resolveEditorFeatures({\n collapsible: false,\n images: false,\n layouts: false,\n tables: false,\n youtube: false,\n })\n );\n\n deepStrictEqual(\n commands.map(({ id }) => id),\n [\n \"paragraph\",\n \"h1\",\n \"h2\",\n \"h3\",\n \"quote\",\n \"code\",\n \"bullet\",\n \"number\",\n \"check\",\n \"hr\",\n ]\n );\n });\n});\n", "path": "registry/pytah/editor/components/editor/plugins/slash-command/commands.test.ts", "target": "src/components/editor/plugins/slash-command/commands.test.ts", "type": "registry:file" }, { "content": "import {\n ChevronRightIcon,\n CodeIcon,\n Heading1Icon,\n Heading2Icon,\n Heading3Icon,\n ImageIcon,\n ListIcon,\n ListOrderedIcon,\n MinusIcon,\n PanelsTopLeftIcon,\n PlayIcon,\n SquareCheckIcon,\n TableIcon,\n TextQuoteIcon,\n TypeIcon,\n} from \"lucide-react\";\nimport type { ResolvedEditorFeatureFlags } from \"../../core/composition\";\nimport type { SlashCommand } from \"./types\";\nimport { SLASH_QUERY_PATTERN } from \"./utils\";\n\nexport const SLASH_COMMAND_PATTERN = SLASH_QUERY_PATTERN;\n\nexport const SLASH_COMMANDS: SlashCommand[] = [\n {\n description: \"Plain text block\",\n icon: TypeIcon,\n id: \"paragraph\",\n keywords: [\"text\", \"plain\", \"p\"],\n label: \"Paragraph\",\n },\n {\n description: \"Large section heading\",\n icon: Heading1Icon,\n id: \"h1\",\n keywords: [\"title\", \"heading\", \"h1\"],\n label: \"Heading 1\",\n },\n {\n description: \"Medium section heading\",\n icon: Heading2Icon,\n id: \"h2\",\n keywords: [\"subtitle\", \"heading\", \"h2\"],\n label: \"Heading 2\",\n },\n {\n description: \"Small section heading\",\n icon: Heading3Icon,\n id: \"h3\",\n keywords: [\"heading\", \"h3\"],\n label: \"Heading 3\",\n },\n {\n description: \"Capture a quote\",\n icon: TextQuoteIcon,\n id: \"quote\",\n keywords: [\"blockquote\", \"quote\", \"citation\"],\n label: \"Blockquote\",\n },\n {\n description: \"Write a code snippet\",\n icon: CodeIcon,\n id: \"code\",\n keywords: [\"code\", \"snippet\", \"pre\"],\n label: \"Code Block\",\n },\n {\n description: \"Unordered list\",\n icon: ListIcon,\n id: \"bullet\",\n keywords: [\"list\", \"bullet\", \"unordered\", \"ul\"],\n label: \"Bullet List\",\n },\n {\n description: \"Ordered list\",\n icon: ListOrderedIcon,\n id: \"number\",\n keywords: [\"list\", \"ordered\", \"numbered\", \"ol\"],\n label: \"Numbered List\",\n },\n {\n description: \"Todo list with checkboxes\",\n icon: SquareCheckIcon,\n id: \"check\",\n keywords: [\"check\", \"checklist\", \"todo\", \"task\"],\n label: \"Checklist\",\n },\n {\n description: \"Insert an image from URL\",\n icon: ImageIcon,\n id: \"image\",\n keywords: [\"image\", \"photo\", \"media\", \"picture\", \"img\"],\n label: \"Image\",\n requiredFeature: \"images\",\n },\n {\n description: \"Embed a YouTube video\",\n icon: PlayIcon,\n id: \"youtube\",\n keywords: [\"youtube\", \"video\", \"embed\", \"yt\"],\n label: \"YouTube\",\n requiredFeature: \"youtube\",\n },\n {\n description: \"Expandable toggle section\",\n icon: ChevronRightIcon,\n id: \"collapsible\",\n keywords: [\"collapsible\", \"toggle\", \"details\", \"accordion\"],\n label: \"Collapsible\",\n requiredFeature: \"collapsible\",\n },\n {\n description: \"Multi-column content layout\",\n icon: PanelsTopLeftIcon,\n id: \"columns\",\n keywords: [\"columns\", \"layout\", \"grid\", \"multi-column\"],\n label: \"Columns\",\n requiredFeature: \"layouts\",\n },\n {\n description: \"Simple editable table\",\n icon: TableIcon,\n id: \"table\",\n keywords: [\"table\", \"grid\", \"cells\", \"columns\", \"rows\"],\n label: \"Table\",\n requiredFeature: \"tables\",\n },\n {\n description: \"Horizontal rule separator\",\n icon: MinusIcon,\n id: \"hr\",\n keywords: [\"divider\", \"separator\", \"hr\", \"line\"],\n label: \"Divider\",\n },\n];\n\nexport const getEnabledSlashCommands = (\n features: ResolvedEditorFeatureFlags\n): SlashCommand[] => {\n return SLASH_COMMANDS.filter((command) => {\n return command.requiredFeature === undefined\n ? true\n : features[command.requiredFeature];\n });\n};\n", "path": "registry/pytah/editor/components/editor/plugins/slash-command/commands.ts", "target": "src/components/editor/plugins/slash-command/commands.ts", "type": "registry:file" }, { "content": "import { strictEqual } from \"node:assert/strict\";\nimport { describe, test } from \"node:test\";\nimport { createHeadlessEditor } from \"@lexical/headless\";\nimport {\n $getRoot,\n $isElementNode,\n $isParagraphNode,\n type LexicalNode,\n} from \"lexical\";\nimport { createEditorConfig } from \"../../core/config\";\nimport { createEmptyEditorState } from \"../../core/utils\";\nimport { DEFAULT_LAYOUT_TEMPLATE } from \"../layout/constants\";\nimport { SLASH_COMMAND_EXECUTORS } from \"./executors\";\n\nconst createTestEditor = () => {\n const config = createEditorConfig({ editable: true });\n\n return createHeadlessEditor({\n editable: config.editable,\n namespace: config.namespace,\n nodes: config.nodes,\n onError: (error) => {\n throw error;\n },\n theme: config.theme,\n });\n};\n\nconst flushEditorUpdates = async () => {\n await Promise.resolve();\n};\n\nconst initializeEditor = async (\n editor: ReturnType\n) => {\n createEmptyEditorState(editor);\n await flushEditorUpdates();\n};\n\nconst expectElementNode = (node: LexicalNode) => {\n strictEqual($isElementNode(node), true);\n\n if (!$isElementNode(node)) {\n throw new Error(\"Expected element node\");\n }\n\n return node;\n};\n\ndescribe(\"slash command executors\", () => {\n test(\"creates a default 3-column table structure\", async () => {\n const editor = createTestEditor();\n await initializeEditor(editor);\n\n editor.update(() => {\n const root = $getRoot();\n const paragraph = root.getFirstChildOrThrow();\n\n if (!$isParagraphNode(paragraph)) {\n throw new Error(\"Expected initial paragraph node\");\n }\n\n SLASH_COMMAND_EXECUTORS.table(paragraph);\n });\n\n await flushEditorUpdates();\n\n editor.getEditorState().read(() => {\n const root = $getRoot();\n const tableNode = expectElementNode(root.getFirstChildOrThrow());\n\n strictEqual(tableNode.getType(), \"table\");\n strictEqual(tableNode.getChildrenSize(), 2);\n\n const headerRow = expectElementNode(tableNode.getFirstChildOrThrow());\n const bodyRow = expectElementNode(tableNode.getLastChildOrThrow());\n\n strictEqual(headerRow.getChildrenSize(), 3);\n strictEqual(bodyRow.getChildrenSize(), 3);\n });\n });\n\n test(\"creates a layout container matching the default preset\", async () => {\n const editor = createTestEditor();\n await initializeEditor(editor);\n\n editor.update(() => {\n const root = $getRoot();\n const paragraph = root.getFirstChildOrThrow();\n\n if (!$isParagraphNode(paragraph)) {\n throw new Error(\"Expected initial paragraph node\");\n }\n\n SLASH_COMMAND_EXECUTORS.columns(paragraph);\n });\n\n await flushEditorUpdates();\n\n editor.getEditorState().read(() => {\n const root = $getRoot();\n const layoutContainer = expectElementNode(root.getFirstChildOrThrow());\n\n strictEqual(layoutContainer.getType(), \"layout-container\");\n strictEqual(layoutContainer.getChildrenSize(), 2);\n strictEqual(\"getTemplateColumns\" in layoutContainer, true);\n strictEqual(\n (\n layoutContainer as unknown as { getTemplateColumns: () => string }\n ).getTemplateColumns(),\n DEFAULT_LAYOUT_TEMPLATE\n );\n });\n });\n\n test(\"replaces a paragraph with a collapsible structure\", async () => {\n const editor = createTestEditor();\n await initializeEditor(editor);\n\n editor.update(() => {\n const root = $getRoot();\n const paragraph = root.getFirstChildOrThrow();\n\n if (!$isParagraphNode(paragraph)) {\n throw new Error(\"Expected initial paragraph node\");\n }\n\n SLASH_COMMAND_EXECUTORS.collapsible(paragraph);\n });\n\n await flushEditorUpdates();\n\n editor.getEditorState().read(() => {\n const root = $getRoot();\n const container = expectElementNode(root.getFirstChildOrThrow());\n\n strictEqual(container.getType(), \"collapsible-container\");\n strictEqual(container.getChildrenSize(), 2);\n strictEqual(\n container.getFirstChildOrThrow().getType(),\n \"collapsible-title\"\n );\n strictEqual(\n container.getLastChildOrThrow().getType(),\n \"collapsible-content\"\n );\n });\n });\n});\n", "path": "registry/pytah/editor/components/editor/plugins/slash-command/executors.test.ts", "target": "src/components/editor/plugins/slash-command/executors.test.ts", "type": "registry:file" }, { "content": "import { $createCodeNode } from \"@lexical/code\";\nimport { $createHorizontalRuleNode } from \"@lexical/extension\";\nimport {\n $createListItemNode,\n $createListNode,\n type ListType,\n} from \"@lexical/list\";\nimport {\n $createHeadingNode,\n $createQuoteNode,\n type HeadingTagType,\n} from \"@lexical/rich-text\";\nimport {\n $createTableCellNode,\n $createTableNode,\n $createTableRowNode,\n} from \"@lexical/table\";\nimport {\n $createParagraphNode,\n $createTextNode,\n $isParagraphNode,\n type ElementNode,\n} from \"lexical\";\nimport { replaceElementWithCollapsible } from \"../collapsible/utils\";\nimport { DEFAULT_LAYOUT_TEMPLATE } from \"../layout/constants\";\nimport { applyLayoutPreset } from \"../layout/utils\";\nimport type { SlashCommandId } from \"./types\";\n\nconst replaceElementChildren = (\n targetElement: ElementNode,\n nextElement: ElementNode\n) => {\n for (const child of targetElement.getChildren()) {\n nextElement.append(child);\n }\n\n targetElement.replace(nextElement);\n};\n\nconst applyParagraphCommand = (targetElement: ElementNode) => {\n if ($isParagraphNode(targetElement)) {\n targetElement.selectEnd();\n return;\n }\n\n const paragraph = $createParagraphNode();\n replaceElementChildren(targetElement, paragraph);\n paragraph.selectEnd();\n};\n\nconst applyHeadingCommand = (\n targetElement: ElementNode,\n headingTag: HeadingTagType\n) => {\n const heading = $createHeadingNode(headingTag);\n replaceElementChildren(targetElement, heading);\n heading.selectEnd();\n};\n\nconst applyQuoteCommand = (targetElement: ElementNode) => {\n const quote = $createQuoteNode();\n replaceElementChildren(targetElement, quote);\n quote.selectEnd();\n};\n\nconst applyCodeCommand = (targetElement: ElementNode) => {\n const code = $createCodeNode();\n targetElement.replace(code);\n code.select();\n};\n\nconst applyListCommand = (targetElement: ElementNode, listType: ListType) => {\n const list = $createListNode(listType);\n const item = $createListItemNode();\n\n for (const child of targetElement.getChildren()) {\n item.append(child);\n }\n\n list.append(item);\n targetElement.replace(list);\n item.selectEnd();\n};\n\nconst createTableCell = (textContent: string, isHeader: boolean) => {\n const tableCell = $createTableCellNode(isHeader ? 1 : 0);\n const paragraph = $createParagraphNode();\n\n paragraph.append($createTextNode(textContent));\n tableCell.append(paragraph);\n\n return tableCell;\n};\n\nconst applyTableCommand = (targetElement: ElementNode) => {\n const tableNode = $createTableNode();\n const headerRow = $createTableRowNode();\n const bodyRow = $createTableRowNode();\n\n headerRow.append(createTableCell(\"Column 1\", true));\n headerRow.append(createTableCell(\"Column 2\", true));\n headerRow.append(createTableCell(\"Column 3\", true));\n\n bodyRow.append(createTableCell(\"\", false));\n bodyRow.append(createTableCell(\"\", false));\n bodyRow.append(createTableCell(\"\", false));\n\n tableNode.append(headerRow);\n tableNode.append(bodyRow);\n\n targetElement.replace(tableNode);\n bodyRow.getFirstChild()?.selectEnd();\n};\n\nconst applyDividerCommand = (targetElement: ElementNode) => {\n const horizontalRule = $createHorizontalRuleNode();\n const paragraph = $createParagraphNode();\n\n targetElement.replace(horizontalRule);\n horizontalRule.insertAfter(paragraph);\n paragraph.select();\n};\n\nconst applyColumnsCommand = (targetElement: ElementNode) => {\n applyLayoutPreset(targetElement, DEFAULT_LAYOUT_TEMPLATE);\n};\n\nconst applyCollapsibleCommand = (targetElement: ElementNode) => {\n replaceElementWithCollapsible(targetElement);\n};\n\nexport const SLASH_COMMAND_EXECUTORS: Record<\n SlashCommandId,\n (element: ElementNode) => void\n> = {\n bullet: (element) => applyListCommand(element, \"bullet\"),\n check: (element) => applyListCommand(element, \"check\"),\n code: applyCodeCommand,\n collapsible: applyCollapsibleCommand,\n columns: applyColumnsCommand,\n h1: (element) => applyHeadingCommand(element, \"h1\"),\n h2: (element) => applyHeadingCommand(element, \"h2\"),\n h3: (element) => applyHeadingCommand(element, \"h3\"),\n hr: applyDividerCommand,\n image: applyParagraphCommand,\n number: (element) => applyListCommand(element, \"number\"),\n paragraph: applyParagraphCommand,\n quote: applyQuoteCommand,\n table: applyTableCommand,\n youtube: applyParagraphCommand,\n};\n", "path": "registry/pytah/editor/components/editor/plugins/slash-command/executors.ts", "target": "src/components/editor/plugins/slash-command/executors.ts", "type": "registry:file" }, { "content": "import { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { LAYOUT_PRESETS } from \"../layout/constants\";\n\nconst TEMPLATE_COLUMN_SEPARATOR = /\\s+/;\n\nconst getLayoutPreviewColumns = (templateColumns: string) => {\n return templateColumns.split(TEMPLATE_COLUMN_SEPARATOR).filter(Boolean);\n};\n\nfunction LayoutPresetPreview({ templateColumns }: { templateColumns: string }) {\n const columns = getLayoutPreviewColumns(templateColumns);\n const columnOccurrences = new Map();\n\n return (\n
\n \n {columns.map((column) => {\n const currentCount = columnOccurrences.get(column) ?? 0;\n columnOccurrences.set(column, currentCount + 1);\n\n return (\n \n );\n })}\n
\n \n );\n}\n\ninterface SlashLayoutDialogProps {\n onCancel: () => void;\n onOpenChange: (open: boolean) => void;\n onSelectPreset: (templateColumns: string) => void;\n open: boolean;\n}\n\nexport function SlashLayoutDialog({\n onCancel,\n onOpenChange,\n onSelectPreset,\n open,\n}: SlashLayoutDialogProps) {\n return (\n \n \n \n Choose columns layout\n \n Pick one of the official Lexical-style column presets.\n \n \n\n
\n
\n {LAYOUT_PRESETS.map((preset) => (\n onSelectPreset(preset.value)}\n type=\"button\"\n variant=\"ghost\"\n >\n \n \n \n \n {preset.label}\n \n \n {preset.description}\n \n \n {preset.value}\n \n \n \n \n ))}\n
\n
\n\n \n \n \n onSelectPreset(LAYOUT_PRESETS[0]?.value ?? \"1fr 1fr\")\n }\n type=\"button\"\n >\n Use default\n \n \n \n
\n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/slash-command/layout-dialog.tsx", "target": "src/components/editor/plugins/slash-command/layout-dialog.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { Popover as PopoverPrimitive } from \"@base-ui/react/popover\";\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { mergeRegister } from \"@lexical/utils\";\nimport {\n $getSelection,\n $isRangeSelection,\n $isTextNode,\n COMMAND_PRIORITY_HIGH,\n KEY_ARROW_DOWN_COMMAND,\n KEY_ARROW_UP_COMMAND,\n KEY_ENTER_COMMAND,\n KEY_ESCAPE_COMMAND,\n} from \"lexical\";\nimport type { ChangeEvent } from \"react\";\nimport { useCallback, useEffect, useMemo, useReducer, useRef } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\nimport type { ResolvedEditorFeatureFlags } from \"../../core/composition\";\nimport { INSERT_IMAGE_COMMAND } from \"../image/commands\";\nimport { readFileAsDataUrl } from \"../image/utils\";\nimport { INSERT_LAYOUT_COMMAND } from \"../layout/commands\";\nimport { INSERT_YOUTUBE_COMMAND } from \"../youtube/commands\";\nimport { parseYouTubeUrl } from \"../youtube/utils\";\nimport { createSlashMenuAnchor, getSelectionRectangle } from \"./anchor\";\nimport { getEnabledSlashCommands } from \"./commands\";\nimport { SLASH_COMMAND_EXECUTORS } from \"./executors\";\nimport { SlashLayoutDialog } from \"./layout-dialog\";\nimport type {\n SlashCommand,\n SlashCommandId,\n SlashCommandSelection,\n} from \"./types\";\nimport {\n filterSlashCommands,\n getFirstCommandId,\n getNeighborCommandId,\n getSelectedCommandIndex,\n getSlashQueryMatch,\n hasSelectedCommand,\n} from \"./utils\";\n\nconst SLASH_MENU_COLLISION_AVOIDANCE = {\n align: \"none\",\n fallbackAxisSide: \"none\",\n side: \"flip\",\n} as const;\n\ninterface SlashCommandPluginProps {\n features: ResolvedEditorFeatureFlags;\n}\n\ninterface SlashCommandState {\n imageAltText: string;\n imageFileName: string;\n imageFileSrc: string | null;\n imageUrl: string;\n isImageDialogOpen: boolean;\n isLayoutPresetOpen: boolean;\n isOpen: boolean;\n isYouTubeDialogOpen: boolean;\n pendingImageTargetKey: string | null;\n pendingLayoutTargetKey: string | null;\n pendingYouTubeTargetKey: string | null;\n query: string;\n rawSelectedCommandId: SlashCommandSelection;\n youTubeUrl: string;\n}\n\ntype SlashCommandAction =\n | { type: \"patch\"; payload: Partial }\n | {\n type: \"move-selected-command\";\n payload: {\n commands: SlashCommand[];\n direction: \"down\" | \"up\";\n };\n }\n | {\n type: \"set-image-file\";\n payload: {\n fileName: string;\n src: string;\n };\n };\n\nconst createInitialSlashCommandState = (\n rawSelectedCommandId: SlashCommandSelection\n): SlashCommandState => ({\n imageAltText: \"\",\n imageFileName: \"\",\n imageFileSrc: null,\n imageUrl: \"\",\n isImageDialogOpen: false,\n isLayoutPresetOpen: false,\n isOpen: false,\n isYouTubeDialogOpen: false,\n pendingImageTargetKey: null,\n pendingLayoutTargetKey: null,\n pendingYouTubeTargetKey: null,\n query: \"\",\n rawSelectedCommandId,\n youTubeUrl: \"\",\n});\n\nconst applySlashCommandPatch = (\n state: SlashCommandState,\n patch: Partial\n): SlashCommandState => {\n for (const key of Object.keys(patch) as Array) {\n if (state[key] !== patch[key]) {\n return { ...state, ...patch };\n }\n }\n\n return state;\n};\n\nconst slashCommandReducer = (\n state: SlashCommandState,\n action: SlashCommandAction\n): SlashCommandState => {\n switch (action.type) {\n case \"patch\": {\n return applySlashCommandPatch(state, action.payload);\n }\n case \"move-selected-command\": {\n return applySlashCommandPatch(state, {\n rawSelectedCommandId: getNeighborCommandId(\n action.payload.commands,\n state.rawSelectedCommandId,\n action.payload.direction\n ),\n });\n }\n case \"set-image-file\": {\n return applySlashCommandPatch(state, {\n imageAltText: state.imageAltText || action.payload.fileName,\n imageFileName: action.payload.fileName,\n imageFileSrc: action.payload.src,\n imageUrl: \"\",\n });\n }\n default: {\n return state;\n }\n }\n};\n\nexport function SlashCommandPlugin({ features }: SlashCommandPluginProps) {\n const [editor] = useLexicalComposerContext();\n const availableCommands = useMemo(() => {\n return getEnabledSlashCommands(features);\n }, [features]);\n const [state, dispatch] = useReducer(\n slashCommandReducer,\n getFirstCommandId(availableCommands),\n createInitialSlashCommandState\n );\n const {\n imageAltText,\n imageFileName,\n imageFileSrc,\n imageUrl,\n isImageDialogOpen,\n isLayoutPresetOpen,\n isOpen,\n isYouTubeDialogOpen,\n pendingImageTargetKey,\n pendingLayoutTargetKey,\n pendingYouTubeTargetKey,\n query,\n rawSelectedCommandId,\n youTubeUrl,\n } = state;\n const commandListRef = useRef(null);\n const animationFrameRef = useRef(null);\n\n const filteredCommands = useMemo(() => {\n return filterSlashCommands(availableCommands, query);\n }, [availableCommands, query]);\n\n const selectedCommandId = useMemo(() => {\n if (filteredCommands.length === 0) {\n return \"\";\n }\n\n return hasSelectedCommand(filteredCommands, rawSelectedCommandId)\n ? rawSelectedCommandId\n : getFirstCommandId(filteredCommands);\n }, [filteredCommands, rawSelectedCommandId]);\n\n const selectedIndex = useMemo(() => {\n return getSelectedCommandIndex(filteredCommands, selectedCommandId);\n }, [filteredCommands, selectedCommandId]);\n\n const anchor = useMemo(() => {\n return createSlashMenuAnchor(editor);\n }, [editor]);\n\n const updateSlashMenu = useCallback(() => {\n const selection = $getSelection();\n const isCollapsedRangeSelection =\n $isRangeSelection(selection) && selection.isCollapsed();\n\n if (!isCollapsedRangeSelection) {\n dispatch({\n type: \"patch\",\n payload: { isLayoutPresetOpen: false, isOpen: false },\n });\n return;\n }\n\n const node = selection.anchor.getNode();\n if (!$isTextNode(node)) {\n dispatch({\n type: \"patch\",\n payload: { isLayoutPresetOpen: false, isOpen: false },\n });\n return;\n }\n\n const textUpToCursor = node\n .getTextContent()\n .slice(0, selection.anchor.offset);\n const nextQuery = getSlashQueryMatch(textUpToCursor);\n\n if (nextQuery === null) {\n dispatch({\n type: \"patch\",\n payload: { isLayoutPresetOpen: false, isOpen: false },\n });\n return;\n }\n\n if (!getSelectionRectangle(editor)) {\n dispatch({ type: \"patch\", payload: { isOpen: false } });\n return;\n }\n\n dispatch({\n type: \"patch\",\n payload: { isOpen: true, query: nextQuery },\n });\n }, [editor]);\n\n const scheduleSlashMenuUpdate = useCallback(() => {\n if (animationFrameRef.current !== null) {\n return;\n }\n\n animationFrameRef.current = window.requestAnimationFrame(() => {\n animationFrameRef.current = null;\n editor.getEditorState().read(() => {\n updateSlashMenu();\n });\n });\n }, [editor, updateSlashMenu]);\n\n const resetImageDialog = useCallback(() => {\n dispatch({\n type: \"patch\",\n payload: {\n imageAltText: \"\",\n imageFileName: \"\",\n imageFileSrc: null,\n imageUrl: \"\",\n isImageDialogOpen: false,\n pendingImageTargetKey: null,\n },\n });\n }, []);\n\n const resetYouTubeDialog = useCallback(() => {\n dispatch({\n type: \"patch\",\n payload: {\n isYouTubeDialogOpen: false,\n pendingYouTubeTargetKey: null,\n youTubeUrl: \"\",\n },\n });\n }, []);\n\n const handleImageFileChange = useCallback(\n (event: ChangeEvent) => {\n const file = event.target.files?.[0];\n event.target.value = \"\";\n\n if (!file) {\n dispatch({\n type: \"patch\",\n payload: { imageFileName: \"\", imageFileSrc: null },\n });\n return;\n }\n\n readFileAsDataUrl(file)\n .then((src) => {\n dispatch({\n type: \"set-image-file\",\n payload: { fileName: file.name, src },\n });\n })\n .catch(() => {\n dispatch({\n type: \"patch\",\n payload: { imageFileName: \"\", imageFileSrc: null },\n });\n });\n },\n []\n );\n\n const executeCommand = useCallback(\n (commandId: SlashCommandId) => {\n if (\n commandId === \"columns\" ||\n commandId === \"image\" ||\n commandId === \"youtube\"\n ) {\n let targetNodeKey: string | null = null;\n\n editor.getEditorState().read(() => {\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return;\n }\n\n const node = selection.anchor.getNode();\n if (!$isTextNode(node)) {\n return;\n }\n\n targetNodeKey = node.getTopLevelElementOrThrow().getKey();\n });\n\n if (commandId === \"columns\") {\n dispatch({\n type: \"patch\",\n payload: {\n isLayoutPresetOpen: true,\n isOpen: false,\n pendingLayoutTargetKey: targetNodeKey,\n },\n });\n } else if (commandId === \"youtube\") {\n dispatch({\n type: \"patch\",\n payload: {\n isOpen: false,\n isYouTubeDialogOpen: true,\n pendingYouTubeTargetKey: targetNodeKey,\n },\n });\n } else {\n dispatch({\n type: \"patch\",\n payload: {\n isImageDialogOpen: true,\n isOpen: false,\n pendingImageTargetKey: targetNodeKey,\n },\n });\n }\n return;\n }\n\n editor.update(() => {\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return;\n }\n\n const node = selection.anchor.getNode();\n if (!$isTextNode(node)) {\n return;\n }\n\n node.setTextContent(\"\");\n\n const element = node.getTopLevelElementOrThrow();\n SLASH_COMMAND_EXECUTORS[commandId](element);\n });\n\n dispatch({ type: \"patch\", payload: { isOpen: false } });\n },\n [editor]\n );\n\n const executeLayoutPreset = useCallback(\n (templateColumns: string) => {\n if (!pendingLayoutTargetKey) {\n return;\n }\n\n editor.dispatchCommand(INSERT_LAYOUT_COMMAND, {\n targetNodeKey: pendingLayoutTargetKey,\n templateColumns,\n });\n\n dispatch({\n type: \"patch\",\n payload: {\n isLayoutPresetOpen: false,\n isOpen: false,\n pendingLayoutTargetKey: null,\n },\n });\n },\n [editor, pendingLayoutTargetKey]\n );\n\n const submitImage = useCallback(() => {\n const nextImageSrc = imageFileSrc ?? imageUrl.trim();\n if (!(nextImageSrc && pendingImageTargetKey)) {\n return;\n }\n\n editor.dispatchCommand(INSERT_IMAGE_COMMAND, {\n altText: imageAltText.trim(),\n src: nextImageSrc,\n targetNodeKey: pendingImageTargetKey,\n });\n\n dispatch({\n type: \"patch\",\n payload: {\n imageAltText: \"\",\n imageFileName: \"\",\n imageFileSrc: null,\n imageUrl: \"\",\n isImageDialogOpen: false,\n isOpen: false,\n pendingImageTargetKey: null,\n },\n });\n }, [editor, imageAltText, imageFileSrc, imageUrl, pendingImageTargetKey]);\n\n const submitYouTube = useCallback(() => {\n if (!pendingYouTubeTargetKey) {\n return;\n }\n\n const videoId = parseYouTubeUrl(youTubeUrl);\n if (!videoId) {\n return;\n }\n\n editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, {\n targetNodeKey: pendingYouTubeTargetKey,\n videoId,\n });\n\n dispatch({\n type: \"patch\",\n payload: {\n isOpen: false,\n isYouTubeDialogOpen: false,\n pendingYouTubeTargetKey: null,\n youTubeUrl: \"\",\n },\n });\n }, [editor, pendingYouTubeTargetKey, youTubeUrl]);\n\n useEffect(() => {\n if (\n !(isOpen && selectedCommandId) ||\n isImageDialogOpen ||\n isLayoutPresetOpen ||\n isYouTubeDialogOpen\n ) {\n return;\n }\n\n const animationFrameId = window.requestAnimationFrame(() => {\n const selectedItemSelector = `[cmdk-item=\"\"][data-value=\"${window.CSS.escape(selectedCommandId)}\"]`;\n const selectedItem =\n commandListRef.current?.querySelector(\n selectedItemSelector\n );\n\n selectedItem?.scrollIntoView({ block: \"nearest\" });\n });\n\n return () => {\n window.cancelAnimationFrame(animationFrameId);\n };\n }, [\n isImageDialogOpen,\n isLayoutPresetOpen,\n isOpen,\n isYouTubeDialogOpen,\n selectedCommandId,\n ]);\n\n useEffect(() => {\n if (isImageDialogOpen || isLayoutPresetOpen || isYouTubeDialogOpen) {\n return;\n }\n\n return editor.registerUpdateListener(() => {\n scheduleSlashMenuUpdate();\n });\n }, [\n editor,\n isImageDialogOpen,\n isLayoutPresetOpen,\n isYouTubeDialogOpen,\n scheduleSlashMenuUpdate,\n ]);\n\n useEffect(() => {\n return () => {\n if (animationFrameRef.current !== null) {\n window.cancelAnimationFrame(animationFrameRef.current);\n }\n };\n }, []);\n\n const handleImageUrlInputRef = useCallback(\n (element: HTMLInputElement | null) => {\n if (!(element && isImageDialogOpen)) {\n return;\n }\n\n element.focus();\n },\n [isImageDialogOpen]\n );\n\n const handleYouTubeUrlInputRef = useCallback(\n (element: HTMLInputElement | null) => {\n if (!(element && isYouTubeDialogOpen)) {\n return;\n }\n\n element.focus();\n },\n [isYouTubeDialogOpen]\n );\n\n useEffect(() => {\n if (!isOpen) {\n return;\n }\n\n return mergeRegister(\n editor.registerCommand(\n KEY_ARROW_DOWN_COMMAND,\n (event) => {\n if (isImageDialogOpen || isLayoutPresetOpen || isYouTubeDialogOpen) {\n return true;\n }\n\n event.preventDefault();\n dispatch({\n type: \"move-selected-command\",\n payload: { commands: filteredCommands, direction: \"down\" },\n });\n return true;\n },\n COMMAND_PRIORITY_HIGH\n ),\n editor.registerCommand(\n KEY_ARROW_UP_COMMAND,\n (event) => {\n if (isImageDialogOpen || isLayoutPresetOpen || isYouTubeDialogOpen) {\n return true;\n }\n\n event.preventDefault();\n dispatch({\n type: \"move-selected-command\",\n payload: { commands: filteredCommands, direction: \"up\" },\n });\n return true;\n },\n COMMAND_PRIORITY_HIGH\n ),\n editor.registerCommand(\n KEY_ENTER_COMMAND,\n (event) => {\n event?.preventDefault();\n\n if (isLayoutPresetOpen) {\n executeLayoutPreset(\"1fr 1fr\");\n return true;\n }\n\n if (isImageDialogOpen) {\n submitImage();\n return true;\n }\n\n if (isYouTubeDialogOpen) {\n submitYouTube();\n return true;\n }\n\n const selectedCommand = filteredCommands[selectedIndex];\n if (selectedCommand) {\n executeCommand(selectedCommand.id);\n }\n return true;\n },\n COMMAND_PRIORITY_HIGH\n ),\n editor.registerCommand(\n KEY_ESCAPE_COMMAND,\n () => {\n if (isImageDialogOpen) {\n resetImageDialog();\n return true;\n }\n\n if (isLayoutPresetOpen) {\n dispatch({\n type: \"patch\",\n payload: {\n isLayoutPresetOpen: false,\n pendingLayoutTargetKey: null,\n },\n });\n return true;\n }\n\n if (isYouTubeDialogOpen) {\n resetYouTubeDialog();\n return true;\n }\n\n dispatch({ type: \"patch\", payload: { isOpen: false } });\n return true;\n },\n COMMAND_PRIORITY_HIGH\n )\n );\n }, [\n editor,\n executeCommand,\n executeLayoutPreset,\n filteredCommands,\n isImageDialogOpen,\n isLayoutPresetOpen,\n isOpen,\n isYouTubeDialogOpen,\n resetYouTubeDialog,\n resetImageDialog,\n selectedIndex,\n submitImage,\n submitYouTube,\n ]);\n\n return (\n <>\n 0}\n >\n \n \n \n \n dispatch({\n type: \"patch\",\n payload: { rawSelectedCommandId: value as SlashCommandId },\n })\n }\n shouldFilter={false}\n value={selectedCommandId}\n >\n \n \n {filteredCommands.map((command, index) => (\n \n dispatch({\n type: \"patch\",\n payload: { rawSelectedCommandId: command.id },\n })\n }\n onSelect={() => executeCommand(command.id)}\n value={command.id}\n >\n \n
\n {command.label}\n \n {command.description}\n \n
\n \n ))}\n
\n {filteredCommands.length === 0 ? (\n No results found\n ) : null}\n
\n \n \n \n
\n \n\n {\n dispatch({\n type: \"patch\",\n payload: {\n isLayoutPresetOpen: false,\n pendingLayoutTargetKey: null,\n },\n });\n }}\n onOpenChange={(open) =>\n dispatch({\n type: \"patch\",\n payload: open\n ? { isLayoutPresetOpen: true }\n : { isLayoutPresetOpen: false, pendingLayoutTargetKey: null },\n })\n }\n onSelectPreset={executeLayoutPreset}\n open={isLayoutPresetOpen}\n />\n\n {\n if (!open) {\n resetImageDialog();\n }\n }}\n open={isImageDialogOpen}\n >\n \n \n Insert image\n \n Add an external image URL and optional alt text.\n \n \n\n {\n event.preventDefault();\n submitImage();\n }}\n >\n
\n \n \n dispatch({\n type: \"patch\",\n payload: { imageUrl: event.target.value },\n })\n }\n placeholder=\"https://example.com/image.jpg\"\n ref={handleImageUrlInputRef}\n type=\"url\"\n value={imageUrl}\n />\n
\n\n
\n \n \n {imageFileName ? (\n

\n Selected: {imageFileName}\n

\n ) : null}\n
\n\n
\n \n \n dispatch({\n type: \"patch\",\n payload: { imageAltText: event.target.value },\n })\n }\n placeholder=\"Describe the image for accessibility\"\n rows={3}\n value={imageAltText}\n />\n
\n\n \n \n Cancel\n \n \n Insert image\n \n \n \n
\n \n\n {\n if (!open) {\n resetYouTubeDialog();\n }\n }}\n open={isYouTubeDialogOpen}\n >\n \n \n Embed YouTube video\n \n Paste a YouTube URL and it will be embedded as a video block.\n \n \n\n {\n event.preventDefault();\n submitYouTube();\n }}\n >\n
\n \n YouTube URL\n \n \n dispatch({\n type: \"patch\",\n payload: { youTubeUrl: event.target.value },\n })\n }\n placeholder=\"https://www.youtube.com/watch?v=jNQXAC9IVRw\"\n ref={handleYouTubeUrlInputRef}\n type=\"url\"\n value={youTubeUrl}\n />\n

\n Supports `youtube.com`, `youtu.be`, and embed links.\n

\n
\n\n \n \n Cancel\n \n \n \n \n
\n \n \n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/slash-command/plugin.tsx", "target": "src/components/editor/plugins/slash-command/plugin.tsx", "type": "registry:file" }, { "content": "import type { LucideIcon } from \"lucide-react\";\nimport type { EditorFeatureFlags } from \"../../core/types\";\n\nexport type SlashCommandFeatureFlag = keyof Pick<\n EditorFeatureFlags,\n \"collapsible\" | \"images\" | \"layouts\" | \"tables\" | \"youtube\"\n>;\n\nexport type SlashCommandId =\n | \"paragraph\"\n | \"h1\"\n | \"h2\"\n | \"h3\"\n | \"quote\"\n | \"code\"\n | \"bullet\"\n | \"number\"\n | \"check\"\n | \"image\"\n | \"youtube\"\n | \"collapsible\"\n | \"columns\"\n | \"table\"\n | \"hr\";\n\nexport type SlashCommandSelection = SlashCommandId | \"\";\n\nexport interface SlashCommand {\n description: string;\n icon: LucideIcon;\n id: SlashCommandId;\n keywords: string[];\n label: string;\n requiredFeature?: SlashCommandFeatureFlag;\n}\n\nexport interface SlashMenuPosition {\n left: number;\n top: number;\n}\n\nexport interface SlashMenuAnchor {\n getBoundingClientRect: () => DOMRect;\n getClientRects: () => DOMRectList;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/slash-command/types.ts", "target": "src/components/editor/plugins/slash-command/types.ts", "type": "registry:file" }, { "content": "import { deepStrictEqual, strictEqual } from \"node:assert/strict\";\nimport { describe, test } from \"node:test\";\nimport { SLASH_COMMANDS } from \"./commands\";\nimport {\n filterSlashCommands,\n getFirstCommandId,\n getNeighborCommandId,\n getSelectedCommandIndex,\n getSlashQueryMatch,\n hasSelectedCommand,\n SLASH_QUERY_PATTERN,\n} from \"./utils\";\n\ndescribe(\"slash command utils\", () => {\n test(\"filters commands by label and keyword\", () => {\n deepStrictEqual(\n filterSlashCommands(SLASH_COMMANDS, \"hea\").map(({ id }) => id),\n [\"h1\", \"h2\", \"h3\"]\n );\n deepStrictEqual(\n filterSlashCommands(SLASH_COMMANDS, \"embed\").map(({ id }) => id),\n [\"youtube\"]\n );\n });\n\n test(\"returns stable navigation ids\", () => {\n const commands = filterSlashCommands(SLASH_COMMANDS, \"\");\n const firstId = getFirstCommandId(commands);\n\n strictEqual(firstId, \"paragraph\");\n strictEqual(getSelectedCommandIndex(commands, \"quote\") > -1, true);\n strictEqual(getNeighborCommandId(commands, \"paragraph\", \"up\"), \"paragraph\");\n strictEqual(getNeighborCommandId(commands, \"paragraph\", \"down\"), \"h1\");\n strictEqual(hasSelectedCommand(commands, \"table\"), true);\n strictEqual(hasSelectedCommand(commands, \"\"), false);\n });\n\n test(\"matches only valid slash queries\", () => {\n strictEqual(SLASH_QUERY_PATTERN.test(\"/table\"), true);\n strictEqual(getSlashQueryMatch(\"/table\"), \"table\");\n strictEqual(getSlashQueryMatch(\"/\"), \"\");\n strictEqual(getSlashQueryMatch(\"hello /table\"), null);\n strictEqual(getSlashQueryMatch(\"/table now\"), null);\n });\n});\n", "path": "registry/pytah/editor/components/editor/plugins/slash-command/utils.test.ts", "target": "src/components/editor/plugins/slash-command/utils.test.ts", "type": "registry:file" }, { "content": "import type { SlashCommand, SlashCommandSelection } from \"./types\";\n\nexport const SLASH_QUERY_PATTERN = /^\\/(\\w*)$/;\n\nexport const filterSlashCommands = (\n commands: SlashCommand[],\n query: string\n): SlashCommand[] => {\n if (!query) {\n return commands;\n }\n\n const normalizedQuery = query.toLowerCase();\n\n return commands.filter((command) => {\n return (\n command.label.toLowerCase().includes(normalizedQuery) ||\n command.keywords.some((keyword) => keyword.includes(normalizedQuery))\n );\n });\n};\n\nexport const getFirstCommandId = (\n commands: SlashCommand[]\n): SlashCommandSelection => {\n return commands[0]?.id ?? \"\";\n};\n\nexport const getSelectedCommandIndex = (\n commands: SlashCommand[],\n selectedCommandId: SlashCommandSelection\n): number => {\n return commands.findIndex((command) => command.id === selectedCommandId);\n};\n\nexport const getNeighborCommandId = (\n commands: SlashCommand[],\n selectedCommandId: SlashCommandSelection,\n direction: \"down\" | \"up\"\n): SlashCommandSelection => {\n const currentIndex = getSelectedCommandIndex(commands, selectedCommandId);\n\n if (currentIndex < 0) {\n return getFirstCommandId(commands);\n }\n\n const nextIndex =\n direction === \"down\"\n ? Math.min(currentIndex + 1, commands.length - 1)\n : Math.max(currentIndex - 1, 0);\n\n return commands[nextIndex]?.id ?? selectedCommandId;\n};\n\nexport const hasSelectedCommand = (\n commands: SlashCommand[],\n selectedCommandId: SlashCommandSelection\n): boolean => {\n return commands.some((command) => command.id === selectedCommandId);\n};\n\nexport const getSlashQueryMatch = (textUpToCursor: string): string | null => {\n const match = textUpToCursor.match(SLASH_QUERY_PATTERN);\n\n return match?.[1] ?? null;\n};\n", "path": "registry/pytah/editor/components/editor/plugins/slash-command/utils.ts", "target": "src/components/editor/plugins/slash-command/utils.ts", "type": "registry:file" }, { "content": "import {\n $deleteTableColumnAtSelection,\n $deleteTableRowAtSelection,\n $getTableCellNodeFromLexicalNode,\n $getTableNodeFromLexicalNodeOrThrow,\n $insertTableColumnAtSelection,\n $insertTableRowAtSelection,\n $isTableSelection,\n} from \"@lexical/table\";\nimport { $getSelection, $isRangeSelection, type LexicalEditor } from \"lexical\";\n\nexport const insertTableRow = (editor: LexicalEditor, insertAfter: boolean) => {\n editor.update(() => {\n $insertTableRowAtSelection(insertAfter);\n });\n};\n\nexport const insertTableRows = (\n editor: LexicalEditor,\n insertAfter: boolean,\n count: number\n) => {\n editor.update(() => {\n for (let index = 0; index < count; index += 1) {\n $insertTableRowAtSelection(insertAfter);\n }\n });\n};\n\nexport const insertTableColumn = (\n editor: LexicalEditor,\n insertAfter: boolean\n) => {\n editor.update(() => {\n $insertTableColumnAtSelection(insertAfter);\n });\n};\n\nexport const insertTableColumns = (\n editor: LexicalEditor,\n insertAfter: boolean,\n count: number\n) => {\n editor.update(() => {\n for (let index = 0; index < count; index += 1) {\n $insertTableColumnAtSelection(insertAfter);\n }\n });\n};\n\nexport const deleteSelectedTableRow = (editor: LexicalEditor) => {\n editor.update(() => {\n $deleteTableRowAtSelection();\n });\n};\n\nexport const deleteSelectedTableColumn = (editor: LexicalEditor) => {\n editor.update(() => {\n $deleteTableColumnAtSelection();\n });\n};\n\nexport const deleteSelectedTable = (editor: LexicalEditor) => {\n editor.update(() => {\n const selection = $getSelection();\n if (!($isRangeSelection(selection) || $isTableSelection(selection))) {\n return;\n }\n\n const tableCellNode = $getTableCellNodeFromLexicalNode(\n selection.anchor.getNode()\n );\n if (!tableCellNode) {\n return;\n }\n\n $getTableNodeFromLexicalNodeOrThrow(tableCellNode).remove();\n });\n};\n", "path": "registry/pytah/editor/components/editor/plugins/table-behavior/actions.ts", "target": "src/components/editor/plugins/table-behavior/actions.ts", "type": "registry:file" }, { "content": "export const DEFAULT_INSERT_TABLE_PAYLOAD = {\n columns: \"3\",\n includeHeaders: true,\n rows: \"3\",\n} as const;\n", "path": "registry/pytah/editor/components/editor/plugins/table-behavior/constants.ts", "target": "src/components/editor/plugins/table-behavior/constants.ts", "type": "registry:file" }, { "content": "import { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { Columns2Icon, Rows3Icon, Trash2Icon } from \"lucide-react\";\nimport type { MouseEvent } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n deleteSelectedTable,\n deleteSelectedTableColumn,\n deleteSelectedTableRow,\n insertTableColumns,\n insertTableRows,\n} from \"./actions\";\nimport type { SelectionCounts } from \"./types\";\n\ninterface TableActionMenuProps {\n onClose: () => void;\n selectionCounts: SelectionCounts;\n}\n\nexport function TableActionMenu({\n onClose,\n selectionCounts,\n}: TableActionMenuProps) {\n const [editor] = useLexicalComposerContext();\n\n const handleMouseDown = (event: MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n };\n\n const rowLabel =\n selectionCounts.rows === 1 ? \"row\" : `${selectionCounts.rows} rows`;\n const columnLabel =\n selectionCounts.columns === 1\n ? \"column\"\n : `${selectionCounts.columns} columns`;\n\n return (\n
\n {\n insertTableRows(editor, false, selectionCounts.rows);\n onClose();\n }}\n onMouseDown={handleMouseDown}\n size=\"sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n Insert {rowLabel} above\n \n {\n insertTableRows(editor, true, selectionCounts.rows);\n onClose();\n }}\n onMouseDown={handleMouseDown}\n size=\"sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n Insert {rowLabel} below\n \n {\n insertTableColumns(editor, false, selectionCounts.columns);\n onClose();\n }}\n onMouseDown={handleMouseDown}\n size=\"sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n Insert {columnLabel} left\n \n {\n insertTableColumns(editor, true, selectionCounts.columns);\n onClose();\n }}\n onMouseDown={handleMouseDown}\n size=\"sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n Insert {columnLabel} right\n \n \n {\n deleteSelectedTableRow(editor);\n onClose();\n }}\n onMouseDown={handleMouseDown}\n size=\"sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n Delete row\n \n {\n deleteSelectedTableColumn(editor);\n onClose();\n }}\n onMouseDown={handleMouseDown}\n size=\"sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n Delete column\n \n {\n deleteSelectedTable(editor);\n onClose();\n }}\n onMouseDown={handleMouseDown}\n size=\"sm\"\n type=\"button\"\n variant=\"ghost\"\n >\n \n Delete table\n \n
\n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/table-behavior/menu.tsx", "target": "src/components/editor/plugins/table-behavior/menu.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { TablePlugin } from \"@lexical/react/LexicalTablePlugin\";\nimport { useLexicalEditable } from \"@lexical/react/useLexicalEditable\";\nimport { mergeRegister } from \"@lexical/utils\";\nimport {\n COMMAND_PRIORITY_CRITICAL,\n isDOMNode,\n SELECTION_CHANGE_COMMAND,\n} from \"lexical\";\nimport { ChevronDownIcon } from \"lucide-react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { TableActionMenu } from \"./menu\";\nimport {\n areSelectionCountsEqual,\n DEFAULT_SELECTION_COUNTS,\n readTableMenuContext,\n} from \"./selection\";\nimport type {\n ButtonPosition,\n SelectionCounts,\n TableMenuContext,\n} from \"./types\";\n\nfunction TableCellActionMenuContainer({\n anchorElem,\n}: {\n anchorElem: HTMLElement;\n}) {\n const [editor] = useLexicalComposerContext();\n const [isMenuOpen, setIsMenuOpen] = useState(false);\n const [isVisible, setIsVisible] = useState(false);\n const [position, setPosition] = useState({ left: 0, top: 0 });\n const [selectionCounts, setSelectionCounts] = useState(\n DEFAULT_SELECTION_COUNTS\n );\n const isMenuOpenRef = useRef(false);\n const isMenuClosingRef = useRef(false);\n const activeCellKeyRef = useRef(null);\n\n const setMenuOpen = useCallback((open: boolean) => {\n isMenuOpenRef.current = open;\n setIsMenuOpen(open);\n }, []);\n\n const applyMenuContext = useCallback((context: TableMenuContext) => {\n activeCellKeyRef.current = context.cellKey;\n setPosition((currentPosition) => {\n return currentPosition.left === context.position.left &&\n currentPosition.top === context.position.top\n ? currentPosition\n : context.position;\n });\n setSelectionCounts((currentCounts) => {\n return areSelectionCountsEqual(currentCounts, context.selectionCounts)\n ? currentCounts\n : context.selectionCounts;\n });\n setIsVisible((currentIsVisible) =>\n currentIsVisible ? currentIsVisible : true\n );\n }, []);\n\n const hideMenu = useCallback(() => {\n activeCellKeyRef.current = null;\n setIsVisible((currentIsVisible) =>\n currentIsVisible ? false : currentIsVisible\n );\n setSelectionCounts((currentCounts) => {\n return areSelectionCountsEqual(currentCounts, DEFAULT_SELECTION_COUNTS)\n ? currentCounts\n : DEFAULT_SELECTION_COUNTS;\n });\n }, []);\n\n const closeMenuAtCurrentPosition = useCallback(() => {\n isMenuClosingRef.current = true;\n setMenuOpen(false);\n }, [setMenuOpen]);\n\n const syncMenuToSelection = useCallback(() => {\n editor.getEditorState().read(() => {\n const context = readTableMenuContext(editor, anchorElem);\n\n if (!context) {\n hideMenu();\n return;\n }\n\n applyMenuContext(context);\n });\n }, [anchorElem, applyMenuContext, editor, hideMenu]);\n\n const updateMenu = useCallback(() => {\n editor.getEditorState().read(() => {\n const context = readTableMenuContext(editor, anchorElem);\n\n if (isMenuClosingRef.current) {\n return;\n }\n\n if (!context) {\n if (isMenuOpenRef.current) {\n closeMenuAtCurrentPosition();\n return;\n }\n\n hideMenu();\n return;\n }\n\n if (\n isMenuOpenRef.current &&\n activeCellKeyRef.current &&\n context.cellKey !== activeCellKeyRef.current\n ) {\n closeMenuAtCurrentPosition();\n return;\n }\n\n applyMenuContext(context);\n });\n }, [\n anchorElem,\n applyMenuContext,\n closeMenuAtCurrentPosition,\n editor,\n hideMenu,\n ]);\n\n useEffect(() => {\n const onPointerUp = () => {\n window.setTimeout(updateMenu, 0);\n };\n\n updateMenu();\n\n return mergeRegister(\n editor.registerUpdateListener(() => {\n updateMenu();\n }),\n editor.registerCommand(\n SELECTION_CHANGE_COMMAND,\n () => {\n updateMenu();\n return false;\n },\n COMMAND_PRIORITY_CRITICAL\n ),\n editor.registerRootListener((rootElement, previousRootElement) => {\n previousRootElement?.removeEventListener(\"pointerup\", onPointerUp);\n rootElement?.addEventListener(\"pointerup\", onPointerUp);\n })\n );\n }, [editor, updateMenu]);\n\n useEffect(() => {\n window.addEventListener(\"resize\", updateMenu);\n window.addEventListener(\"scroll\", updateMenu, true);\n\n return () => {\n window.removeEventListener(\"resize\", updateMenu);\n window.removeEventListener(\"scroll\", updateMenu, true);\n };\n }, [updateMenu]);\n\n useEffect(() => {\n if (!isMenuOpen) {\n return;\n }\n\n const handleClickOutside = (event: MouseEvent) => {\n const target = event.target;\n if (!(target && isDOMNode(target))) {\n return;\n }\n\n const menuRoot = anchorElem.querySelector(\n \"[data-table-actions-root='true']\"\n );\n if (menuRoot?.contains(target)) {\n return;\n }\n\n closeMenuAtCurrentPosition();\n };\n\n window.addEventListener(\"click\", handleClickOutside);\n return () => {\n window.removeEventListener(\"click\", handleClickOutside);\n };\n }, [anchorElem, closeMenuAtCurrentPosition, isMenuOpen]);\n\n return createPortal(\n \n {\n setMenuOpen(open);\n\n if (open) {\n isMenuClosingRef.current = false;\n return;\n }\n\n isMenuClosingRef.current = true;\n }}\n onOpenChangeComplete={(open) => {\n if (open) {\n return;\n }\n\n isMenuClosingRef.current = false;\n syncMenuToSelection();\n }}\n open={isMenuOpen}\n >\n {\n event.preventDefault();\n event.stopPropagation();\n }}\n onPointerDown={(event) => {\n event.stopPropagation();\n }}\n size=\"icon-xs\"\n variant=\"outline\"\n />\n }\n >\n \n \n \n \n \n \n ,\n anchorElem\n );\n}\n\nexport function TableBehaviorPlugin() {\n const [editor] = useLexicalComposerContext();\n const isEditable = useLexicalEditable();\n const [anchorElem, setAnchorElem] = useState(null);\n\n useEffect(() => {\n return editor.registerRootListener((rootElement) => {\n setAnchorElem(rootElement?.parentElement ?? null);\n });\n }, [editor]);\n\n return (\n <>\n \n {isEditable && anchorElem ? (\n \n ) : null}\n \n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/table-behavior/plugin.tsx", "target": "src/components/editor/plugins/table-behavior/plugin.tsx", "type": "registry:file" }, { "content": "import {\n $getTableCellNodeFromLexicalNode,\n $getTableColumnIndexFromTableCellNode,\n $getTableNodeFromLexicalNodeOrThrow,\n $getTableRowIndexFromTableCellNode,\n $isTableRowNode,\n $isTableSelection,\n type TableSelection,\n} from \"@lexical/table\";\nimport { $getSelection, $isRangeSelection, type LexicalEditor } from \"lexical\";\nimport type {\n SelectionCounts,\n TableMenuContext,\n TableSelectionState,\n} from \"./types\";\n\nexport const DEFAULT_SELECTION_COUNTS: SelectionCounts = {\n columns: 1,\n rows: 1,\n};\n\nexport const areSelectionCountsEqual = (\n left: SelectionCounts,\n right: SelectionCounts\n) => {\n return left.columns === right.columns && left.rows === right.rows;\n};\n\nexport const EMPTY_TABLE_SELECTION_STATE: TableSelectionState = {\n columnCount: 0,\n columnIndex: -1,\n isActive: false,\n rowCount: 0,\n rowIndex: -1,\n tableKey: null,\n};\n\nexport const readTableSelectionState = (): TableSelectionState => {\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return EMPTY_TABLE_SELECTION_STATE;\n }\n\n const anchorNode = selection.anchor.getNode();\n const tableCellNode = $getTableCellNodeFromLexicalNode(anchorNode);\n\n if (!tableCellNode) {\n return EMPTY_TABLE_SELECTION_STATE;\n }\n\n const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);\n const firstRow = tableNode.getFirstChild();\n const columnCount = $isTableRowNode(firstRow)\n ? firstRow.getChildrenSize()\n : 0;\n\n return {\n columnCount,\n columnIndex: $getTableColumnIndexFromTableCellNode(tableCellNode),\n isActive: true,\n rowCount: tableNode.getChildrenSize(),\n rowIndex: $getTableRowIndexFromTableCellNode(tableCellNode),\n tableKey: tableNode.getKey(),\n };\n};\n\nexport const resolveSelectionCounts = (\n selection: ReturnType\n): SelectionCounts => {\n if (!$isTableSelection(selection)) {\n return DEFAULT_SELECTION_COUNTS;\n }\n\n const shape = (selection as TableSelection).getShape();\n return {\n columns: shape.toX - shape.fromX + 1,\n rows: shape.toY - shape.fromY + 1,\n };\n};\n\nexport const readTableMenuContext = (\n editor: LexicalEditor,\n anchorElem: HTMLElement\n): TableMenuContext | null => {\n const selection = $getSelection();\n if (!($isRangeSelection(selection) || $isTableSelection(selection))) {\n return null;\n }\n\n const tableCellNode = $getTableCellNodeFromLexicalNode(\n selection.anchor.getNode()\n );\n if (!tableCellNode?.isAttached()) {\n return null;\n }\n\n const tableCellElement = editor.getElementByKey(tableCellNode.getKey());\n if (!tableCellElement) {\n return null;\n }\n\n const cellRect = tableCellElement.getBoundingClientRect();\n const anchorRect = anchorElem.getBoundingClientRect();\n const buttonSize = 20;\n\n return {\n cellKey: tableCellNode.getKey(),\n position: {\n left: cellRect.right - anchorRect.left - buttonSize - 6,\n top:\n cellRect.top -\n anchorRect.top +\n Math.round((cellRect.height - buttonSize) / 2),\n },\n selectionCounts: resolveSelectionCounts(selection),\n };\n};\n", "path": "registry/pytah/editor/components/editor/plugins/table-behavior/selection.ts", "target": "src/components/editor/plugins/table-behavior/selection.ts", "type": "registry:file" }, { "content": "import type { NodeKey } from \"lexical\";\n\nexport interface SelectionCounts {\n columns: number;\n rows: number;\n}\n\nexport interface ButtonPosition {\n left: number;\n top: number;\n}\n\nexport interface TableMenuContext {\n cellKey: NodeKey;\n position: ButtonPosition;\n selectionCounts: SelectionCounts;\n}\n\nexport interface TableSelectionState {\n columnCount: number;\n columnIndex: number;\n isActive: boolean;\n rowCount: number;\n rowIndex: number;\n tableKey: NodeKey | null;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/table-behavior/types.ts", "target": "src/components/editor/plugins/table-behavior/types.ts", "type": "registry:file" }, { "content": "import type { TocHeadingStyle } from \"./types\";\n\nexport const OBSERVER_ROOT_MARGIN = \"0px 0px -70% 0px\";\nexport const ACTIVE_HEADING_TOP_OFFSET = 120;\n\nexport const HEADING_STYLES: Record = {\n h1: { indent: \"pl-0\", size: \"text-[13px]\", weight: \"font-medium\" },\n h2: { indent: \"pl-3\", size: \"text-[13px]\", weight: \"font-normal\" },\n h3: { indent: \"pl-6\", size: \"text-[12px]\", weight: \"font-normal\" },\n h4: { indent: \"pl-9\", size: \"text-[11px]\", weight: \"font-normal\" },\n h5: { indent: \"pl-12\", size: \"text-[11px]\", weight: \"font-normal\" },\n h6: { indent: \"pl-15\", size: \"text-[11px]\", weight: \"font-normal\" },\n};\n\nexport const DEFAULT_HEADING_STYLE = HEADING_STYLES.h3;\nexport const DEFAULT_SCROLL_TOP_OFFSET = 24;\n", "path": "registry/pytah/editor/components/editor/plugins/toc/constants.ts", "target": "src/components/editor/plugins/toc/constants.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport type { TableOfContentsEntry } from \"@lexical/react/LexicalTableOfContentsPlugin\";\nimport { mergeRegister } from \"@lexical/utils\";\nimport type { LexicalEditor, NodeKey } from \"lexical\";\nimport { useCallback, useEffect, useReducer, useRef } from \"react\";\nimport { OBSERVER_ROOT_MARGIN } from \"./constants\";\nimport type { TocState } from \"./types\";\nimport { resolveActiveHeadingKey, resolveSelectedHeadingKey } from \"./utils\";\n\ntype TocAction =\n | { type: \"set_active\"; payload: NodeKey | null }\n | { type: \"set_selected\"; payload: NodeKey | null };\n\nconst tocReducer = (state: TocState, action: TocAction): TocState => {\n switch (action.type) {\n case \"set_active\":\n return state.activeKey === action.payload\n ? state\n : { ...state, activeKey: action.payload };\n case \"set_selected\":\n return state.selectedHeadingKey === action.payload\n ? state\n : { ...state, selectedHeadingKey: action.payload };\n default:\n return state;\n }\n};\n\nexport function useActiveHeading(\n entries: readonly TableOfContentsEntry[],\n editor: LexicalEditor\n): NodeKey | null {\n const [state, dispatch] = useReducer(tocReducer, {\n activeKey: null,\n selectedHeadingKey: null,\n });\n const observerRef = useRef(null);\n const rafRef = useRef(0);\n\n useEffect(() => {\n return mergeRegister(\n editor.registerUpdateListener(({ editorState }) => {\n editorState.read(() => {\n dispatch({\n type: \"set_selected\",\n payload: resolveSelectedHeadingKey(),\n });\n });\n })\n );\n }, [editor]);\n\n const syncActiveHeading = useCallback(() => {\n cancelAnimationFrame(rafRef.current);\n rafRef.current = requestAnimationFrame(() => {\n dispatch({\n type: \"set_active\",\n payload: resolveActiveHeadingKey(entries, editor),\n });\n });\n }, [entries, editor]);\n\n useEffect(() => {\n observerRef.current?.disconnect();\n\n const observer = new IntersectionObserver(syncActiveHeading, {\n rootMargin: OBSERVER_ROOT_MARGIN,\n threshold: 0,\n });\n observerRef.current = observer;\n\n for (const [key] of entries) {\n const element = editor.getElementByKey(key);\n if (element instanceof HTMLElement) {\n observer.observe(element);\n }\n }\n\n syncActiveHeading();\n window.addEventListener(\"scroll\", syncActiveHeading, { passive: true });\n window.addEventListener(\"resize\", syncActiveHeading);\n\n return () => {\n cancelAnimationFrame(rafRef.current);\n observer.disconnect();\n window.removeEventListener(\"scroll\", syncActiveHeading);\n window.removeEventListener(\"resize\", syncActiveHeading);\n };\n }, [entries, editor, syncActiveHeading]);\n\n return state.selectedHeadingKey ?? state.activeKey;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/toc/hooks.ts", "target": "src/components/editor/plugins/toc/hooks.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport {\n TableOfContentsPlugin as LexicalTableOfContentsPlugin,\n type TableOfContentsEntry,\n} from \"@lexical/react/LexicalTableOfContentsPlugin\";\nimport type { LexicalEditor } from \"lexical\";\nimport type { ReactNode } from \"react\";\n\ninterface EditorTableOfContentsPluginProps {\n children: (\n entries: readonly TableOfContentsEntry[],\n editor: LexicalEditor\n ) => ReactNode;\n}\n\nexport function EditorTableOfContentsPlugin({\n children,\n}: EditorTableOfContentsPluginProps) {\n return (\n \n {(entries, editor) => <>{children(entries, editor)}}\n \n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/toc/plugin.tsx", "target": "src/components/editor/plugins/toc/plugin.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport type { TableOfContentsEntry } from \"@lexical/react/LexicalTableOfContentsPlugin\";\nimport type { LexicalEditor, NodeKey } from \"lexical\";\nimport { cn } from \"@/lib/utils\";\nimport { useActiveHeading } from \"./hooks\";\nimport { EditorTableOfContentsPlugin } from \"./plugin\";\nimport { getHeadingStyle, scrollAndFocusHeading } from \"./utils\";\n\nfunction EditorTableOfContentsItems({\n activeKey,\n className,\n editor,\n entries,\n}: {\n activeKey: NodeKey | null;\n className?: string;\n editor: LexicalEditor;\n entries: readonly TableOfContentsEntry[];\n}) {\n if (entries.length === 0) {\n return (\n

\n Add headings to see the outline.\n

\n );\n }\n\n return (\n \n );\n}\n\nfunction EditorTableOfContentsInner({\n className,\n editor,\n entries,\n}: {\n className?: string;\n editor: LexicalEditor;\n entries: readonly TableOfContentsEntry[];\n}) {\n const activeKey = useActiveHeading(entries, editor);\n\n return (\n \n );\n}\n\nexport function EditorTableOfContents({ className }: { className?: string }) {\n return (\n \n );\n}\n", "path": "registry/pytah/editor/components/editor/plugins/toc/sidebar.tsx", "target": "src/components/editor/plugins/toc/sidebar.tsx", "type": "registry:file" }, { "content": "import type { NodeKey } from \"lexical\";\n\nexport type TocHeadingTag = \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\";\n\nexport interface TocHeadingStyle {\n indent: string;\n size: string;\n weight: string;\n}\n\nexport interface TocState {\n activeKey: NodeKey | null;\n selectedHeadingKey: NodeKey | null;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/toc/types.ts", "target": "src/components/editor/plugins/toc/types.ts", "type": "registry:file" }, { "content": "import type { TableOfContentsEntry } from \"@lexical/react/LexicalTableOfContentsPlugin\";\nimport { $isHeadingNode } from \"@lexical/rich-text\";\nimport {\n $getNodeByKey,\n $getSelection,\n $isRangeSelection,\n type LexicalEditor,\n type NodeKey,\n} from \"lexical\";\nimport {\n ACTIVE_HEADING_TOP_OFFSET,\n DEFAULT_HEADING_STYLE,\n DEFAULT_SCROLL_TOP_OFFSET,\n HEADING_STYLES,\n} from \"./constants\";\nimport type { TocState } from \"./types\";\n\nexport const getHeadingStyle = (tag: string) => {\n return HEADING_STYLES[tag] ?? DEFAULT_HEADING_STYLE;\n};\n\nexport const getScrollTopOffset = () => {\n const headers = Array.from(document.querySelectorAll(\"header\"));\n let maxHeaderBottom = 0;\n\n for (const header of headers) {\n if (!(header instanceof HTMLElement)) {\n continue;\n }\n\n const { position } = window.getComputedStyle(header);\n if (position !== \"fixed\" && position !== \"sticky\") {\n continue;\n }\n\n maxHeaderBottom = Math.max(\n maxHeaderBottom,\n header.getBoundingClientRect().bottom\n );\n }\n\n return Math.max(DEFAULT_SCROLL_TOP_OFFSET, Math.round(maxHeaderBottom + 16));\n};\n\nconst scrollToHeading = (headingElement: HTMLElement) => {\n const top =\n window.scrollY +\n headingElement.getBoundingClientRect().top -\n getScrollTopOffset();\n\n window.scrollTo({\n behavior: \"smooth\",\n top: Math.max(0, top),\n });\n};\n\nexport const scrollAndFocusHeading = (\n editor: LexicalEditor,\n headingKey: NodeKey\n) => {\n const headingElement = editor.getElementByKey(headingKey);\n if (!(headingElement instanceof HTMLElement)) {\n return;\n }\n\n editor.update(\n () => {\n $getNodeByKey(headingKey)?.selectStart();\n },\n { discrete: true }\n );\n\n editor.focus();\n\n window.requestAnimationFrame(() => {\n scrollToHeading(headingElement);\n });\n};\n\nexport const resolveActiveHeadingKey = (\n entries: readonly TableOfContentsEntry[],\n editor: LexicalEditor\n): NodeKey | null => {\n let activeKey: NodeKey | null = null;\n\n for (const [key] of entries) {\n const element = editor.getElementByKey(key);\n if (!(element instanceof HTMLElement)) {\n continue;\n }\n\n if (element.getBoundingClientRect().top <= ACTIVE_HEADING_TOP_OFFSET) {\n activeKey = key;\n continue;\n }\n\n return activeKey ?? key;\n }\n\n return activeKey ?? entries.at(-1)?.[0] ?? null;\n};\n\nexport const resolveSelectedHeadingKey = (): NodeKey | null => {\n const selection = $getSelection();\n if (!$isRangeSelection(selection)) {\n return null;\n }\n\n const topLevelElement = selection.anchor\n .getNode()\n .getTopLevelElementOrThrow();\n if (!$isHeadingNode(topLevelElement)) {\n return null;\n }\n\n return topLevelElement.getKey();\n};\n\nexport const areTocStatesEqual = (left: TocState, right: TocState) => {\n return (\n left.activeKey === right.activeKey &&\n left.selectedHeadingKey === right.selectedHeadingKey\n );\n};\n", "path": "registry/pytah/editor/components/editor/plugins/toc/utils.ts", "target": "src/components/editor/plugins/toc/utils.ts", "type": "registry:file" }, { "content": "import { createCommand } from \"lexical\";\n\nexport interface InsertYouTubePayload {\n targetNodeKey?: string;\n videoId: string;\n}\n\nexport const INSERT_YOUTUBE_COMMAND = createCommand(\n \"INSERT_YOUTUBE_COMMAND\"\n);\n", "path": "registry/pytah/editor/components/editor/plugins/youtube/commands.ts", "target": "src/components/editor/plugins/youtube/commands.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { useLexicalComposerContext } from \"@lexical/react/LexicalComposerContext\";\nimport { $insertNodeToNearestRoot } from \"@lexical/utils\";\nimport {\n $createParagraphNode,\n $getNodeByKey,\n $isElementNode,\n COMMAND_PRIORITY_EDITOR,\n} from \"lexical\";\nimport { useEffect } from \"react\";\nimport { $createYouTubeNode, YouTubeNode } from \"../../core/nodes/youtube/node\";\nimport { INSERT_YOUTUBE_COMMAND } from \"./commands\";\n\nconst insertParagraphAfterYouTube = (\n youTubeNode: ReturnType\n) => {\n const paragraph = $createParagraphNode();\n youTubeNode.insertAfter(paragraph);\n paragraph.select();\n};\n\nexport function YouTubePlugin() {\n const [editor] = useLexicalComposerContext();\n\n useEffect(() => {\n if (!editor.hasNodes([YouTubeNode])) {\n throw new Error(\"YouTubePlugin: YouTubeNode not registered on editor\");\n }\n\n return editor.registerCommand(\n INSERT_YOUTUBE_COMMAND,\n ({ targetNodeKey, videoId }) => {\n const trimmedVideoId = videoId.trim();\n if (!trimmedVideoId) {\n return false;\n }\n\n const youTubeNode = $createYouTubeNode(trimmedVideoId);\n\n if (targetNodeKey) {\n const targetNode = $getNodeByKey(targetNodeKey);\n if (!$isElementNode(targetNode)) {\n return false;\n }\n\n targetNode.replace(youTubeNode);\n insertParagraphAfterYouTube(youTubeNode);\n return true;\n }\n\n $insertNodeToNearestRoot(youTubeNode);\n insertParagraphAfterYouTube(youTubeNode);\n return true;\n },\n COMMAND_PRIORITY_EDITOR\n );\n }, [editor]);\n\n return null;\n}\n", "path": "registry/pytah/editor/components/editor/plugins/youtube/plugin.tsx", "target": "src/components/editor/plugins/youtube/plugin.tsx", "type": "registry:file" }, { "content": "import { deepStrictEqual, strictEqual } from \"node:assert/strict\";\nimport { describe, test } from \"node:test\";\nimport { parseYouTubeUrl } from \"./utils\";\n\ndescribe(\"youtube utils\", () => {\n test(\"extracts a video id from supported url shapes\", () => {\n deepStrictEqual(\n [\n parseYouTubeUrl(\"https://youtu.be/dQw4w9WgXcQ\"),\n parseYouTubeUrl(\"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42\"),\n parseYouTubeUrl(\"https://www.youtube.com/embed/dQw4w9WgXcQ\"),\n ],\n [\"dQw4w9WgXcQ\", \"dQw4w9WgXcQ\", \"dQw4w9WgXcQ\"]\n );\n });\n\n test(\"rejects invalid ids and unrelated urls\", () => {\n strictEqual(parseYouTubeUrl(\"https://example.com/video\"), null);\n strictEqual(parseYouTubeUrl(\"https://youtu.be/short\"), null);\n strictEqual(parseYouTubeUrl(\"not a youtube url\"), null);\n });\n});\n", "path": "registry/pytah/editor/components/editor/plugins/youtube/utils.test.ts", "target": "src/components/editor/plugins/youtube/utils.test.ts", "type": "registry:file" }, { "content": "const YOUTUBE_URL_PATTERN =\n /^.*(?:youtu\\.be\\/|v\\/|u\\/\\w\\/|embed\\/|watch\\?v=|&v=)([^#&?]*).*/;\n\nexport const parseYouTubeUrl = (url: string): string | null => {\n const match = YOUTUBE_URL_PATTERN.exec(url.trim());\n const videoId = match?.[1];\n\n if (videoId?.length === 11) {\n return videoId;\n }\n\n return null;\n};\n", "path": "registry/pytah/editor/components/editor/plugins/youtube/utils.ts", "target": "src/components/editor/plugins/youtube/utils.ts", "type": "registry:file" }, { "content": "\"use client\";\n\nimport type { FileTextIcon } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\nimport { FEATURE_ITEMS, WORD_SEPARATOR_PATTERN } from \"../core/constants\";\nimport type { EditorSnapshot } from \"../core/types\";\n\nexport function EditorShell({\n children,\n className,\n}: {\n children: React.ReactNode;\n className?: string;\n}) {\n return (\n \n {children}\n \n );\n}\n\nexport function EditorHeader({ className }: { className?: string }) {\n return (\n \n
\n {FEATURE_ITEMS.map((item) => (\n \n \n {item.label}\n
\n ))}\n \n \n );\n}\n\nexport function EditorFooter({\n className,\n snapshot,\n}: {\n className?: string;\n snapshot: EditorSnapshot;\n}) {\n const trimmedText = snapshot.text.trim();\n const wordCount = trimmedText\n ? trimmedText.split(WORD_SEPARATOR_PATTERN).length\n : 0;\n const characterCount = snapshot.text.length;\n\n return (\n \n
\n
\n {wordCount} words\n {characterCount} chars\n Copy/paste ready HTML + Markdown\n
\n Use / to insert blocks\n
\n \n );\n}\n\nexport function OutputPanel({\n icon: Icon,\n label,\n onCopy,\n value,\n}: {\n icon: typeof FileTextIcon;\n label: string;\n onCopy: () => void;\n value: string;\n}) {\n return (\n
\n
\n
\n \n {label}\n
\n \n
\n \n
\n );\n}\n", "path": "registry/pytah/editor/components/editor/ui/chrome.tsx", "target": "src/components/editor/ui/chrome.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport type { LucideIcon } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\nimport { COLOR_PALETTE, type ColorSwatch } from \"../core/colors\";\n\ninterface ColorSwatchesProps {\n /** Currently active color value (hex). Empty string means no color. */\n activeColor: string;\n className?: string;\n /** Icon rendered inside the trigger button. */\n icon: LucideIcon;\n /** Accessible label for the trigger button. */\n label: string;\n /** Called with the selected hex value, or \"\" when the user clears. */\n onColorChange: (color: string) => void;\n /** Notified when the popover opens/closes — useful to prevent the parent\n * floating toolbar from hiding while the picker is in use. */\n onOpenChange?: (open: boolean) => void;\n /**\n * Override the default palette. Changing this prop (or editing\n * `src/components/editor/core/colors.ts`) is the intended customisation\n * surface for developers.\n */\n palette?: ColorSwatch[];\n}\n\nexport function ColorSwatches({\n activeColor,\n icon: Icon,\n label,\n onColorChange,\n onOpenChange,\n palette = COLOR_PALETTE,\n className,\n}: ColorSwatchesProps) {\n const [open, setOpen] = useState(false);\n\n const handleOpenChange = (nextOpen: boolean) => {\n setOpen(nextOpen);\n onOpenChange?.(nextOpen);\n };\n\n const handleSelect = (color: string) => {\n onColorChange(color);\n handleOpenChange(false);\n };\n\n return (\n \n {/*\n * onMouseDown is prevented so that clicking the trigger does not move\n * focus away from the editor — the Lexical selection is preserved and\n * the color is applied to the correct range.\n */}\n e.preventDefault()}\n render={\n \n }\n >\n \n {/* Thin color bar indicates the currently active color */}\n \n \n\n {/*\n * onMouseDown is also prevented here so that clicking any swatch\n * button keeps focus in the editor instead of moving it to the popup.\n */}\n e.preventDefault()}\n side=\"bottom\"\n sideOffset={6}\n >\n
\n {palette.map((swatch) => (\n handleSelect(swatch.value)}\n style={{ backgroundColor: swatch.value }}\n title={swatch.label}\n type=\"button\"\n />\n ))}\n
\n\n
\n handleSelect(\"\")}\n type=\"button\"\n >\n Clear\n \n
\n \n
\n );\n}\n", "path": "registry/pytah/editor/components/editor/ui/color-swatches.tsx", "target": "src/components/editor/ui/color-swatches.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { CheckListPlugin } from \"@lexical/react/LexicalCheckListPlugin\";\nimport { ContentEditable } from \"@lexical/react/LexicalContentEditable\";\nimport { LexicalErrorBoundary } from \"@lexical/react/LexicalErrorBoundary\";\nimport { HistoryPlugin } from \"@lexical/react/LexicalHistoryPlugin\";\nimport { ListPlugin } from \"@lexical/react/LexicalListPlugin\";\nimport { MarkdownShortcutPlugin } from \"@lexical/react/LexicalMarkdownShortcutPlugin\";\nimport { RichTextPlugin } from \"@lexical/react/LexicalRichTextPlugin\";\nimport { TabIndentationPlugin } from \"@lexical/react/LexicalTabIndentationPlugin\";\nimport type { LexicalEditor } from \"lexical\";\nimport { cn } from \"@/lib/utils\";\nimport {\n type ResolvedEditorFeatureFlags,\n renderEditorSlot,\n} from \"../core/composition\";\nimport type {\n EditorChromeSlots,\n EditorPluginSlots,\n EditorSnapshot,\n EditorToolbar,\n} from \"../core/types\";\nimport { BlockTypeToolbarPlugin } from \"../plugins/block-type-toolbar/plugin\";\nimport { CodeHighlightPlugin } from \"../plugins/code-highlight/plugin\";\nimport { CollapsiblePlugin } from \"../plugins/collapsible/plugin\";\nimport { EditablePlugin } from \"../plugins/core/editable\";\nimport { EditorStatePlugin } from \"../plugins/core/editor-state\";\nimport { FocusOnMountPlugin } from \"../plugins/core/focus-on-mount\";\nimport { HorizontalRulePlugin } from \"../plugins/core/horizontal-rule\";\nimport { SeedContentPlugin } from \"../plugins/core/seed-content\";\nimport { DraggableBlockPlugin } from \"../plugins/draggable-block/plugin\";\nimport { FloatingToolbarPlugin } from \"../plugins/floating-toolbar/plugin\";\nimport { FullToolbarPlugin } from \"../plugins/full-toolbar/plugin\";\nimport { ImagePlugin } from \"../plugins/image/plugin\";\nimport { LayoutPlugin } from \"../plugins/layout/plugin\";\nimport { FloatingLinkEditorPlugin } from \"../plugins/link-behavior/floating-link-editor\";\nimport { LinkBehaviorPlugin } from \"../plugins/link-behavior/plugin\";\nimport { EDITOR_MARKDOWN_TRANSFORMERS } from \"../plugins/markdown/transformers\";\nimport { SlashCommandPlugin } from \"../plugins/slash-command/plugin\";\nimport { TableBehaviorPlugin } from \"../plugins/table-behavior/plugin\";\nimport { YouTubePlugin } from \"../plugins/youtube/plugin\";\nimport { EditorFooter } from \"./chrome\";\n\ninterface EditorTopToolbarProps {\n editable: boolean;\n toolbar: EditorToolbar;\n topToolbar?: EditorChromeSlots[\"topToolbar\"];\n}\n\nfunction EditorTopToolbar({\n editable,\n topToolbar,\n toolbar,\n}: EditorTopToolbarProps) {\n if (!editable) {\n return null;\n }\n\n if (topToolbar !== undefined) {\n return topToolbar;\n }\n\n if (!toolbar) {\n return null;\n }\n\n return (\n
\n
\n {toolbar === \"full\" ? (\n \n ) : (\n \n )}\n
\n
\n );\n}\n\ninterface EditorContentProps {\n contentClassName?: string;\n editable: boolean;\n editorInstance: LexicalEditor | null;\n features: ResolvedEditorFeatureFlags;\n footerSlot?: EditorChromeSlots[\"footer\"];\n initialHtml?: string;\n initialMarkdown?: string;\n minimal?: boolean;\n onSnapshotChange: (textContent: string, editor: LexicalEditor) => void;\n onSnapshotReady?: (snapshot: EditorSnapshot, editor: LexicalEditor) => void;\n placeholder: string;\n pluginSlots?: EditorPluginSlots;\n showFooter: boolean;\n snapshot: EditorSnapshot;\n toolbar: EditorToolbar;\n topToolbar?: EditorChromeSlots[\"topToolbar\"];\n}\n\ninterface DefaultEditorPluginsProps {\n editable: boolean;\n editorInstance: LexicalEditor | null;\n features: ResolvedEditorFeatureFlags;\n initialHtml?: string;\n initialMarkdown?: string;\n onSnapshotChange: (textContent: string, editor: LexicalEditor) => void;\n onSnapshotReady?: (snapshot: EditorSnapshot, editor: LexicalEditor) => void;\n}\n\nfunction DefaultEditorPlugins({\n editable,\n editorInstance,\n features,\n initialHtml,\n initialMarkdown,\n onSnapshotChange,\n onSnapshotReady,\n}: DefaultEditorPluginsProps) {\n return (\n <>\n {features.history ? : null}\n \n \n \n \n {features.images ? : null}\n {features.youtube ? : null}\n {features.collapsible ? : null}\n {features.layouts ? : null}\n \n {features.tables ? : null}\n {features.tabIndentation ? : null}\n {features.markdownShortcuts ? (\n \n ) : null}\n \n \n {features.seedContent ? (\n \n ) : null}\n \n );\n}\n\ninterface EditableEditorPluginsProps {\n features: ResolvedEditorFeatureFlags;\n pluginSlots?: EditorPluginSlots;\n}\n\nfunction EditableEditorPlugins({\n features,\n pluginSlots,\n}: EditableEditorPluginsProps) {\n return (\n <>\n {pluginSlots?.beforeEditable}\n {features.focusOnMount ? : null}\n {features.draggableBlocks ? : null}\n {features.floatingToolbar ? : null}\n {features.floatingLinkEditor ? : null}\n {features.slashCommand ? (\n \n ) : null}\n {pluginSlots?.afterEditable}\n \n );\n}\n\nexport function EditorContent({\n contentClassName,\n editable,\n editorInstance,\n features,\n footerSlot,\n initialHtml,\n initialMarkdown,\n minimal = false,\n onSnapshotChange,\n onSnapshotReady,\n placeholder,\n pluginSlots,\n showFooter,\n snapshot,\n topToolbar,\n toolbar,\n}: EditorContentProps) {\n const footerContent =\n footerSlot === undefined ? (\n \n ) : (\n renderEditorSlot(footerSlot, { snapshot })\n );\n\n return (\n <>\n \n\n
\n \n {placeholder}\n
\n }\n />\n }\n ErrorBoundary={LexicalErrorBoundary}\n />\n \n\n {!minimal && showFooter ? footerContent : null}\n\n {pluginSlots?.beforeDefault}\n \n {editable ? (\n \n ) : null}\n {pluginSlots?.afterDefault}\n \n );\n}\n", "path": "registry/pytah/editor/components/editor/ui/content.tsx", "target": "src/components/editor/ui/content.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { FileCode2Icon, FileTextIcon } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport type { EditorSnapshot } from \"../core/types\";\nimport { OutputPanel } from \"./chrome\";\n\ninterface EditorActionBarProps {\n className?: string;\n onLoadHtml: () => void;\n onLoadMarkdown: () => void;\n onReset: () => void;\n}\n\nexport function EditorActionBar({\n className,\n onLoadHtml,\n onLoadMarkdown,\n onReset,\n}: EditorActionBarProps) {\n return (\n \n
\n \n \n Load markdown\n \n \n \n
\n \n );\n}\n\ninterface EditorOutputGridProps {\n className?: string;\n onCopyHtml: () => void;\n onCopyMarkdown: () => void;\n snapshot: EditorSnapshot;\n}\n\nexport function EditorOutputGrid({\n className,\n onCopyHtml,\n onCopyMarkdown,\n snapshot,\n}: EditorOutputGridProps) {\n return (\n
\n \n \n
\n );\n}\n", "path": "registry/pytah/editor/components/editor/ui/panels.tsx", "target": "src/components/editor/ui/panels.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { Button as ButtonPrimitive } from \"@base-ui/react/button\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst buttonVariants = cva(\n \"group/button inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap rounded-lg border border-transparent bg-clip-padding font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n {\n variants: {\n variant: {\n default: \"bg-primary text-primary-foreground [a]:hover:bg-primary/80\",\n outline:\n \"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50\",\n secondary:\n \"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground\",\n ghost:\n \"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50\",\n destructive:\n \"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 dark:hover:bg-destructive/30\",\n link: \"text-primary underline-offset-4 hover:underline\",\n },\n size: {\n default:\n \"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2\",\n xs: \"h-6 gap-1 in-data-[slot=button-group]:rounded-lg rounded-[min(var(--radius-md),10px)] px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3\",\n sm: \"h-7 gap-1 in-data-[slot=button-group]:rounded-lg rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5\",\n lg: \"h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3\",\n icon: \"size-8\",\n \"icon-xs\":\n \"size-6 in-data-[slot=button-group]:rounded-lg rounded-[min(var(--radius-md),10px)] [&_svg:not([class*='size-'])]:size-3\",\n \"icon-sm\":\n \"size-7 in-data-[slot=button-group]:rounded-lg rounded-[min(var(--radius-md),12px)]\",\n \"icon-lg\": \"size-9\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n);\n\nfunction Button({\n className,\n variant = \"default\",\n size = \"default\",\n ...props\n}: ButtonPrimitive.Props & VariantProps) {\n return (\n \n );\n}\n\nexport { Button, buttonVariants };\n", "path": "registry/pytah/editor/ui/button.tsx", "target": "src/components/ui/button.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { Command as CommandPrimitive } from \"cmdk\";\nimport { CheckIcon, SearchIcon } from \"lucide-react\";\nimport type * as React from \"react\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { InputGroup, InputGroupAddon } from \"@/components/ui/input-group\";\nimport { cn } from \"@/lib/utils\";\n\nfunction Command({\n className,\n ...props\n}: React.ComponentProps) {\n return (\n \n );\n}\n\nfunction CommandDialog({\n title = \"Command Palette\",\n description = \"Search for a command to run...\",\n children,\n className,\n showCloseButton = false,\n ...props\n}: Omit, \"children\"> & {\n title?: string;\n description?: string;\n className?: string;\n showCloseButton?: boolean;\n children: React.ReactNode;\n}) {\n return (\n \n \n {title}\n {description}\n \n \n {children}\n \n \n );\n}\n\nfunction CommandInput({\n className,\n ...props\n}: React.ComponentProps) {\n return (\n
\n \n \n \n \n \n \n
\n );\n}\n\nfunction CommandList({\n className,\n ...props\n}: React.ComponentProps) {\n return (\n \n );\n}\n\nfunction CommandEmpty({\n className,\n ...props\n}: React.ComponentProps) {\n return (\n \n );\n}\n\nfunction CommandGroup({\n className,\n ...props\n}: React.ComponentProps) {\n return (\n \n );\n}\n\nfunction CommandSeparator({\n className,\n ...props\n}: React.ComponentProps) {\n return (\n \n );\n}\n\nfunction CommandItem({\n className,\n children,\n ...props\n}: React.ComponentProps) {\n return (\n \n {children}\n \n \n );\n}\n\nfunction CommandShortcut({\n className,\n ...props\n}: React.ComponentProps<\"span\">) {\n return (\n \n );\n}\n\nexport {\n Command,\n CommandDialog,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n CommandShortcut,\n};\n", "path": "registry/pytah/editor/ui/command.tsx", "target": "src/components/ui/command.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { Dialog as DialogPrimitive } from \"@base-ui/react/dialog\";\nimport { XIcon } from \"lucide-react\";\nimport type * as React from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nfunction Dialog({ ...props }: DialogPrimitive.Root.Props) {\n return ;\n}\n\nfunction DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {\n return ;\n}\n\nfunction DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {\n return ;\n}\n\nfunction DialogClose({ ...props }: DialogPrimitive.Close.Props) {\n return ;\n}\n\nfunction DialogOverlay({\n className,\n ...props\n}: DialogPrimitive.Backdrop.Props) {\n return (\n \n );\n}\n\nfunction DialogContent({\n className,\n children,\n showCloseButton = true,\n ...props\n}: DialogPrimitive.Popup.Props & {\n showCloseButton?: boolean;\n}) {\n return (\n \n \n \n {children}\n {showCloseButton && (\n \n }\n >\n \n Close\n \n )}\n \n \n );\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction DialogFooter({\n className,\n showCloseButton = false,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & {\n showCloseButton?: boolean;\n}) {\n return (\n \n {children}\n {showCloseButton && (\n }>\n Close\n \n )}\n \n );\n}\n\nfunction DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {\n return (\n \n );\n}\n\nfunction DialogDescription({\n className,\n ...props\n}: DialogPrimitive.Description.Props) {\n return (\n \n );\n}\n\nexport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogOverlay,\n DialogPortal,\n DialogTitle,\n DialogTrigger,\n};\n", "path": "registry/pytah/editor/ui/dialog.tsx", "target": "src/components/ui/dialog.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { Input as InputPrimitive } from \"@base-ui/react/input\";\nimport type * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n return (\n \n );\n}\n\nexport { Input };\n", "path": "registry/pytah/editor/ui/input.tsx", "target": "src/components/ui/input.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\n\nfunction InputGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n [data-align=block-end]]:h-auto has-[>[data-align=block-start]]:h-auto has-[>textarea]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:flex-col has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-start]]:[&>input]:pl-1.5\",\n className\n )}\n data-slot=\"input-group\"\n {...props}\n />\n );\n}\n\nconst inputGroupAddonVariants = cva(\n \"flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 font-medium text-muted-foreground text-sm group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4\",\n {\n variants: {\n align: {\n \"inline-start\":\n \"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]\",\n \"inline-end\":\n \"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]\",\n \"block-start\":\n \"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2\",\n \"block-end\":\n \"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2\",\n },\n },\n defaultVariants: {\n align: \"inline-start\",\n },\n }\n);\n\nfunction InputGroupAddon({\n className,\n align = \"inline-start\",\n ...props\n}: React.ComponentProps<\"div\"> & VariantProps) {\n return (\n \n );\n}\n\nconst inputGroupButtonVariants = cva(\n \"flex items-center gap-2 text-sm shadow-none\",\n {\n variants: {\n size: {\n xs: \"h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5\",\n sm: \"\",\n \"icon-xs\":\n \"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0\",\n \"icon-sm\": \"size-8 p-0 has-[>svg]:p-0\",\n },\n },\n defaultVariants: {\n size: \"xs\",\n },\n }\n);\n\nfunction InputGroupButton({\n className,\n type = \"button\",\n variant = \"ghost\",\n size = \"xs\",\n ...props\n}: Omit, \"size\" | \"type\"> &\n VariantProps & {\n type?: \"button\" | \"submit\" | \"reset\";\n }) {\n return (\n \n );\n}\n\nfunction InputGroupText({ className, ...props }: React.ComponentProps<\"span\">) {\n return (\n \n );\n}\n\nfunction InputGroupInput({\n className,\n ...props\n}: React.ComponentProps<\"input\">) {\n return (\n \n );\n}\n\nfunction InputGroupTextarea({\n className,\n ...props\n}: React.ComponentProps<\"textarea\">) {\n return (\n \n );\n}\n\nexport {\n InputGroup,\n InputGroupAddon,\n InputGroupButton,\n InputGroupInput,\n InputGroupText,\n InputGroupTextarea,\n};\n", "path": "registry/pytah/editor/ui/input-group.tsx", "target": "src/components/ui/input-group.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { Popover as PopoverPrimitive } from \"@base-ui/react/popover\";\nimport type * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Popover({ ...props }: PopoverPrimitive.Root.Props) {\n return ;\n}\n\nfunction PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {\n return ;\n}\n\nfunction PopoverContent({\n className,\n align = \"center\",\n alignOffset = 0,\n side = \"bottom\",\n sideOffset = 4,\n ...props\n}: PopoverPrimitive.Popup.Props &\n Pick<\n PopoverPrimitive.Positioner.Props,\n \"align\" | \"alignOffset\" | \"side\" | \"sideOffset\"\n >) {\n return (\n \n \n \n \n \n );\n}\n\nfunction PopoverHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {\n return (\n \n );\n}\n\nfunction PopoverDescription({\n className,\n ...props\n}: PopoverPrimitive.Description.Props) {\n return (\n \n );\n}\n\nexport {\n Popover,\n PopoverContent,\n PopoverDescription,\n PopoverHeader,\n PopoverTitle,\n PopoverTrigger,\n};\n", "path": "registry/pytah/editor/ui/popover.tsx", "target": "src/components/ui/popover.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { Separator as SeparatorPrimitive } from \"@base-ui/react/separator\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Separator({\n className,\n orientation = \"horizontal\",\n ...props\n}: SeparatorPrimitive.Props) {\n return (\n \n );\n}\n\nexport { Separator };\n", "path": "registry/pytah/editor/ui/separator.tsx", "target": "src/components/ui/separator.tsx", "type": "registry:file" }, { "content": "import type * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Textarea({ className, ...props }: React.ComponentProps<\"textarea\">) {\n return (\n \n );\n}\n\nexport { Textarea };\n", "path": "registry/pytah/editor/ui/textarea.tsx", "target": "src/components/ui/textarea.tsx", "type": "registry:file" }, { "content": "\"use client\";\n\nimport { Toggle as TogglePrimitive } from \"@base-ui/react/toggle\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst toggleVariants = cva(\n \"group/toggle inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-lg font-medium text-sm outline-none transition-all hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-pressed:bg-muted aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n {\n variants: {\n variant: {\n default: \"bg-transparent\",\n outline: \"border border-input bg-transparent hover:bg-muted\",\n },\n size: {\n default: \"h-8 min-w-8 px-2\",\n sm: \"h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-1.5 text-[0.8rem]\",\n lg: \"h-9 min-w-9 px-2.5\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n);\n\nfunction Toggle({\n className,\n variant = \"default\",\n size = \"default\",\n ...props\n}: TogglePrimitive.Props & VariantProps) {\n return (\n \n );\n}\n\nexport { Toggle, toggleVariants };\n", "path": "registry/pytah/editor/ui/toggle.tsx", "target": "src/components/ui/toggle.tsx", "type": "registry:file" }, { "content": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n", "path": "registry/pytah/editor/lib/utils.ts", "target": "src/lib/utils.ts", "type": "registry:file" }, { "content": "{\n \"requirements\": {\n \"react\": \"19+\",\n \"tailwind\": \"4.x\",\n \"shadcn\": \"4.x\",\n \"lexical\": \"0.42.x\"\n },\n \"editorDependencies\": {\n \"baseUi\": \"@base-ui/react\",\n \"cmdk\": \"cmdk\",\n \"cva\": \"class-variance-authority\",\n \"lucide\": \"lucide-react\",\n \"tailwindMerge\": \"tailwind-merge\"\n },\n \"notes\": {\n \"alias\": \"The standard @/ alias must resolve to ./src.\",\n \"animate\": \"Your global Tailwind v4 stylesheet should import tw-animate-css.\",\n \"css\": \"The registry item injects the editor highlight tokens automatically.\"\n }\n}\n", "path": "registry/pytah/editor/components/editor/core/compatibility.json", "target": "src/components/editor/core/compatibility.json", "type": "registry:file" } ], "name": "editor", "title": "Pytah Editor", "type": "registry:block" }