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: {}