diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/Editor/BlocksEditor/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/Editor/BlocksEditor/index.tsx
index 2c9afb7db..451868ffb 100644
--- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/Editor/BlocksEditor/index.tsx
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/Editor/BlocksEditor/index.tsx
@@ -1,13 +1,108 @@
import { memo, Suspense } from 'react'
-import { AstError } from '@latitude-data/constants/simpleBlocks'
+import { AstError, AnyBlock } from '@latitude-data/constants/simpleBlocks'
import { TextEditorPlaceholder } from '@latitude-data/web-ui/molecules/TextEditorPlaceholder'
-import { AnyBlock } from '@latitude-data/constants/simpleBlocks'
-import { CodeBlock } from '@latitude-data/web-ui/atoms/CodeBlock'
+import { BlocksEditor } from '@latitude-data/web-ui/molecules/BlocksEditor'
+
+// Example blocks to demonstrate the editor
+const exampleBlocks: AnyBlock[] = [
+ {
+ id: 'block_1',
+ type: 'text',
+ content: 'This is a simple text block with some content.',
+ },
+ {
+ id: 'block_2',
+ type: 'system',
+ children: [
+ {
+ id: 'msg_child_1',
+ type: 'text',
+ content: 'You are a helpful AI assistant. Always be polite and informative.',
+ },
+ ],
+ },
+ {
+ id: 'block_3',
+ type: 'user',
+ children: [
+ {
+ id: 'msg_child_2',
+ type: 'text',
+ content: 'Hello! Can you help me with a question?',
+ },
+ {
+ id: 'msg_child_3',
+ type: 'content-image',
+ content: 'screenshot.png',
+ },
+ ],
+ },
+ {
+ id: 'block_4',
+ type: 'assistant',
+ children: [
+ {
+ id: 'msg_child_4',
+ type: 'text',
+ content: 'Of course! I\'d be happy to help you. What\'s your question?',
+ },
+ ],
+ },
+ {
+ id: 'block_5',
+ type: 'step',
+ children: [
+ {
+ id: 'step_child_1',
+ type: 'user',
+ children: [
+ {
+ id: 'step_msg_1',
+ type: 'text',
+ content: 'Please analyze this data.',
+ },
+ {
+ id: 'step_msg_2',
+ type: 'content-file',
+ content: 'data.csv',
+ attributes: {
+ name: 'data.csv',
+ },
+ },
+ ],
+ },
+ {
+ id: 'step_child_2',
+ type: 'tool-call',
+ attributes: {
+ id: 'call_123',
+ name: 'analyze_data',
+ parameters: {
+ file: 'data.csv',
+ format: 'csv',
+ },
+ },
+ },
+ ],
+ attributes: {
+ as: 'data_analysis',
+ isolated: true,
+ },
+ },
+ {
+ id: 'block_6',
+ type: 'prompt',
+ attributes: {
+ path: 'prompts/summarize.md',
+ },
+ },
+]
export const PlaygroundBlocksEditor = memo(
({
value: _prompt,
blocks = [],
+ onChange,
}: {
compileErrors: AstError[] | undefined
blocks: AnyBlock[] | undefined
@@ -17,12 +112,36 @@ export const PlaygroundBlocksEditor = memo(
readOnlyMessage?: string
onChange: (value: string) => void
}) => {
- if (!blocks.length) return null
+ // const blocksToRender = blocks.length > 0 ? blocks : exampleBlocks
+
+ const handleBlocksChange = (_updatedBlocks: AnyBlock[]) => {
+ // Convert blocks back to string format if needed
+ // For now, we'll just stringify the blocks
+ // onChange(JSON.stringify(updatedBlocks, null, 2))
+ }
return (
}>
- Blocks Editor
- {JSON.stringify(blocks, null, 2)}
+
+
+ Blocks Editor Demo - {exampleBlocks.length} blocks loaded
+
+
+
+
+ Show raw blocks data
+
+
+ {JSON.stringify(exampleBlocks, null, 2)}
+
+
+
)
},
diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json
index abf7713bd..464385683 100644
--- a/packages/web-ui/package.json
+++ b/packages/web-ui/package.json
@@ -65,6 +65,7 @@
"./molecules/AnimatedDots": "./src/ds/molecules/AnimatedDots/index.tsx",
"./molecules/BlankSlate": "./src/ds/molecules/BlankSlate/index.tsx",
"./molecules/BlankSlateWithSteps": "./src/ds/molecules/BlankSlateWithSteps/index.tsx",
+ "./molecules/BlocksEditor": "./src/ds/molecules/BlocksEditor/index.tsx",
"./molecules/Breadcrumb": "./src/ds/molecules/Breadcrumb/index.tsx",
"./molecules/BreadcrumbBadge": "./src/ds/molecules/BreadcrumbBadge/index.tsx",
"./molecules/ButtonWithBadge": "./src/ds/molecules/ButtonWithBadge/index.tsx",
@@ -125,6 +126,15 @@
"@latitude-data/compiler": "workspace:^",
"@latitude-data/constants": "workspace:^",
"@latitude-data/core": "workspace:^",
+ "@lexical/react": "^0.32.1",
+ "@lexical/rich-text": "^0.32.1",
+ "@lexical/list": "^0.32.1",
+ "@lexical/link": "^0.32.1",
+ "@lexical/code": "^0.32.1",
+ "@lexical/table": "^0.32.1",
+ "@lexical/markdown": "^0.32.1",
+ "@lexical/selection": "^0.32.1",
+ "@lexical/utils": "^0.32.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
@@ -146,6 +156,7 @@
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"hsl-to-hex": "^1.0.0",
+ "lexical": "^0.32.1",
"lodash-es": "^4.17.21",
"lucide-react": "^0.468.0",
"monaco-editor": "^0.50.0",
diff --git a/packages/web-ui/src/ds/atoms/Icons/index.tsx b/packages/web-ui/src/ds/atoms/Icons/index.tsx
index c1cb3391e..9f1efb6e5 100644
--- a/packages/web-ui/src/ds/atoms/Icons/index.tsx
+++ b/packages/web-ui/src/ds/atoms/Icons/index.tsx
@@ -87,6 +87,7 @@ import {
Pin,
PinOff,
Play,
+ PlusIcon,
Puzzle,
RefreshCcw,
RegexIcon,
@@ -323,6 +324,7 @@ const Icons = {
youtube: Youtube,
space: Space,
blend: Blend,
+ plus: PlusIcon,
exa: Exa,
yepcode: YepCode,
monday: Monday,
diff --git a/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/components/BlocksToolbar.tsx b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/components/BlocksToolbar.tsx
new file mode 100644
index 000000000..680c29cd2
--- /dev/null
+++ b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/components/BlocksToolbar.tsx
@@ -0,0 +1,66 @@
+import React from 'react'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import {
+ INSERT_MESSAGE_BLOCK_COMMAND,
+ INSERT_STEP_BLOCK_COMMAND,
+} from '../plugins/BlocksPlugin'
+import { MessageRole } from '../nodes/CustomBlocks'
+
+interface BlocksToolbarProps {
+ className?: string
+}
+
+export function BlocksToolbar({ className = '' }: BlocksToolbarProps) {
+ const [editor] = useLexicalComposerContext()
+
+ const insertMessageBlock = (role: MessageRole) => {
+ editor.dispatchCommand(INSERT_MESSAGE_BLOCK_COMMAND, role)
+ }
+
+ const insertStepBlock = () => {
+ // Generate a short random name like Step_abc123
+ const randomId = crypto.randomUUID().slice(0, 8)
+ const stepName = `Step_${randomId}`
+ editor.dispatchCommand(INSERT_STEP_BLOCK_COMMAND, stepName)
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/index.tsx b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/index.tsx
new file mode 100644
index 000000000..e821ffda1
--- /dev/null
+++ b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/index.tsx
@@ -0,0 +1,255 @@
+import React, { useCallback, useState } from 'react'
+import { LexicalComposer } from '@lexical/react/LexicalComposer'
+import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
+import { ContentEditable } from '@lexical/react/LexicalContentEditable'
+import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
+import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'
+import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
+import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+// Core Lexical
+import { $getRoot, EditorState, $isParagraphNode } from 'lexical'
+
+import { cn } from '../../../../lib/utils'
+import { BlocksEditorProps } from '../types'
+import {
+ MessageBlockNode,
+ StepBlockNode,
+ $isMessageBlockNode,
+ $isStepBlockNode,
+} from './nodes/CustomBlocks'
+
+import {
+ BlocksPlugin,
+ InitializeBlocksPlugin,
+ HierarchyValidationPlugin,
+} from './plugins/BlocksPlugin'
+import { EnterKeyPlugin } from './plugins/EnterKeyPlugin'
+import { BlockClickPlugin } from './plugins/BlockClickPlugin'
+import { DraggableBlockPlugin } from './plugins/DraggableBlockPlugin'
+import { BlocksToolbar } from './components/BlocksToolbar'
+import { AnyBlock } from '@latitude-data/constants/simpleBlocks'
+
+const theme = {
+ ltr: 'ltr',
+ rtl: 'rtl',
+ paragraph: 'mb-1 text-sm leading-relaxed',
+ text: {
+ bold: 'font-bold',
+ italic: 'italic',
+ strikethrough: 'line-through',
+ subscript: 'text-xs align-sub',
+ superscript: 'text-xs align-super',
+ underline: 'underline',
+ underlineStrikethrough: 'underline line-through',
+ },
+}
+
+function OnChangeHandler({
+ onChange,
+ onBlocksChange,
+}: {
+ onChange?: (content: string) => void
+ onBlocksChange?: (blocks: AnyBlock[]) => void
+}) {
+ const [_editor] = useLexicalComposerContext()
+
+ const handleChange = useCallback(
+ (editorState: EditorState) => {
+ editorState.read(() => {
+ if (onChange) {
+ const root = $getRoot()
+ const textContent = root.getTextContent()
+ onChange(textContent)
+ }
+
+ // Move to a separate function to handle blocks conversion
+ // This can be tested
+ if (onBlocksChange) {
+ const root = $getRoot()
+ const blocks: AnyBlock[] = []
+
+ // Convert Lexical nodes to AnyBlock[] format
+ root.getChildren().forEach((node) => {
+ if ($isMessageBlockNode(node)) {
+ // Message block
+ const messageBlock: AnyBlock = {
+ id: crypto.randomUUID(),
+ type: node.getRole(),
+ children: [],
+ }
+
+ // Get message children
+ node.getChildren().forEach((child: any) => {
+ if ($isParagraphNode(child)) {
+ const textContent = child.getTextContent()
+ if (textContent.trim()) {
+ messageBlock.children.push({
+ id: crypto.randomUUID(),
+ type: 'text',
+ content: textContent,
+ })
+ }
+ }
+ })
+
+ blocks.push(messageBlock)
+ } else if ($isStepBlockNode(node)) {
+ // Step block
+ const stepBlock: AnyBlock = {
+ id: crypto.randomUUID(),
+ type: 'step',
+ children: [],
+ attributes: {
+ as: node.getStepName(),
+ },
+ }
+
+ // Get step children
+ node.getChildren().forEach((child: any) => {
+ if ($isMessageBlockNode(child)) {
+ const messageBlock: any = {
+ id: crypto.randomUUID(),
+ type: child.getRole(),
+ children: [],
+ }
+
+ // Get message children
+ child.getChildren().forEach((grandchild: any) => {
+ if ($isParagraphNode(grandchild)) {
+ const textContent = grandchild.getTextContent()
+ if (textContent.trim()) {
+ messageBlock.children.push({
+ id: crypto.randomUUID(),
+ type: 'text',
+ content: textContent,
+ })
+ }
+ }
+ })
+
+ if (messageBlock.children.length > 0) {
+ stepBlock.children?.push(messageBlock)
+ }
+ } else if ($isParagraphNode(child)) {
+ const textContent = child.getTextContent()
+ if (textContent.trim()) {
+ stepBlock.children?.push({
+ id: crypto.randomUUID(),
+ type: 'text',
+ content: textContent,
+ })
+ }
+ }
+ })
+
+ blocks.push(stepBlock)
+ } else if ($isParagraphNode(node)) {
+ // Regular paragraph node at root level
+ const textContent = node.getTextContent()
+ if (textContent.trim()) {
+ blocks.push({
+ id: crypto.randomUUID(),
+ type: 'text',
+ content: textContent,
+ })
+ }
+ }
+ })
+
+ onBlocksChange(blocks)
+ }
+ })
+ },
+ [onChange, onBlocksChange],
+ )
+
+ return
+}
+
+export function BlocksEditor({
+ placeholder = 'Start typing...',
+ initialValue = [],
+ onChange,
+ onBlocksChange,
+ className,
+ readOnly = false,
+ autoFocus = false,
+}: BlocksEditorProps) {
+ const [floatingAnchorElem, setFloatingAnchorElem] =
+ useState(null)
+
+ const onRef = useCallback((floatingAnchorElem: HTMLDivElement) => {
+ if (floatingAnchorElem !== null) {
+ setFloatingAnchorElem(floatingAnchorElem)
+ }
+ }, [])
+
+ const initialConfig = {
+ namespace: 'BlocksEditor',
+ theme,
+ onError: (error: Error) => {
+ console.error('Editor error:', error)
+ },
+ editable: !readOnly,
+ nodes: [MessageBlockNode, StepBlockNode],
+ }
+
+ return (
+
+
+ {/* Toolbar */}
+ {!readOnly && }
+
+
+
+ {placeholder}
+
+ }
+ />
+ }
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+
+ {/* Core Plugins */}
+
+
+
+
+
+
+
+ {/* Drag and Drop Plugin */}
+ {!readOnly && floatingAnchorElem && (
+
+ )}
+
+ {/* Auto focus */}
+ {autoFocus && }
+
+ {/* Change handler */}
+
+
+
+
+ )
+}
diff --git a/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/nodes/CustomBlocks.tsx b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/nodes/CustomBlocks.tsx
new file mode 100644
index 000000000..ae6abfc4c
--- /dev/null
+++ b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/nodes/CustomBlocks.tsx
@@ -0,0 +1,415 @@
+import {
+ ElementNode,
+ NodeKey,
+ LexicalNode,
+ SerializedElementNode,
+ $applyNodeReplacement,
+ $createParagraphNode,
+ $createTextNode,
+ EditorConfig,
+} from 'lexical'
+
+// Define our simplified block types
+export type BlockType = 'paragraph' | 'message' | 'step'
+export type MessageRole = 'system' | 'user' | 'assistant' | 'developer'
+
+// Base serialized block interface
+interface SerializedBlockNode extends SerializedElementNode {
+ blockType: BlockType
+ role?: MessageRole
+ stepName?: string
+}
+
+// Paragraph Block - Plain paragraphs of text
+export class ParagraphBlockNode extends ElementNode {
+ static getType(): string {
+ return 'paragraph-block'
+ }
+
+ static clone(node: ParagraphBlockNode): ParagraphBlockNode {
+ return new ParagraphBlockNode(node.__key)
+ }
+
+ constructor(key?: NodeKey) {
+ super(key)
+ }
+
+ createDOM(_config: EditorConfig): HTMLElement {
+ const div = document.createElement('div')
+ div.className = 'paragraph-block mb-4 p-4 border border-gray-200 rounded-lg bg-white'
+ return div
+ }
+
+ updateDOM(): boolean {
+ return false
+ }
+
+ static importJSON(_serializedNode: SerializedBlockNode): ParagraphBlockNode {
+ return new ParagraphBlockNode()
+ }
+
+ exportJSON(): SerializedBlockNode {
+ return {
+ ...super.exportJSON(),
+ type: 'paragraph-block',
+ blockType: 'paragraph',
+ }
+ }
+
+ canBeEmpty(): boolean {
+ return false
+ }
+
+ isInline(): boolean {
+ return false
+ }
+
+ // Hierarchy enforcement: Paragraph blocks can only contain regular Lexical nodes (no custom blocks)
+ canInsertChild(child: LexicalNode): boolean {
+ // Don't allow any custom block types as children
+ return !$isParagraphBlockNode(child) && !$isMessageBlockNode(child) && !$isStepBlockNode(child)
+ }
+
+ canReplaceWith(_replacement: LexicalNode): boolean {
+ // Can be replaced with any other block type
+ return true
+ }
+
+ canMergeWith(node: LexicalNode): boolean {
+ // Can only merge with other paragraph blocks
+ return $isParagraphBlockNode(node)
+ }
+
+ canInsertTextBefore(): boolean {
+ return false
+ }
+
+ canInsertTextAfter(): boolean {
+ return false
+ }
+}
+
+// Message Block - Can hold paragraphs, has a role
+export class MessageBlockNode extends ElementNode {
+ __role: MessageRole
+
+ static getType(): string {
+ return 'message-block'
+ }
+
+ static clone(node: MessageBlockNode): MessageBlockNode {
+ return new MessageBlockNode(node.__role, node.__key)
+ }
+
+ constructor(role: MessageRole = 'user', key?: NodeKey) {
+ super(key)
+ this.__role = role
+ }
+
+ createDOM(_config: EditorConfig): HTMLElement {
+ const div = document.createElement('div')
+ const roleColors = {
+ system: 'bg-purple-50 border-purple-300',
+ user: 'bg-blue-50 border-blue-300',
+ assistant: 'bg-green-50 border-green-300',
+ developer: 'bg-orange-50 border-orange-300',
+ }
+
+ div.className = `message-block mb-4 p-4 border-2 rounded-lg ${roleColors[this.__role]}`
+
+ // Add data attributes for click handling
+ div.setAttribute('data-block-type', 'message-block')
+ div.setAttribute('data-lexical-key', this.__key)
+
+ // Add role indicator
+ const roleLabel = document.createElement('div')
+ roleLabel.className = 'message-role text-xs font-semibold text-gray-600 mb-2 uppercase'
+ roleLabel.textContent = `${this.__role} message`
+ roleLabel.contentEditable = 'false'
+ div.appendChild(roleLabel)
+
+ return div
+ }
+
+ updateDOM(prevNode: this, dom: HTMLElement): boolean {
+ if (prevNode.__role !== this.__role) {
+ const roleColors = {
+ system: 'bg-purple-50 border-purple-300',
+ user: 'bg-blue-50 border-blue-300',
+ assistant: 'bg-green-50 border-green-300',
+ developer: 'bg-orange-50 border-orange-300',
+ }
+
+ dom.className = `message-block mb-4 p-4 border-2 rounded-lg ${roleColors[this.__role]}`
+
+ const roleLabel = dom.querySelector('.message-role')
+ if (roleLabel) {
+ roleLabel.textContent = `${this.__role} message`
+ }
+ return true
+ }
+ return false
+ }
+
+ getRole(): MessageRole {
+ return this.getLatest().__role
+ }
+
+ setRole(role: MessageRole): this {
+ const writable = this.getWritable()
+ writable.__role = role
+ return writable
+ }
+
+ static importJSON(serializedNode: SerializedBlockNode): MessageBlockNode {
+ const { role } = serializedNode
+ return new MessageBlockNode(role || 'user')
+ }
+
+ exportJSON(): SerializedBlockNode {
+ return {
+ ...super.exportJSON(),
+ type: 'message-block',
+ blockType: 'message',
+ role: this.__role,
+ }
+ }
+
+ canBeEmpty(): boolean {
+ return false
+ }
+
+ isInline(): boolean {
+ return false
+ }
+
+ // Hierarchy enforcement: Message blocks can only contain paragraphs and regular nodes (no other messages or steps)
+ canInsertChild(child: LexicalNode): boolean {
+ // Allow paragraph blocks and regular Lexical nodes, but not message blocks or step blocks
+ return !$isMessageBlockNode(child) && !$isStepBlockNode(child)
+ }
+
+ canReplaceWith(_replacement: LexicalNode): boolean {
+ // Can be replaced with any other block type
+ return true
+ }
+
+ canMergeWith(node: LexicalNode): boolean {
+ // Can only merge with other message blocks of the same role
+ return $isMessageBlockNode(node) && node.getRole() === this.getRole()
+ }
+
+ canInsertTextBefore(): boolean {
+ return false
+ }
+
+ canInsertTextAfter(): boolean {
+ return false
+ }
+}
+
+// Step Block - Can hold messages and paragraphs, has a name
+export class StepBlockNode extends ElementNode {
+ __stepName: string
+
+ static getType(): string {
+ return 'step-block'
+ }
+
+ static clone(node: StepBlockNode): StepBlockNode {
+ return new StepBlockNode(node.__stepName, node.__key)
+ }
+
+ constructor(stepName: string = 'Step', key?: NodeKey) {
+ super(key)
+ this.__stepName = stepName
+ }
+
+ createDOM(_config: EditorConfig): HTMLElement {
+ const div = document.createElement('div')
+ div.className = 'step-block mb-6 p-4 border-2 border-indigo-400 rounded-lg bg-indigo-50'
+
+ // Add data attributes for click handling
+ div.setAttribute('data-block-type', 'step-block')
+ div.setAttribute('data-lexical-key', this.__key)
+
+ // Add step label (editable)
+ const stepLabel = document.createElement('div')
+ stepLabel.className = 'step-header text-sm font-bold text-indigo-800 mb-3 px-2 py-1 rounded hover:bg-indigo-100 cursor-text border border-transparent hover:border-indigo-300 transition-colors'
+ stepLabel.contentEditable = 'true'
+ stepLabel.setAttribute('data-step-header', 'true')
+ stepLabel.setAttribute('spellcheck', 'false')
+ stepLabel.innerHTML = `📋 ${this.__stepName}`
+
+ // Handle editing events
+ stepLabel.addEventListener('focus', (e) => {
+ e.stopPropagation()
+ // Select just the text part, not the emoji
+ const nameSpan = stepLabel.querySelector('.editable-step-name')
+ if (nameSpan) {
+ const range = document.createRange()
+ range.selectNodeContents(nameSpan)
+ const selection = window.getSelection()
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ }
+ })
+
+ stepLabel.addEventListener('blur', (e) => {
+ e.stopPropagation()
+ // Extract the text content and update the step name
+ const nameSpan = stepLabel.querySelector('.editable-step-name')
+ if (nameSpan) {
+ const newName = nameSpan.textContent?.trim() || this.__stepName
+ if (newName.length === 0) {
+ // Revert to original name if empty
+ stepLabel.innerHTML = `📋 ${this.__stepName}`
+ } else if (newName !== this.__stepName) {
+ // Update the step name in the Lexical node
+ this.setStepName(newName)
+ }
+ }
+ })
+
+ stepLabel.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ stepLabel.blur()
+ }
+ // Prevent other keyboard events from bubbling to Lexical
+ e.stopPropagation()
+ })
+
+ stepLabel.addEventListener('input', (e) => {
+ e.stopPropagation()
+ // Ensure we maintain the emoji and structure, and prevent empty text
+ const text = stepLabel.textContent?.replace('📋 ', '').trim() || this.__stepName
+ if (text.length === 0) {
+ // Prevent deletion of all text, revert to current step name
+ stepLabel.innerHTML = `📋 ${this.__stepName}`
+ } else {
+ stepLabel.innerHTML = `📋 ${text}`
+ }
+
+ // Keep cursor at the end of the span
+ const nameSpan = stepLabel.querySelector('.editable-step-name')
+ if (nameSpan) {
+ const range = document.createRange()
+ range.selectNodeContents(nameSpan)
+ range.collapse(false)
+ const selection = window.getSelection()
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ }
+ })
+
+ div.appendChild(stepLabel)
+
+ return div
+ }
+
+ updateDOM(prevNode: this, dom: HTMLElement): boolean {
+ if (prevNode.__stepName !== this.__stepName) {
+ const stepLabel = dom.querySelector('.step-header')
+ if (stepLabel) {
+ stepLabel.innerHTML = `📋 ${this.__stepName}`
+ }
+ return true
+ }
+ return false
+ }
+
+ getStepName(): string {
+ return this.getLatest().__stepName
+ }
+
+ setStepName(stepName: string): this {
+ const writable = this.getWritable()
+ writable.__stepName = stepName
+ return writable
+ }
+
+ static importJSON(serializedNode: SerializedBlockNode): StepBlockNode {
+ const { stepName } = serializedNode
+ return new StepBlockNode(stepName || 'Step')
+ }
+
+ exportJSON(): SerializedBlockNode {
+ return {
+ ...super.exportJSON(),
+ type: 'step-block',
+ blockType: 'step',
+ stepName: this.__stepName,
+ }
+ }
+
+ canBeEmpty(): boolean {
+ return false
+ }
+
+ isInline(): boolean {
+ return false
+ }
+
+ // Hierarchy enforcement: Step blocks can contain everything except other step blocks
+ canInsertChild(child: LexicalNode): boolean {
+ // Allow everything except other step blocks
+ return !$isStepBlockNode(child)
+ }
+
+ canReplaceWith(_replacement: LexicalNode): boolean {
+ // Can be replaced with any other block type
+ return true
+ }
+
+ canMergeWith(node: LexicalNode): boolean {
+ // Can only merge with other step blocks
+ return $isStepBlockNode(node)
+ }
+
+ canInsertTextBefore(): boolean {
+ return false
+ }
+
+ canInsertTextAfter(): boolean {
+ return false
+ }
+}
+
+// Helper functions for creating nodes
+export function $createParagraphBlockNode(): ParagraphBlockNode {
+ const block = new ParagraphBlockNode()
+ const paragraph = $createParagraphNode()
+ paragraph.append($createTextNode(''))
+ block.append(paragraph)
+ return $applyNodeReplacement(block)
+}
+
+export function $createMessageBlockNode(role: MessageRole = 'user'): MessageBlockNode {
+ const block = new MessageBlockNode(role)
+ const paragraph = $createParagraphNode()
+ paragraph.append($createTextNode(''))
+ block.append(paragraph)
+ return $applyNodeReplacement(block)
+}
+
+export function $createStepBlockNode(stepName: string = 'Step'): StepBlockNode {
+ const block = new StepBlockNode(stepName)
+ const paragraph = $createParagraphNode()
+ paragraph.append($createTextNode(''))
+ block.append(paragraph)
+ return $applyNodeReplacement(block)
+}
+
+// Type guards
+export function $isParagraphBlockNode(node: LexicalNode | null | undefined): node is ParagraphBlockNode {
+ return node instanceof ParagraphBlockNode
+}
+
+export function $isMessageBlockNode(node: LexicalNode | null | undefined): node is MessageBlockNode {
+ return node instanceof MessageBlockNode
+}
+
+export function $isStepBlockNode(node: LexicalNode | null | undefined): node is StepBlockNode {
+ return node instanceof StepBlockNode
+}
diff --git a/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/overrides/plugins/DraggableBlockPlugin.tsx b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/overrides/plugins/DraggableBlockPlugin.tsx
new file mode 100644
index 000000000..7ad95421d
--- /dev/null
+++ b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/overrides/plugins/DraggableBlockPlugin.tsx
@@ -0,0 +1,481 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type {JSX} from 'react';
+
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {eventFiles} from '@lexical/rich-text';
+import {calculateZoomLevel, isHTMLElement, mergeRegister} from '@lexical/utils';
+import {
+ $getNearestNodeFromDOMNode,
+ $getNodeByKey,
+ $getRoot,
+ COMMAND_PRIORITY_HIGH,
+ COMMAND_PRIORITY_LOW,
+ DRAGOVER_COMMAND,
+ DROP_COMMAND,
+ LexicalEditor,
+} from 'lexical';
+import React, {
+ DragEvent as ReactDragEvent,
+ ReactNode,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import {createPortal} from 'react-dom';
+
+import {Point} from './shared/point';
+import {Rectangle} from './shared/rect';
+
+const SPACE = 4;
+const TARGET_LINE_HALF_HEIGHT = 2;
+const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block';
+const TEXT_BOX_HORIZONTAL_PADDING = 28;
+
+const Downward = 1;
+const Upward = -1;
+const Indeterminate = 0;
+
+let prevIndex = Infinity;
+
+function getCurrentIndex(keysLength: number): number {
+ if (keysLength === 0) {
+ return Infinity;
+ }
+ if (prevIndex >= 0 && prevIndex < keysLength) {
+ return prevIndex;
+ }
+
+ return Math.floor(keysLength / 2);
+}
+
+function getTopLevelNodeKeys(editor: LexicalEditor): string[] {
+ return editor.getEditorState().read(() => $getRoot().getChildrenKeys());
+}
+
+function getCollapsedMargins(elem: HTMLElement): {
+ marginTop: number;
+ marginBottom: number;
+} {
+ const getMargin = (
+ element: Element | null,
+ margin: 'marginTop' | 'marginBottom',
+ ): number =>
+ element ? parseFloat(window.getComputedStyle(element)[margin]) : 0;
+
+ const {marginTop, marginBottom} = window.getComputedStyle(elem);
+ const prevElemSiblingMarginBottom = getMargin(
+ elem.previousElementSibling,
+ 'marginBottom',
+ );
+ const nextElemSiblingMarginTop = getMargin(
+ elem.nextElementSibling,
+ 'marginTop',
+ );
+ const collapsedTopMargin = Math.max(
+ parseFloat(marginTop),
+ prevElemSiblingMarginBottom,
+ );
+ const collapsedBottomMargin = Math.max(
+ parseFloat(marginBottom),
+ nextElemSiblingMarginTop,
+ );
+
+ return {marginBottom: collapsedBottomMargin, marginTop: collapsedTopMargin};
+}
+
+function getBlockElement(
+ anchorElem: HTMLElement,
+ editor: LexicalEditor,
+ event: MouseEvent,
+ useEdgeAsDefault = false,
+): HTMLElement | null {
+ const anchorElementRect = anchorElem.getBoundingClientRect();
+ const topLevelNodeKeys = getTopLevelNodeKeys(editor);
+
+ let blockElem: HTMLElement | null = null;
+
+ editor.getEditorState().read(() => {
+ if (useEdgeAsDefault) {
+ const [firstNode, lastNode] = [
+ topLevelNodeKeys[0] ? editor.getElementByKey(topLevelNodeKeys[0]) : null,
+ topLevelNodeKeys.length > 0 ?
+ editor.getElementByKey(topLevelNodeKeys[topLevelNodeKeys.length - 1]!) : null,
+ ];
+
+ const [firstNodeRect, lastNodeRect] = [
+ firstNode != null ? firstNode.getBoundingClientRect() : undefined,
+ lastNode != null ? lastNode.getBoundingClientRect() : undefined,
+ ];
+
+ if (firstNodeRect && lastNodeRect) {
+ const firstNodeZoom = calculateZoomLevel(firstNode);
+ const lastNodeZoom = calculateZoomLevel(lastNode);
+ if (event.y / firstNodeZoom < firstNodeRect.top) {
+ blockElem = firstNode;
+ } else if (event.y / lastNodeZoom > lastNodeRect.bottom) {
+ blockElem = lastNode;
+ }
+
+ if (blockElem) {
+ return;
+ }
+ }
+ }
+
+ let index = getCurrentIndex(topLevelNodeKeys.length);
+ let direction = Indeterminate;
+
+ while (index >= 0 && index < topLevelNodeKeys.length) {
+ const key = topLevelNodeKeys[index];
+ if (!key) break;
+ const elem = editor.getElementByKey(key);
+ if (elem === null) {
+ break;
+ }
+ const zoom = calculateZoomLevel(elem);
+ const point = new Point(event.x / zoom, event.y / zoom);
+ const domRect = Rectangle.fromDOM(elem);
+ const {marginTop, marginBottom} = getCollapsedMargins(elem);
+ const rect = domRect.generateNewRect({
+ bottom: domRect.bottom + marginBottom,
+ left: anchorElementRect.left,
+ right: anchorElementRect.right,
+ top: domRect.top - marginTop,
+ });
+
+ const {
+ result,
+ reason: {isOnTopSide, isOnBottomSide},
+ } = rect.contains(point);
+
+ if (result) {
+ blockElem = elem;
+ prevIndex = index;
+ break;
+ }
+
+ if (direction === Indeterminate) {
+ if (isOnTopSide) {
+ direction = Upward;
+ } else if (isOnBottomSide) {
+ direction = Downward;
+ } else {
+ // stop search block element
+ direction = Infinity;
+ }
+ }
+
+ index += direction;
+ }
+ });
+
+ return blockElem;
+}
+
+function setMenuPosition(
+ targetElem: HTMLElement | null,
+ floatingElem: HTMLElement,
+ anchorElem: HTMLElement,
+) {
+ if (!targetElem) {
+ floatingElem.style.opacity = '0';
+ floatingElem.style.transform = 'translate(-10000px, -10000px)';
+ return;
+ }
+
+ const targetRect = targetElem.getBoundingClientRect();
+ const targetStyle = window.getComputedStyle(targetElem);
+ const floatingElemRect = floatingElem.getBoundingClientRect();
+ const anchorElementRect = anchorElem.getBoundingClientRect();
+
+ // top left
+ let targetCalculateHeight: number = parseInt(targetStyle.lineHeight, 10);
+ if (isNaN(targetCalculateHeight)) {
+ // middle
+ targetCalculateHeight = targetRect.bottom - targetRect.top;
+ }
+ const top =
+ targetRect.top +
+ (targetCalculateHeight - floatingElemRect.height) / 2 -
+ anchorElementRect.top;
+
+ const left = SPACE;
+
+ floatingElem.style.opacity = '1';
+ floatingElem.style.transform = `translate(${left}px, ${top}px)`;
+}
+
+function setDragImage(
+ dataTransfer: DataTransfer,
+ draggableBlockElem: HTMLElement,
+) {
+ const {transform} = draggableBlockElem.style;
+
+ // Remove dragImage borders
+ draggableBlockElem.style.transform = 'translateZ(0)';
+ dataTransfer.setDragImage(draggableBlockElem, 0, 0);
+
+ setTimeout(() => {
+ draggableBlockElem.style.transform = transform;
+ });
+}
+
+function setTargetLine(
+ targetLineElem: HTMLElement,
+ targetBlockElem: HTMLElement,
+ mouseY: number,
+ anchorElem: HTMLElement,
+) {
+ const {top: targetBlockElemTop, height: targetBlockElemHeight} =
+ targetBlockElem.getBoundingClientRect();
+ const {top: anchorTop, width: anchorWidth} =
+ anchorElem.getBoundingClientRect();
+ const {marginTop, marginBottom} = getCollapsedMargins(targetBlockElem);
+ let lineTop = targetBlockElemTop;
+ if (mouseY >= targetBlockElemTop) {
+ lineTop += targetBlockElemHeight + marginBottom / 2;
+ } else {
+ lineTop -= marginTop / 2;
+ }
+
+ const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT;
+ const left = TEXT_BOX_HORIZONTAL_PADDING - SPACE;
+
+ targetLineElem.style.transform = `translate(${left}px, ${top}px)`;
+ targetLineElem.style.width = `${
+ anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - SPACE) * 2
+ }px`;
+ targetLineElem.style.opacity = '.4';
+}
+
+function hideTargetLine(targetLineElem: HTMLElement | null) {
+ if (targetLineElem) {
+ targetLineElem.style.opacity = '0';
+ targetLineElem.style.transform = 'translate(-10000px, -10000px)';
+ }
+}
+
+function useDraggableBlockMenu(
+ editor: LexicalEditor,
+ anchorElem: HTMLElement,
+ menuRef: React.RefObject,
+ targetLineRef: React.RefObject,
+ isEditable: boolean,
+ menuComponent: ReactNode,
+ targetLineComponent: ReactNode,
+ isOnMenu: (element: HTMLElement) => boolean,
+ onElementChanged?: (element: HTMLElement | null) => void,
+): JSX.Element {
+ const scrollerElem = anchorElem.parentElement;
+
+ const isDraggingBlockRef = useRef(false);
+ const [draggableBlockElem, setDraggableBlockElemState] =
+ useState(null);
+
+ const setDraggableBlockElem = useCallback(
+ (elem: HTMLElement | null) => {
+ setDraggableBlockElemState(elem);
+ if (onElementChanged) {
+ onElementChanged(elem);
+ }
+ },
+ [onElementChanged],
+ );
+
+ useEffect(() => {
+ function onMouseMove(event: MouseEvent) {
+ const target = event.target;
+ if (!isHTMLElement(target)) {
+ setDraggableBlockElem(null);
+ return;
+ }
+
+ if (isOnMenu(target as HTMLElement)) {
+ return;
+ }
+
+ const _draggableBlockElem = getBlockElement(anchorElem, editor, event);
+
+ setDraggableBlockElem(_draggableBlockElem);
+ }
+
+ function onMouseLeave() {
+ setDraggableBlockElem(null);
+ }
+
+ if (scrollerElem != null) {
+ scrollerElem.addEventListener('mousemove', onMouseMove);
+ scrollerElem.addEventListener('mouseleave', onMouseLeave);
+ }
+
+ return () => {
+ if (scrollerElem != null) {
+ scrollerElem.removeEventListener('mousemove', onMouseMove);
+ scrollerElem.removeEventListener('mouseleave', onMouseLeave);
+ }
+ };
+ }, [scrollerElem, anchorElem, editor, isOnMenu, setDraggableBlockElem]);
+
+ useEffect(() => {
+ if (menuRef.current) {
+ setMenuPosition(draggableBlockElem, menuRef.current, anchorElem);
+ }
+ }, [anchorElem, draggableBlockElem, menuRef]);
+
+ useEffect(() => {
+ function onDragover(event: DragEvent): boolean {
+ if (!isDraggingBlockRef.current) {
+ return false;
+ }
+ const [isFileTransfer] = eventFiles(event);
+ if (isFileTransfer) {
+ return false;
+ }
+ const {pageY, target} = event;
+ if (!isHTMLElement(target)) {
+ return false;
+ }
+ const targetBlockElem = getBlockElement(anchorElem, editor, event, true);
+ const targetLineElem = targetLineRef.current;
+ if (targetBlockElem === null || targetLineElem === null) {
+ return false;
+ }
+ setTargetLine(
+ targetLineElem,
+ targetBlockElem,
+ pageY / calculateZoomLevel(target),
+ anchorElem,
+ );
+ // Prevent default event to be able to trigger onDrop events
+ event.preventDefault();
+ return true;
+ }
+
+ function $onDrop(event: DragEvent): boolean {
+ if (!isDraggingBlockRef.current) {
+ return false;
+ }
+ const [isFileTransfer] = eventFiles(event);
+ if (isFileTransfer) {
+ return false;
+ }
+ const {target, dataTransfer, pageY} = event;
+ const dragData =
+ dataTransfer != null ? dataTransfer.getData(DRAG_DATA_FORMAT) : '';
+ const draggedNode = $getNodeByKey(dragData);
+ if (!draggedNode) {
+ return false;
+ }
+ if (!isHTMLElement(target)) {
+ return false;
+ }
+ const targetBlockElem = getBlockElement(anchorElem, editor, event, true);
+ if (!targetBlockElem) {
+ return false;
+ }
+ const targetNode = $getNearestNodeFromDOMNode(targetBlockElem);
+ if (!targetNode) {
+ return false;
+ }
+ if (targetNode === draggedNode) {
+ return true;
+ }
+ const targetBlockElemTop = targetBlockElem.getBoundingClientRect().top;
+ if (pageY / calculateZoomLevel(target) >= targetBlockElemTop) {
+ targetNode.insertAfter(draggedNode);
+ } else {
+ targetNode.insertBefore(draggedNode);
+ }
+ setDraggableBlockElem(null);
+
+ return true;
+ }
+
+ return mergeRegister(
+ editor.registerCommand(
+ DRAGOVER_COMMAND,
+ (event) => {
+ return onDragover(event);
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+ editor.registerCommand(
+ DROP_COMMAND,
+ (event) => {
+ return $onDrop(event);
+ },
+ COMMAND_PRIORITY_HIGH,
+ ),
+ );
+ }, [anchorElem, editor, targetLineRef, setDraggableBlockElem]);
+
+ function onDragStart(event: ReactDragEvent): void {
+ const dataTransfer = event.dataTransfer;
+ if (!dataTransfer || !draggableBlockElem) {
+ return;
+ }
+ setDragImage(dataTransfer, draggableBlockElem);
+ let nodeKey = '';
+ editor.update(() => {
+ const node = $getNearestNodeFromDOMNode(draggableBlockElem);
+ if (node) {
+ nodeKey = node.getKey();
+ }
+ });
+ isDraggingBlockRef.current = true;
+ dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey);
+ }
+
+ function onDragEnd(): void {
+ isDraggingBlockRef.current = false;
+ hideTargetLine(targetLineRef.current);
+ }
+ return createPortal(
+ <>
+
+ {isEditable && menuComponent}
+
+ {targetLineComponent}
+ >,
+ anchorElem,
+ );
+}
+
+export function DraggableBlockPlugin_EXPERIMENTAL({
+ anchorElem = document.body,
+ menuRef,
+ targetLineRef,
+ menuComponent,
+ targetLineComponent,
+ isOnMenu,
+ onElementChanged,
+}: {
+ anchorElem?: HTMLElement;
+ menuRef: React.RefObject;
+ targetLineRef: React.RefObject;
+ menuComponent: ReactNode;
+ targetLineComponent: ReactNode;
+ isOnMenu: (element: HTMLElement) => boolean;
+ onElementChanged?: (element: HTMLElement | null) => void;
+}): JSX.Element {
+ const [editor] = useLexicalComposerContext();
+ return useDraggableBlockMenu(
+ editor,
+ anchorElem,
+ menuRef,
+ targetLineRef,
+ editor._editable,
+ menuComponent,
+ targetLineComponent,
+ isOnMenu,
+ onElementChanged,
+ );
+}
diff --git a/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/overrides/plugins/shared/point.ts b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/overrides/plugins/shared/point.ts
new file mode 100644
index 000000000..4af76ae68
--- /dev/null
+++ b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/overrides/plugins/shared/point.ts
@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export class Point {
+ private readonly _x: number;
+ private readonly _y: number;
+
+ constructor(x: number, y: number) {
+ this._x = x;
+ this._y = y;
+ }
+
+ get x(): number {
+ return this._x;
+ }
+
+ get y(): number {
+ return this._y;
+ }
+
+ public equals({x, y}: Point): boolean {
+ return this.x === x && this.y === y;
+ }
+
+ public calcDeltaXTo({x}: Point): number {
+ return this.x - x;
+ }
+
+ public calcDeltaYTo({y}: Point): number {
+ return this.y - y;
+ }
+
+ public calcHorizontalDistanceTo(point: Point): number {
+ return Math.abs(this.calcDeltaXTo(point));
+ }
+
+ public calcVerticalDistance(point: Point): number {
+ return Math.abs(this.calcDeltaYTo(point));
+ }
+
+ public calcDistanceTo(point: Point): number {
+ const deltaX = this.calcDeltaXTo(point);
+ const deltaY = this.calcDeltaYTo(point);
+ return Math.sqrt(deltaX * deltaX + deltaY * deltaY);
+ }
+}
diff --git a/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/overrides/plugins/shared/rect.ts b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/overrides/plugins/shared/rect.ts
new file mode 100644
index 000000000..c8da89fb7
--- /dev/null
+++ b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/overrides/plugins/shared/rect.ts
@@ -0,0 +1,132 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {Point} from './point';
+
+export class Rectangle {
+ private readonly _left: number;
+ private readonly _top: number;
+ private readonly _right: number;
+ private readonly _bottom: number;
+
+ constructor(left: number, top: number, right: number, bottom: number) {
+ this._left = left;
+ this._top = top;
+ this._right = right;
+ this._bottom = bottom;
+ }
+
+ get left(): number {
+ return this._left;
+ }
+
+ get top(): number {
+ return this._top;
+ }
+
+ get right(): number {
+ return this._right;
+ }
+
+ get bottom(): number {
+ return this._bottom;
+ }
+
+ get width(): number {
+ return Math.abs(this._left - this._right);
+ }
+
+ get height(): number {
+ return Math.abs(this._bottom - this._top);
+ }
+
+ public equals({left, top, right, bottom}: Rectangle): boolean {
+ return (
+ this.left === left &&
+ this.top === top &&
+ this.right === right &&
+ this.bottom === bottom
+ );
+ }
+
+ public contains({x, y}: Point): {
+ result: boolean;
+ reason: {
+ isOnBottomSide: boolean;
+ isOnLeftSide: boolean;
+ isOnRightSide: boolean;
+ isOnTopSide: boolean;
+ };
+ } {
+ const isOnTopSide = y < this.top;
+ const isOnBottomSide = y > this.bottom;
+ const isOnLeftSide = x < this.left;
+ const isOnRightSide = x > this.right;
+
+ const result =
+ !isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide;
+
+ return {
+ reason: {
+ isOnBottomSide,
+ isOnLeftSide,
+ isOnRightSide,
+ isOnTopSide,
+ },
+ result,
+ };
+ }
+
+ public intersectsWith(rect: Rectangle): boolean {
+ const {left: x1, top: y1, width: w1, height: h1} = rect;
+ const {left: x2, top: y2, width: w2, height: h2} = this;
+ const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2;
+ const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2;
+ const minX = x1 <= x2 ? x1 : x2;
+ const minY = y1 <= y2 ? y1 : y2;
+ return maxX - minX < w1 + w2 && maxY - minY < h1 + h2;
+ }
+
+ public generateNewRect({
+ left = this.left,
+ top = this.top,
+ right = this.right,
+ bottom = this.bottom,
+ }): Rectangle {
+ return new Rectangle(left, top, right, bottom);
+ }
+
+ static fromLTRB(
+ left: number,
+ top: number,
+ right: number,
+ bottom: number,
+ ): Rectangle {
+ return new Rectangle(left, top, right, bottom);
+ }
+
+ static fromLTWH(
+ left: number,
+ top: number,
+ width: number,
+ height: number,
+ ): Rectangle {
+ return new Rectangle(left, top, left + width, top + height);
+ }
+
+ static fromPoints(startPoint: Point, endPoint: Point): Rectangle {
+ const {x: left, y: top} = startPoint;
+ const {x: right, y: bottom} = endPoint;
+ return Rectangle.fromLTRB(left, top, right, bottom);
+ }
+
+ static fromDOM(dom: HTMLElement): Rectangle {
+ const {left, width, top, height} = dom.getBoundingClientRect();
+ return Rectangle.fromLTWH(left, top, width, height);
+ }
+}
diff --git a/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/plugins/BlockClickPlugin.tsx b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/plugins/BlockClickPlugin.tsx
new file mode 100644
index 000000000..a7928fd3f
--- /dev/null
+++ b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/plugins/BlockClickPlugin.tsx
@@ -0,0 +1,120 @@
+import { useEffect, useState } from 'react'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getNodeByKey,
+ $getSelection,
+ $isRangeSelection,
+} from 'lexical'
+import { $findMatchingParent } from '@lexical/utils'
+import { $isMessageBlockNode, $isStepBlockNode } from '../nodes/CustomBlocks'
+import { Icon } from '../../../../atoms/Icons'
+
+interface BlockWithCursor {
+ blockKey: string
+ blockElement: HTMLElement
+ rect: DOMRect
+}
+
+export function BlockClickPlugin() {
+ const [editor] = useLexicalComposerContext()
+ const [activeBlock, setActiveBlock] = useState(null)
+
+ useEffect(() => {
+ const updateActiveBlock = () => {
+ editor.getEditorState().read(() => {
+ const selection = $getSelection()
+ if (!$isRangeSelection(selection)) {
+ setActiveBlock(null)
+ return
+ }
+
+ // Find if the cursor is inside a message or step block
+ const anchorNode = selection.anchor.getNode()
+ const focusNode = selection.focus.getNode()
+
+ // Check if either anchor or focus is in a block
+ const blockNode = $findMatchingParent(anchorNode, (node) =>
+ $isMessageBlockNode(node) || $isStepBlockNode(node)
+ ) || $findMatchingParent(focusNode, (node) =>
+ $isMessageBlockNode(node) || $isStepBlockNode(node)
+ )
+
+ if (blockNode) {
+ const blockKey = blockNode.getKey()
+ const blockElement = editor.getElementByKey(blockKey)
+
+ if (blockElement) {
+ const rect = blockElement.getBoundingClientRect()
+ setActiveBlock({
+ blockKey,
+ blockElement,
+ rect,
+ })
+ }
+ } else {
+ setActiveBlock(null)
+ }
+ })
+ }
+
+ // Listen to selection changes
+ const removeListener = editor.registerUpdateListener(({ editorState }) => {
+ editorState.read(() => {
+ updateActiveBlock()
+ })
+ })
+
+ // Initial check
+ updateActiveBlock()
+
+ return removeListener
+ }, [editor])
+
+ const handleInsertAbove = () => {
+ if (!activeBlock) return
+
+ editor.update(() => {
+ // Find the block node by key in the Lexical tree
+ const blockNode = $getNodeByKey(activeBlock.blockKey)
+
+ if (!blockNode || (!$isMessageBlockNode(blockNode) && !$isStepBlockNode(blockNode))) {
+ return
+ }
+
+ // Create a new paragraph and insert it before the block
+ const newParagraph = $createParagraphNode()
+ newParagraph.append($createTextNode(''))
+
+ blockNode.insertBefore(newParagraph)
+
+ // Focus the new paragraph
+ newParagraph.selectStart()
+ })
+ }
+
+ if (!activeBlock) {
+ return null
+ }
+
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/plugins/BlocksPlugin.tsx b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/plugins/BlocksPlugin.tsx
new file mode 100644
index 000000000..9c30a2437
--- /dev/null
+++ b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/plugins/BlocksPlugin.tsx
@@ -0,0 +1,299 @@
+import { useEffect } from 'react'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import {
+ $getSelection,
+ $isRangeSelection,
+ createCommand,
+ LexicalCommand,
+ COMMAND_PRIORITY_EDITOR,
+ $getRoot,
+ LexicalNode,
+ $isElementNode,
+ $createTextNode,
+ $createParagraphNode,
+} from 'lexical'
+import { AnyBlock } from '@latitude-data/constants/simpleBlocks'
+import {
+ $createMessageBlockNode,
+ $createStepBlockNode,
+ $isMessageBlockNode,
+ $isStepBlockNode,
+ MessageRole,
+} from '../nodes/CustomBlocks'
+
+// Function to validate and fix hierarchy violations
+function $validateAndFixHierarchy(): boolean {
+ const root = $getRoot()
+ let hasViolations = false
+
+ function validateNode(node: LexicalNode): void {
+ if (!$isElementNode(node)) return
+
+ const children = node.getChildren()
+
+ for (const child of children) {
+ // Check if this child violates hierarchy rules
+ if ($isMessageBlockNode(node)) {
+ // Message blocks should not contain other message blocks or step blocks
+ if ($isMessageBlockNode(child) || $isStepBlockNode(child)) {
+ console.warn(
+ '🚨 Hierarchy violation: Message block contains invalid block',
+ {
+ parent: node.getType(),
+ child: child.getType(),
+ },
+ )
+ hasViolations = true
+ // Move the child to root level
+ child.remove()
+ root.append(child)
+ }
+ } else if ($isStepBlockNode(node)) {
+ // Step blocks should not contain other step blocks
+ if ($isStepBlockNode(child)) {
+ console.warn(
+ '🚨 Hierarchy violation: Step block contains step block',
+ {
+ parent: node.getType(),
+ child: child.getType(),
+ },
+ )
+ hasViolations = true
+ // Move the child to root level
+ child.remove()
+ root.append(child)
+ }
+ }
+
+ // Recursively validate children
+ validateNode(child)
+ }
+ }
+
+ validateNode(root)
+
+ if (hasViolations) {
+ console.log(
+ '✅ Fixed hierarchy violations - moved invalid nested blocks to root level',
+ )
+ }
+
+ return hasViolations
+}
+
+// Helper function to find where to insert a new block based on hierarchy rules
+function $findInsertionPoint(
+ anchorNode: LexicalNode,
+ blockType: 'message' | 'step',
+): {
+ parent: LexicalNode
+ insertMethod: 'append' | 'insertAfter'
+ referenceNode?: LexicalNode
+} {
+ // Walk up the tree to find the appropriate container
+ let currentNode: LexicalNode | null = anchorNode
+
+ while (currentNode) {
+ if ($isMessageBlockNode(currentNode)) {
+ // Inside a message block - messages and steps cannot be inside message blocks
+ // Insert after the message block at its parent level
+ const parent = currentNode.getParent()
+ if (parent) {
+ return {
+ parent,
+ insertMethod: 'insertAfter',
+ referenceNode: currentNode,
+ }
+ }
+ }
+
+ if ($isStepBlockNode(currentNode)) {
+ // Inside a step block
+ if (blockType === 'message') {
+ // Messages can be inserted inside step blocks
+ return { parent: currentNode, insertMethod: 'append' }
+ } else {
+ // Steps cannot be inside step blocks
+ // Insert after the step block at its parent level
+ const parent = currentNode.getParent()
+ if (parent) {
+ return {
+ parent,
+ insertMethod: 'insertAfter',
+ referenceNode: currentNode,
+ }
+ }
+ }
+ }
+
+ currentNode = currentNode.getParent()
+ }
+
+ // Default: insert at root level
+ return { parent: $getRoot(), insertMethod: 'append' }
+}
+
+export const INSERT_MESSAGE_BLOCK_COMMAND: LexicalCommand =
+ createCommand('INSERT_MESSAGE_BLOCK_COMMAND')
+export const INSERT_STEP_BLOCK_COMMAND: LexicalCommand = createCommand(
+ 'INSERT_STEP_BLOCK_COMMAND',
+)
+
+export function BlocksPlugin(): null {
+ const [editor] = useLexicalComposerContext()
+
+ useEffect(() => {
+ // Register command handlers
+ const removeMessageCommand = editor.registerCommand(
+ INSERT_MESSAGE_BLOCK_COMMAND,
+ (role: MessageRole) => {
+ const selection = $getSelection()
+ if ($isRangeSelection(selection)) {
+ const anchorNode = selection.anchor.getNode()
+ const { parent, insertMethod, referenceNode } = $findInsertionPoint(
+ anchorNode,
+ 'message',
+ )
+
+ // Create new message block
+ const messageBlock = $createMessageBlockNode(role)
+
+ // Insert using the determined method
+ if (insertMethod === 'append' && $isElementNode(parent)) {
+ parent.append(messageBlock)
+ } else if (insertMethod === 'insertAfter' && referenceNode) {
+ referenceNode.insertAfter(messageBlock)
+ }
+
+ // Focus the new block
+ messageBlock.selectEnd()
+ }
+ return true
+ },
+ COMMAND_PRIORITY_EDITOR,
+ )
+
+ const removeStepCommand = editor.registerCommand(
+ INSERT_STEP_BLOCK_COMMAND,
+ (stepName: string) => {
+ const selection = $getSelection()
+ if ($isRangeSelection(selection)) {
+ const anchorNode = selection.anchor.getNode()
+ const { parent, insertMethod, referenceNode } = $findInsertionPoint(
+ anchorNode,
+ 'step',
+ )
+
+ // Create new step block
+ const stepBlock = $createStepBlockNode(stepName)
+
+ // Insert using the determined method
+ if (insertMethod === 'append' && $isElementNode(parent)) {
+ parent.append(stepBlock)
+ } else if (insertMethod === 'insertAfter' && referenceNode) {
+ referenceNode.insertAfter(stepBlock)
+ }
+
+ // Focus the new block
+ stepBlock.selectEnd()
+ }
+ return true
+ },
+ COMMAND_PRIORITY_EDITOR,
+ )
+
+ return () => {
+ removeMessageCommand()
+ removeStepCommand()
+ }
+ }, [editor])
+
+ return null
+}
+
+// Initialize editor with blocks
+export function InitializeBlocksPlugin({
+ initialBlocks,
+}: {
+ initialBlocks: AnyBlock[]
+}): null {
+ const [editor] = useLexicalComposerContext()
+
+ useEffect(() => {
+ editor.update(() => {
+ const root = $getRoot()
+
+ // If we have initial blocks, always use them
+ if (initialBlocks.length > 0) {
+ console.log('🔄 Initializing editor with blocks:', initialBlocks)
+
+ // Clear existing content
+ root.clear()
+
+ // Convert AnyBlock[] to Lexical nodes
+ for (const block of initialBlocks) {
+ let lexicalBlock
+
+ if (block.type === 'step') {
+ lexicalBlock = $createStepBlockNode(block.attributes?.as || 'Step')
+ // TODO: Handle step children
+ } else if (
+ ['system', 'user', 'assistant', 'developer'].includes(block.type)
+ ) {
+ lexicalBlock = $createMessageBlockNode(block.type as MessageRole)
+ // TODO: Handle message children
+ } else if (block.type === 'text') {
+ // Create a regular Lexical paragraph for text blocks
+ lexicalBlock = $createParagraphNode()
+ lexicalBlock.append($createTextNode(block.content))
+ } else {
+ console.warn('Unsupported block type for editor:', block.type)
+ // Create a regular Lexical paragraph as default
+ lexicalBlock = $createParagraphNode()
+ }
+
+ if (lexicalBlock) {
+ root.append(lexicalBlock)
+ }
+ }
+ } else if (root.getChildrenSize() === 0) {
+ // Only add default paragraph if there are no initial blocks and no existing content
+ console.log('📝 Adding default paragraph')
+ const paragraph = $createParagraphNode()
+ root.append(paragraph)
+ }
+ })
+ }, [editor, initialBlocks])
+
+ return null
+}
+
+export function HierarchyValidationPlugin(): null {
+ const [editor] = useLexicalComposerContext()
+
+ useEffect(() => {
+ // Validate hierarchy on mount and whenever the editor content changes
+ const validateHierarchy = () => {
+ editor.update(() => {
+ $validateAndFixHierarchy()
+ })
+ }
+
+ // Run validation on mount
+ validateHierarchy()
+
+ // Also run validation whenever the editor state changes
+ const removeListener = editor.registerUpdateListener(() => {
+ editor.getEditorState().read(() => {
+ // Only validate in read mode to avoid infinite loops
+ editor.update(() => {
+ $validateAndFixHierarchy()
+ })
+ })
+ })
+
+ return removeListener
+ }, [editor])
+
+ return null
+}
diff --git a/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/plugins/DraggableBlockPlugin.tsx b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/plugins/DraggableBlockPlugin.tsx
new file mode 100644
index 000000000..38a01384a
--- /dev/null
+++ b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/plugins/DraggableBlockPlugin.tsx
@@ -0,0 +1,95 @@
+import React, { useRef, useState } from 'react'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { $createParagraphNode, $getNearestNodeFromDOMNode } from 'lexical'
+
+import { DraggableBlockPlugin_EXPERIMENTAL } from '../overrides/plugins/DraggableBlockPlugin'
+
+import { Icon } from '../../../../atoms/Icons'
+import { cn } from '../../../../../lib/utils'
+
+const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu'
+
+function isOnMenu(element: HTMLElement): boolean {
+ return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`)
+}
+
+interface DraggableBlockPluginProps {
+ anchorElem?: HTMLElement
+}
+
+export function DraggableBlockPlugin({
+ anchorElem = document.body,
+}: DraggableBlockPluginProps) {
+ const [editor] = useLexicalComposerContext()
+ const menuRef = useRef(null)
+ const targetLineRef = useRef(null)
+ const [draggableElement, setDraggableElement] = useState(
+ null,
+ )
+
+ function insertBlock(e: React.MouseEvent) {
+ if (!draggableElement || !editor) {
+ return
+ }
+
+ editor.update(() => {
+ const node = $getNearestNodeFromDOMNode(draggableElement)
+ if (!node) {
+ return
+ }
+
+ const pNode = $createParagraphNode()
+ if (e.altKey || e.ctrlKey) {
+ node.insertBefore(pNode)
+ } else {
+ node.insertAfter(pNode)
+ }
+ pNode.select()
+ })
+ }
+
+ return (
+
+
+
+
+
+
+ }
+ targetLineComponent={
+
+ }
+ isOnMenu={isOnMenu}
+ onElementChanged={setDraggableElement}
+ />
+ )
+}
diff --git a/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/plugins/EnterKeyPlugin.tsx b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/plugins/EnterKeyPlugin.tsx
new file mode 100644
index 000000000..f09770872
--- /dev/null
+++ b/packages/web-ui/src/ds/molecules/BlocksEditor/Editor/plugins/EnterKeyPlugin.tsx
@@ -0,0 +1,132 @@
+import { useEffect } from 'react'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import {
+ KEY_ENTER_COMMAND,
+ COMMAND_PRIORITY_CRITICAL,
+ $getSelection,
+ $isRangeSelection,
+ $isParagraphNode,
+ $createParagraphNode,
+} from 'lexical'
+import { $isMessageBlockNode, $isStepBlockNode } from '../nodes/CustomBlocks'
+
+export function EnterKeyPlugin(): null {
+ const [editor] = useLexicalComposerContext()
+
+ useEffect(() => {
+ const removeEnterListener = editor.registerCommand(
+ KEY_ENTER_COMMAND,
+ (_event) => {
+ // We need to determine synchronously if we should handle this command
+ // So we'll use editor.getEditorState().read() instead of editor.update()
+
+ let shouldHandle = false
+
+ editor.getEditorState().read(() => {
+ const selection = $getSelection()
+ if (!$isRangeSelection(selection)) return
+
+ const anchor = selection.anchor
+ const anchorNode = anchor.getNode()
+
+ // Find the block node that contains the current selection
+ let currentNode = anchorNode
+ let blockNode = null
+
+ while (currentNode) {
+ if (
+ $isMessageBlockNode(currentNode) ||
+ $isStepBlockNode(currentNode)
+ ) {
+ blockNode = currentNode
+ break
+ }
+ const parent = currentNode.getParent()
+ if (!parent) break
+ currentNode = parent
+ }
+
+ if (!blockNode) {
+ return
+ }
+
+ // Check if we're at the end of the block and on an empty paragraph
+ const children = blockNode.getChildren()
+
+ const lastChild = children[children.length - 1]
+ const secondLastChild =
+ children.length > 1 ? children[children.length - 2] : null
+
+ // Check if current position is at the end of the last paragraph
+ if ($isParagraphNode(anchorNode) && anchorNode === lastChild) {
+ const text = anchorNode.getTextContent().trim()
+ const cursorOffset = anchor.offset
+ const textLength = anchorNode.getTextContent().length
+
+ if (text === '' && cursorOffset === textLength) {
+ if (secondLastChild && $isParagraphNode(secondLastChild)) {
+ const secondLastText = secondLastChild.getTextContent().trim()
+
+ if (secondLastText === '') {
+ shouldHandle = true
+ }
+ }
+ }
+ }
+ })
+ if (!shouldHandle) return false
+
+ // Now we perform the actual update
+ editor.update(() => {
+ const selection = $getSelection()
+ if (!$isRangeSelection(selection)) return
+
+ const anchor = selection.anchor
+ const anchorNode = anchor.getNode()
+
+ // Find the block node again
+ let currentNode = anchorNode
+ let blockNode = null
+
+ while (currentNode) {
+ if (
+ $isMessageBlockNode(currentNode) ||
+ $isStepBlockNode(currentNode)
+ ) {
+ blockNode = currentNode
+ break
+ }
+ const parent = currentNode.getParent()
+ if (!parent) break
+ currentNode = parent
+ }
+
+ if (!blockNode) return
+
+ const children = blockNode.getChildren()
+ const lastChild = children[children.length - 1]
+
+ if ($isParagraphNode(lastChild)) {
+ const newParagraph = $createParagraphNode()
+ blockNode.insertAfter(newParagraph)
+
+ // Remove the last empty paragraph
+ lastChild.remove()
+
+ // Move selection to the new paragraph outside the block
+ newParagraph.select()
+ }
+ })
+
+ return true // Mark as handled to stop other listeners
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ )
+
+ return () => {
+ removeEnterListener()
+ }
+ }, [editor])
+
+ return null
+}
diff --git a/packages/web-ui/src/ds/molecules/BlocksEditor/README.md b/packages/web-ui/src/ds/molecules/BlocksEditor/README.md
new file mode 100644
index 000000000..7bc3d7c55
--- /dev/null
+++ b/packages/web-ui/src/ds/molecules/BlocksEditor/README.md
@@ -0,0 +1,203 @@
+# BlocksEditor
+
+A Notion-like, block-based editor built with Lexical that enforces hierarchical content structure. The editor supports custom blocks with strict nesting rules to maintain content organization and prevent invalid configurations.
+
+## Overview
+
+The BlocksEditor is designed to handle structured content through a hierarchy of custom blocks. It only accepts and emits block arrays (not strings) and provides full editing capabilities with cursor movement between and inside blocks.
+
+## Block Types
+
+### 1. Paragraph Block
+- **Purpose**: The most basic primitive for text content
+- **Visual**: Simple white container with gray border
+- **Usage**: Plain text paragraphs
+
+### 2. Message Block
+- **Purpose**: Represents different types of messages in a conversation
+- **Roles**: `system`, `user`, `assistant`, `developer`
+- **Visual**: Color-coded containers with role labels
+ - System: Purple background
+ - User: Blue background
+ - Assistant: Green background
+ - Developer: Orange background
+
+### 3. Step Block
+- **Purpose**: Groups related content into logical steps
+- **Visual**: Indigo container with step header and emoji
+- **Usage**: Organizing workflows or multi-step processes
+
+## Hierarchy Rules
+
+The editor enforces strict hierarchical rules to prevent invalid content structures:
+
+### Rule 1: Paragraph Blocks (Most Restrictive)
+```
+Paragraph Block
+├── ✅ Regular Lexical nodes (TextNode, etc.)
+└── ❌ NO custom blocks (messages, steps, paragraphs)
+```
+
+### Rule 2: Message Blocks (Medium Restrictive)
+```
+Message Block
+├── ✅ Paragraph blocks
+├── ✅ Regular Lexical nodes
+├── ❌ NO other message blocks
+└── ❌ NO step blocks
+```
+
+### Rule 3: Step Blocks (Least Restrictive)
+```
+Step Block
+├── ✅ Message blocks (any role)
+├── ✅ Paragraph blocks
+├── ✅ Regular Lexical nodes
+└── ❌ NO other step blocks
+```
+
+### Rule 4: Root Level (No Restrictions)
+```
+Root Level
+├── ✅ Any block type
+├── ✅ Step blocks
+├── ✅ Message blocks
+└── ✅ Paragraph blocks
+```
+
+## Key Constraints
+
+1. **No nested messages**: Messages cannot contain other messages
+2. **No nested steps**: Steps cannot contain other steps
+3. **Paragraphs are atomic**: Paragraph blocks only contain text, no other blocks
+4. **Steps can contain messages**: Steps can hold multiple messages and paragraphs
+5. **Hierarchical enforcement**: The editor prevents invalid insertions automatically
+
+## Usage
+
+### Basic Implementation
+
+```tsx
+import { BlocksEditor } from '@latitude-data/web-ui/molecules/BlocksEditor'
+import { AnyBlock } from '@latitude-data/constants/simpleBlocks'
+
+function MyComponent() {
+ const [blocks, setBlocks] = useState([])
+
+ return (
+
+ )
+}
+```
+
+### Props
+
+| Prop | Type | Default | Description |
+|------|------|---------|-------------|
+| `initialValue` | `AnyBlock[]` | `[]` | Initial blocks to load |
+| `onBlocksChange` | `(blocks: AnyBlock[]) => void` | - | Callback when blocks change |
+| `onChange` | `(content: string) => void` | - | Legacy string-based callback |
+| `placeholder` | `string` | `"Start typing..."` | Placeholder text |
+| `readOnly` | `boolean` | `false` | Make editor read-only |
+| `autoFocus` | `boolean` | `false` | Auto-focus on mount |
+| `className` | `string` | - | Additional CSS classes |
+
+## Architecture
+
+### Core Components
+
+1. **CustomBlocks.tsx**: Defines the three block types as Lexical ElementNodes
+2. **BlocksPlugin.tsx**: Handles block insertion commands and hierarchy validation
+3. **BlocksToolbar.tsx**: Provides UI for inserting different block types
+4. **Editor/index.tsx**: Main editor component with Lexical configuration
+
+### Hierarchy Enforcement Implementation
+
+#### Node-Level Controls
+Each custom block implements these methods to enforce rules:
+
+- `canInsertChild(child)`: Controls what children are allowed
+- `canMergeWith(node)`: Controls block merging behavior
+- `canInsertTextBefore/After()`: Prevents text insertion outside blocks
+- `canReplaceWith()`: Controls block replacement
+
+#### Command-Level Validation
+The BlocksPlugin validates context before inserting blocks:
+
+```typescript
+// Example validation logic
+function $canInsertBlockInContext(blockType: BlockType, contextNode: LexicalNode | null): boolean {
+ if ($isParagraphBlockNode(contextNode)) {
+ return false // No blocks allowed inside paragraphs
+ }
+
+ if ($isMessageBlockNode(contextNode)) {
+ return blockType === 'paragraph' // Only paragraphs in messages
+ }
+
+ if ($isStepBlockNode(contextNode)) {
+ return blockType !== 'step' // No steps inside steps
+ }
+
+ return true // Root level allows everything
+}
+```
+
+## Examples
+
+### Valid Hierarchy
+```
+Step: "Data Analysis"
+├── User Message: "Please analyze this data"
+│ └── Paragraph: "Here's the CSV file..."
+├── Assistant Message: "I'll help you analyze it"
+│ └── Paragraph: "Let me start by examining..."
+└── Paragraph: "Additional notes about the analysis"
+```
+
+### Invalid Hierarchy (Prevented)
+```
+❌ Message Block
+ └── Message Block (nested messages not allowed)
+
+❌ Step Block
+ └── Step Block (nested steps not allowed)
+
+❌ Paragraph Block
+ └── Message Block (paragraphs can't contain blocks)
+```
+
+## Development
+
+### Adding New Block Types
+
+1. Create a new ElementNode class in `CustomBlocks.tsx`
+2. Implement hierarchy methods (`canInsertChild`, etc.)
+3. Add creation helper and type guard functions
+4. Register the node in the editor configuration
+5. Add toolbar button and command in plugins
+
+### Testing Hierarchy Rules
+
+The editor will log warnings to console when invalid operations are attempted:
+```
+Console: "Cannot insert message block in this context"
+```
+
+## Dependencies
+
+- `lexical`: Core editor framework
+- `@lexical/react`: React integration
+- `@latitude-data/constants`: Block type definitions
+
+## Notes
+
+- The editor only works with block arrays, not strings
+- All hierarchy validation happens at the Lexical node level
+- Invalid operations are silently rejected with console warnings
+- The toolbar shows all options but enforces rules during insertion
\ No newline at end of file
diff --git a/packages/web-ui/src/ds/molecules/BlocksEditor/index.tsx b/packages/web-ui/src/ds/molecules/BlocksEditor/index.tsx
new file mode 100644
index 000000000..393ec8389
--- /dev/null
+++ b/packages/web-ui/src/ds/molecules/BlocksEditor/index.tsx
@@ -0,0 +1,28 @@
+'use client'
+
+import React, { lazy } from 'react'
+
+import { TextEditorPlaceholder } from '../TextEditorPlaceholder'
+import { BlocksEditorProps, JSONContent } from './types'
+import { ClientOnly } from '../../atoms/ClientOnly'
+
+const LazyBlocksEditor = lazy(() =>
+ import('./Editor/index').then(
+ (module) =>
+ ({
+ default: module.BlocksEditor,
+ }) as {
+ default: React.ComponentType
+ },
+ ),
+)
+
+function EditorWrapper(props: BlocksEditorProps) {
+ return (
+
+
+
+ )
+}
+
+export { EditorWrapper as BlocksEditor, TextEditorPlaceholder, type JSONContent }
diff --git a/packages/web-ui/src/ds/molecules/BlocksEditor/types.ts b/packages/web-ui/src/ds/molecules/BlocksEditor/types.ts
new file mode 100644
index 000000000..da7f88d72
--- /dev/null
+++ b/packages/web-ui/src/ds/molecules/BlocksEditor/types.ts
@@ -0,0 +1,14 @@
+import { AnyBlock } from '@latitude-data/constants/simpleBlocks'
+
+export type JSONContent = object
+
+export type BlocksEditorProps = {
+ placeholder?: string
+ initialValue?: AnyBlock[] // Support both string and blocks array
+ onChange?: (content: string) => void
+ onBlocksChange?: (blocks: AnyBlock[]) => void // New callback for blocks
+ className?: string
+ readOnly?: boolean
+ autoFocus?: boolean
+}
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8f54c0508..513b3bfa9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -537,7 +537,7 @@ importers:
dependencies:
'@latitude-data/sdk':
specifier: ^4.1.8
- version: link:../sdks/typescript
+ version: 4.1.9(typescript@5.8.3)
chalk:
specifier: ^5.4.1
version: 5.4.1
@@ -1220,6 +1220,33 @@ importers:
'@latitude-data/core':
specifier: workspace:^
version: link:../core
+ '@lexical/code':
+ specifier: ^0.32.1
+ version: 0.32.1
+ '@lexical/link':
+ specifier: ^0.32.1
+ version: 0.32.1
+ '@lexical/list':
+ specifier: ^0.32.1
+ version: 0.32.1
+ '@lexical/markdown':
+ specifier: ^0.32.1
+ version: 0.32.1
+ '@lexical/react':
+ specifier: ^0.32.1
+ version: 0.32.1(react-dom@18.3.0(react@18.3.0))(react@18.3.0)(yjs@13.6.27)
+ '@lexical/rich-text':
+ specifier: ^0.32.1
+ version: 0.32.1
+ '@lexical/selection':
+ specifier: ^0.32.1
+ version: 0.32.1
+ '@lexical/table':
+ specifier: ^0.32.1
+ version: 0.32.1
+ '@lexical/utils':
+ specifier: ^0.32.1
+ version: 0.32.1
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.50.0)(react-dom@18.3.0(react@18.3.0))(react@18.3.0)
@@ -1283,6 +1310,9 @@ importers:
hsl-to-hex:
specifier: ^1.0.0
version: 1.0.0
+ lexical:
+ specifier: ^0.32.1
+ version: 0.32.1
lodash-es:
specifier: ^4.17.21
version: 4.17.21
@@ -3069,6 +3099,12 @@ packages:
react: '>=16.8.0'
react-dom: '>=16.8.0'
+ '@floating-ui/react@0.27.12':
+ resolution: {integrity: sha512-kKlWNrpIQxF1B/a2MZvE0/uyKby4960yjO91W7nVyNKmmfNi62xU9HCjL1M1eWzx/LFj/VPSwJVbwQk9Pq/68A==}
+ peerDependencies:
+ react: '>=17.0.0'
+ react-dom: '>=17.0.0'
+
'@floating-ui/utils@0.2.9':
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
@@ -3591,6 +3627,11 @@ packages:
peerDependencies:
'@langchain/core': '>=0.2.21 <0.4.0'
+ '@latitude-data/sdk@4.1.9':
+ resolution: {integrity: sha512-O9OkltiF8rifNRhUsfmPpKbzAdHHBhV60lwph+TXgshbgpgBuJZgbo405SNKtBVqxrdWOZIYPusLzzt7JfczSQ==}
+ peerDependencies:
+ typescript: ^5.5.4
+
'@latitude-data/socket.io-react-hook@2.4.5':
resolution: {integrity: sha512-otajd00jgJw6+wHM7LRjUuE+wg6vb0U8aQxyDdEFBTCf7BXIxvv94piXGZW2YivR0b41Gmh4qWgjodI/8TcE9g==}
peerDependencies:
@@ -3599,6 +3640,77 @@ packages:
'@leichtgewicht/ip-codec@2.0.5':
resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==}
+ '@lexical/clipboard@0.32.1':
+ resolution: {integrity: sha512-oO7CuMVh3EFEqtE6+7Ccf7jMD5RNUmSdTnFm/X4kYNGqs9lgGt8j5PgSk7oP9OuAjxKNdBTbltSlh54CX3AUIg==}
+
+ '@lexical/code@0.32.1':
+ resolution: {integrity: sha512-2rXj8s/CG32XKQ2EpORpACfpzyAxB+/SrQW2cjwczarLs5Fxnx6u6HwahZnxaF0z5UHIPUy90qDiOiRExc74Yg==}
+
+ '@lexical/devtools-core@0.32.1':
+ resolution: {integrity: sha512-3WnZQo6Qig7ccjDu2b8s1Kb5CCXowxnK0i8CCRG9mHAw7i6XpZUYAbk4rmcK/qbhLHrc7LwUrAMFzGtfLEH3XA==}
+ peerDependencies:
+ react: '>=17.x'
+ react-dom: '>=17.x'
+
+ '@lexical/dragon@0.32.1':
+ resolution: {integrity: sha512-Dlx8P2b/O7gZLmXnoanmDkFL5RgA8Vvix4ZuSvT0apblqySzgi8l3NHHwwqXy1g2nfSupvpr7Dsf10Lu3l0Hlw==}
+
+ '@lexical/hashtag@0.32.1':
+ resolution: {integrity: sha512-S63bb7uIB4hO2V0UmzUiKlwAGegQlyFKqrOw9NJwOb8O96gHRxr27FUsEb8ToWLM8TSm2aw1WsZXs7CJQqGtCg==}
+
+ '@lexical/history@0.32.1':
+ resolution: {integrity: sha512-IRsKllumYEWxmzR2evN30MFY+JBM723lSyzm2PAQcgHCeBxi8t0Vc3EdyJRay+YdN65JgrohQi1WbktbK923uQ==}
+
+ '@lexical/html@0.32.1':
+ resolution: {integrity: sha512-uctCdC9gVzx/Sw9CimT4C2IDfSbfEGYunyIrJBpsfcdqp0rroGNizjIoZNBH3xcgkk9UDboSADo+wimbzEoy8A==}
+
+ '@lexical/link@0.32.1':
+ resolution: {integrity: sha512-atdwNpWjZ0U2/kgS0ATTkZ8lJLHiv3TsJgqJL33BuV9Gn7advJokd4faM79Y8XxkhiPi1lVTBSHgI8V4hs+c+Q==}
+
+ '@lexical/list@0.32.1':
+ resolution: {integrity: sha512-3zShCfEdAvodR6mQ5CNN1gcEwfV341LXJzWCIkZzG1cPwaiBHUlT7TynQtKTPn1sATCEMmxoDG0/T+itsRNZgA==}
+
+ '@lexical/mark@0.32.1':
+ resolution: {integrity: sha512-AXF2wmUvvSI45y+sgZKnU0pnUdttd9v75DDQgdplqtCkyDqHVGxVCNCrLE+PJtzIrwJxtA2UyC8yFZMBM92HpA==}
+
+ '@lexical/markdown@0.32.1':
+ resolution: {integrity: sha512-AmUTRRx6Je0AOiQqp48Xn92/71AzhFgi4nO1EtPW5eae1CihrtiEh5UQr48mV6EyjvH9D3OlOLU8XrzS+J9a+w==}
+
+ '@lexical/offset@0.32.1':
+ resolution: {integrity: sha512-zfHqoLlQ0lq1akFHy81xnDaRRE5KkqFa7OovOxKPBpALQCiJIAb2ykqj/Woc2oUeYaEcnkaFU9+kEWMK9yY0fQ==}
+
+ '@lexical/overflow@0.32.1':
+ resolution: {integrity: sha512-wjcFGjzkbugds2Q5Wag59WrcxJwMUACEXms1FtFdu1/YcBPqNAKJSyfo8Z/5LGfstQEb2nPtSuEQZUb7v+XYyA==}
+
+ '@lexical/plain-text@0.32.1':
+ resolution: {integrity: sha512-uFS3xoETB3phnYHZXfMKvl8gh6YRW7rpokuJmQVMHNNBklORmMpN00rRQ/zsc/jt/nPzaPpE5cLwSHXeJdqJUg==}
+
+ '@lexical/react@0.32.1':
+ resolution: {integrity: sha512-PCiAiwGIGfkYb2o9Kx+gGGqXwxqb7/W4cGSnw1nzmNtCerJ3S64WZs87Lgcow0RlDSwqzpH534+eCyIddueSqw==}
+ peerDependencies:
+ react: '>=17.x'
+ react-dom: '>=17.x'
+
+ '@lexical/rich-text@0.32.1':
+ resolution: {integrity: sha512-SnmpZ7boTLxeYfNezNLvchDiJOAALA2nD0Uq/SpkIOJ6R01R7m1aPdLv55LGKoBT9UxCRdo0HWXytwiVZI+ehQ==}
+
+ '@lexical/selection@0.32.1':
+ resolution: {integrity: sha512-X1aXJdq/5EOuSuMOqK3t+rEVmpqLf+vc2Kl5YuP8+gGWUbXuxR6iryrQuy1mAViZpF/5qw4HO/Sb+9JjubaZEg==}
+
+ '@lexical/table@0.32.1':
+ resolution: {integrity: sha512-sGk2jUbQHj5hatpxRyl6IE2oWsjRnYhmaP94THzn95/uK69o8eSizcnd148WzYsX8Zz+L9PTLS1xjvCbfLTP+A==}
+
+ '@lexical/text@0.32.1':
+ resolution: {integrity: sha512-0Ek8F3KC4d16b2YaTHdyYFqDSBZ5KRtGrqU3GBog+VOGxucGaEbXEK1/ypX5CTe/wwkQDrH0FKWPQbd3l5t5YQ==}
+
+ '@lexical/utils@0.32.1':
+ resolution: {integrity: sha512-ZaqZZksNIHJd+g8GXc11D1ESi8JzsdLVQZ+9odXVaNxtwDIaGIqMSccFyuZ9VSoJDde4JXZkgp/0PShbAZDkyw==}
+
+ '@lexical/yjs@0.32.1':
+ resolution: {integrity: sha512-VHGTg5z4wcDkPe8NnhzA5+CoOKJ5tVmTmMvoiZ91rtNUFQPxWRky88Gjt1e3yXldYp4pImNEgtAjlWqvaJBYGA==}
+ peerDependencies:
+ yjs: '>=13.5.22'
+
'@llamaindex/anthropic@0.0.33':
resolution: {integrity: sha512-RyVOLF7ixHLtFSRwDmpX7qOLHna4M5tLEMj76WBms9CBHKDY4wywqqPSB/bKKFXjNDFbFCK5LbkQmb16zdThEg==}
@@ -10276,6 +10388,9 @@ packages:
peerDependencies:
ws: '*'
+ isomorphic.js@0.2.5:
+ resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
+
istanbul-lib-coverage@3.2.0:
resolution: {integrity: sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==}
engines: {node: '>=8'}
@@ -10570,6 +10685,14 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
+ lexical@0.32.1:
+ resolution: {integrity: sha512-Rvr9p00zUwzjXIqElIjMDyl/24QHw68yaqmXUWIT3lSdSAr8OpjSJK3iWBLZwVZwwpVhwShZRckomc+3vSb/zw==}
+
+ lib0@0.2.109:
+ resolution: {integrity: sha512-jP0gbnyW0kwlx1Atc4dcHkBbrVAkdHjuyHxtClUPYla7qCmwIif1qZ6vQeJdR5FrOVdn26HvQT0ko01rgW7/Xw==}
+ engines: {node: '>=16'}
+ hasBin: true
+
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
@@ -13187,6 +13310,9 @@ packages:
resolution: {integrity: sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==}
engines: {node: ^14.18.0 || >=16.0.0}
+ tabbable@6.2.0:
+ resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
+
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
@@ -14194,6 +14320,10 @@ packages:
resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==}
engines: {node: '>=12'}
+ yjs@13.6.27:
+ resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==}
+ engines: {node: '>=16.0.0', npm: '>=8.0.0'}
+
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -16970,6 +17100,14 @@ snapshots:
react: 18.3.0
react-dom: 18.3.0(react@18.3.0)
+ '@floating-ui/react@0.27.12(react-dom@18.3.0(react@18.3.0))(react@18.3.0)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.3(react-dom@18.3.0(react@18.3.0))(react@18.3.0)
+ '@floating-ui/utils': 0.2.9
+ react: 18.3.0
+ react-dom: 18.3.0(react@18.3.0)
+ tabbable: 6.2.0
+
'@floating-ui/utils@0.2.9': {}
'@google-cloud/aiplatform@3.35.0':
@@ -17488,6 +17626,17 @@ snapshots:
'@langchain/core': 0.3.57(openai@4.104.0(ws@8.18.2)(zod@3.24.2))
js-tiktoken: 1.0.20
+ '@latitude-data/sdk@4.1.9(typescript@5.8.3)':
+ dependencies:
+ eventsource-parser: 2.0.1
+ node-fetch: 3.3.2
+ promptl-ai: 0.7.4
+ typescript: 5.8.3
+ zod: 3.24.2
+ transitivePeerDependencies:
+ - encoding
+ - ws
+
'@latitude-data/socket.io-react-hook@2.4.5(react@19.0.0-rc-5d19e1c8-20240923)':
dependencies:
parseuri: 0.0.6
@@ -17502,6 +17651,152 @@ snapshots:
'@leichtgewicht/ip-codec@2.0.5': {}
+ '@lexical/clipboard@0.32.1':
+ dependencies:
+ '@lexical/html': 0.32.1
+ '@lexical/list': 0.32.1
+ '@lexical/selection': 0.32.1
+ '@lexical/utils': 0.32.1
+ lexical: 0.32.1
+
+ '@lexical/code@0.32.1':
+ dependencies:
+ '@lexical/utils': 0.32.1
+ lexical: 0.32.1
+ prismjs: 1.30.0
+
+ '@lexical/devtools-core@0.32.1(react-dom@18.3.0(react@18.3.0))(react@18.3.0)':
+ dependencies:
+ '@lexical/html': 0.32.1
+ '@lexical/link': 0.32.1
+ '@lexical/mark': 0.32.1
+ '@lexical/table': 0.32.1
+ '@lexical/utils': 0.32.1
+ lexical: 0.32.1
+ react: 18.3.0
+ react-dom: 18.3.0(react@18.3.0)
+
+ '@lexical/dragon@0.32.1':
+ dependencies:
+ lexical: 0.32.1
+
+ '@lexical/hashtag@0.32.1':
+ dependencies:
+ '@lexical/utils': 0.32.1
+ lexical: 0.32.1
+
+ '@lexical/history@0.32.1':
+ dependencies:
+ '@lexical/utils': 0.32.1
+ lexical: 0.32.1
+
+ '@lexical/html@0.32.1':
+ dependencies:
+ '@lexical/selection': 0.32.1
+ '@lexical/utils': 0.32.1
+ lexical: 0.32.1
+
+ '@lexical/link@0.32.1':
+ dependencies:
+ '@lexical/utils': 0.32.1
+ lexical: 0.32.1
+
+ '@lexical/list@0.32.1':
+ dependencies:
+ '@lexical/selection': 0.32.1
+ '@lexical/utils': 0.32.1
+ lexical: 0.32.1
+
+ '@lexical/mark@0.32.1':
+ dependencies:
+ '@lexical/utils': 0.32.1
+ lexical: 0.32.1
+
+ '@lexical/markdown@0.32.1':
+ dependencies:
+ '@lexical/code': 0.32.1
+ '@lexical/link': 0.32.1
+ '@lexical/list': 0.32.1
+ '@lexical/rich-text': 0.32.1
+ '@lexical/text': 0.32.1
+ '@lexical/utils': 0.32.1
+ lexical: 0.32.1
+
+ '@lexical/offset@0.32.1':
+ dependencies:
+ lexical: 0.32.1
+
+ '@lexical/overflow@0.32.1':
+ dependencies:
+ lexical: 0.32.1
+
+ '@lexical/plain-text@0.32.1':
+ dependencies:
+ '@lexical/clipboard': 0.32.1
+ '@lexical/selection': 0.32.1
+ '@lexical/utils': 0.32.1
+ lexical: 0.32.1
+
+ '@lexical/react@0.32.1(react-dom@18.3.0(react@18.3.0))(react@18.3.0)(yjs@13.6.27)':
+ dependencies:
+ '@floating-ui/react': 0.27.12(react-dom@18.3.0(react@18.3.0))(react@18.3.0)
+ '@lexical/devtools-core': 0.32.1(react-dom@18.3.0(react@18.3.0))(react@18.3.0)
+ '@lexical/dragon': 0.32.1
+ '@lexical/hashtag': 0.32.1
+ '@lexical/history': 0.32.1
+ '@lexical/link': 0.32.1
+ '@lexical/list': 0.32.1
+ '@lexical/mark': 0.32.1
+ '@lexical/markdown': 0.32.1
+ '@lexical/overflow': 0.32.1
+ '@lexical/plain-text': 0.32.1
+ '@lexical/rich-text': 0.32.1
+ '@lexical/table': 0.32.1
+ '@lexical/text': 0.32.1
+ '@lexical/utils': 0.32.1
+ '@lexical/yjs': 0.32.1(yjs@13.6.27)
+ lexical: 0.32.1
+ react: 18.3.0
+ react-dom: 18.3.0(react@18.3.0)
+ react-error-boundary: 3.1.4(react@18.3.0)
+ transitivePeerDependencies:
+ - yjs
+
+ '@lexical/rich-text@0.32.1':
+ dependencies:
+ '@lexical/clipboard': 0.32.1
+ '@lexical/selection': 0.32.1
+ '@lexical/utils': 0.32.1
+ lexical: 0.32.1
+
+ '@lexical/selection@0.32.1':
+ dependencies:
+ lexical: 0.32.1
+
+ '@lexical/table@0.32.1':
+ dependencies:
+ '@lexical/clipboard': 0.32.1
+ '@lexical/utils': 0.32.1
+ lexical: 0.32.1
+
+ '@lexical/text@0.32.1':
+ dependencies:
+ lexical: 0.32.1
+
+ '@lexical/utils@0.32.1':
+ dependencies:
+ '@lexical/list': 0.32.1
+ '@lexical/selection': 0.32.1
+ '@lexical/table': 0.32.1
+ lexical: 0.32.1
+
+ '@lexical/yjs@0.32.1(yjs@13.6.27)':
+ dependencies:
+ '@lexical/offset': 0.32.1
+ '@lexical/selection': 0.32.1
+ lexical: 0.32.1
+ yjs: 13.6.27
+
'@llamaindex/anthropic@0.0.33(@aws-crypto/sha256-js@5.2.0)(@huggingface/transformers@3.5.2)(@types/node@22.15.31)(gpt-tokenizer@2.9.0)(js-tiktoken@1.0.20)(jsdom@24.1.3(canvas@2.11.2))(msw@2.10.2(@types/node@22.15.31)(typescript@5.8.3))(pathe@1.1.2)(terser@5.43.1)':
dependencies:
'@anthropic-ai/sdk': 0.32.1
@@ -21034,7 +21329,7 @@ snapshots:
'@stoplight/json-ref-readers@1.2.2':
dependencies:
- node-fetch: 2.6.7
+ node-fetch: 2.7.0
tslib: 1.14.1
transitivePeerDependencies:
- encoding
@@ -25709,6 +26004,8 @@ snapshots:
dependencies:
ws: 8.18.2
+ isomorphic.js@0.2.5: {}
+
istanbul-lib-coverage@3.2.0: {}
istextorbinary@9.5.0:
@@ -25999,6 +26296,12 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
+ lexical@0.32.1: {}
+
+ lib0@0.2.109:
+ dependencies:
+ isomorphic.js: 0.2.5
+
lie@3.3.0:
dependencies:
immediate: 3.0.6
@@ -27467,6 +27770,20 @@ snapshots:
transitivePeerDependencies:
- encoding
+ openai@4.104.0(zod@3.25.61):
+ dependencies:
+ '@types/node': 18.19.111
+ '@types/node-fetch': 2.6.12
+ abort-controller: 3.0.0
+ agentkeepalive: 4.6.0
+ form-data-encoder: 1.7.2
+ formdata-node: 4.4.1
+ node-fetch: 2.7.0
+ optionalDependencies:
+ zod: 3.25.61
+ transitivePeerDependencies:
+ - encoding
+
openapi-types@12.1.3: {}
openapi3-ts@4.4.0:
@@ -27982,6 +28299,19 @@ snapshots:
progress@2.0.3: {}
+ promptl-ai@0.7.4:
+ dependencies:
+ acorn: 8.15.0
+ code-red: 1.0.4
+ fast-sha256: 1.3.0
+ locate-character: 3.0.0
+ openai: 4.104.0(zod@3.25.61)
+ yaml: 2.4.5
+ zod: 3.25.61
+ transitivePeerDependencies:
+ - encoding
+ - ws
+
promptl-ai@0.7.4(ws@8.18.2):
dependencies:
acorn: 8.15.0
@@ -29565,6 +29895,8 @@ snapshots:
dependencies:
'@pkgr/core': 0.2.7
+ tabbable@6.2.0: {}
+
tailwind-merge@2.6.0: {}
tailwindcss-animate@1.0.7(tailwindcss@3.4.17):
@@ -30880,6 +31212,10 @@ snapshots:
buffer-crc32: 0.2.13
pend: 1.2.0
+ yjs@13.6.27:
+ dependencies:
+ lib0: 0.2.109
+
yocto-queue@0.1.0: {}
yoctocolors-cjs@2.1.2: {}