diff --git a/packages/apollo-react/package.json b/packages/apollo-react/package.json index 625223c46..5395d3eb0 100644 --- a/packages/apollo-react/package.json +++ b/packages/apollo-react/package.json @@ -175,16 +175,16 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@floating-ui/react": "^0.27.15", - "@lexical/code": "0.16.0", - "@lexical/html": "0.16.0", - "@lexical/link": "0.16.0", - "@lexical/list": "0.16.0", - "@lexical/markdown": "0.16.0", - "@lexical/react": "0.16.0", - "@lexical/rich-text": "0.16.0", - "@lexical/selection": "0.16.0", - "@lexical/table": "0.16.0", - "@lexical/utils": "0.16.0", + "@lexical/code": "0.42.0", + "@lexical/html": "0.42.0", + "@lexical/link": "0.42.0", + "@lexical/list": "0.42.0", + "@lexical/markdown": "0.42.0", + "@lexical/react": "0.42.0", + "@lexical/rich-text": "0.42.0", + "@lexical/selection": "0.42.0", + "@lexical/table": "0.42.0", + "@lexical/utils": "0.42.0", "@lingui/core": "^5.6.1", "@lingui/react": "^5.6.1", "@mui/icons-material": "^5.18.0", @@ -216,7 +216,7 @@ "debounce": "^3.0.0", "html-to-image": "^1.11.11", "katex": "^0.16.27", - "lexical": "0.16.0", + "lexical": "0.42.0", "lodash": "^4.18.1", "lucide-react": "^0.577.0", "luxon": "^3.7.1", diff --git a/packages/apollo-react/src/material/components/ap-rich-text-editor/ApRichTextEditor.tsx b/packages/apollo-react/src/material/components/ap-rich-text-editor/ApRichTextEditor.tsx index 4f9a59f55..3cf39a9c7 100644 --- a/packages/apollo-react/src/material/components/ap-rich-text-editor/ApRichTextEditor.tsx +++ b/packages/apollo-react/src/material/components/ap-rich-text-editor/ApRichTextEditor.tsx @@ -5,7 +5,7 @@ import { ListItemNode, ListNode } from '@lexical/list'; import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; -import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'; +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; import { ListPlugin } from '@lexical/react/LexicalListPlugin'; diff --git a/packages/apollo-wind/package.json b/packages/apollo-wind/package.json index 336b7fc62..2c86dba4d 100644 --- a/packages/apollo-wind/package.json +++ b/packages/apollo-wind/package.json @@ -77,6 +77,9 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.2.2", + "@lexical/clipboard": "0.42.0", + "@lexical/react": "0.42.0", + "@lexical/utils": "0.42.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.8", @@ -112,9 +115,12 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "dompurify": "^3.4.0", "framer-motion": "^12.26.2", "jsep": "^1.4.0", + "lexical": "0.42.0", "lucide-react": "^0.577.0", + "marked": "^17.0.6", "next-themes": "^0.4.6", "react-day-picker": "^9.13.0", "react-hook-form": "^7.66.1", diff --git a/packages/apollo-wind/src/components/ui/index.ts b/packages/apollo-wind/src/components/ui/index.ts index ceed7eede..cf3361ab5 100644 --- a/packages/apollo-wind/src/components/ui/index.ts +++ b/packages/apollo-wind/src/components/ui/index.ts @@ -30,6 +30,7 @@ export * from './multi-select'; export * from './pagination'; export * from './popover'; export * from './progress'; +export * from './prompt-editor'; export * from './radio-group'; export * from './resizable'; export * from './scroll-area'; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/components/EditorToolbar.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/components/EditorToolbar.tsx new file mode 100644 index 000000000..f5a78f3d0 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/components/EditorToolbar.tsx @@ -0,0 +1,159 @@ +import { Bold, Italic, List, ListOrdered, Maximize2, Strikethrough } from 'lucide-react'; +import { cn } from '@/lib'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import type { PromptEditorMode, PromptEditorToolbarActionsRef } from '../types'; + +export interface EditorToolbarProps { + mode: PromptEditorMode; + onModeChange: (mode: PromptEditorMode) => void; + disabled?: boolean; + actionsRef?: React.RefObject; + onFullscreen?: () => void; +} + +/** + * Toolbar formatting button: 28×28 px tap target, 4 px border-radius, 14 px lucide icon. + */ +const ToolbarButton = ({ + icon: Icon, + label, + disabled, + onClick, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + disabled?: boolean; + onClick?: () => void; +}) => ( + + + + + + {label} + + +); + +const ToolbarSeparator = () => ; + +export const EditorToolbar = ({ + mode, + onModeChange, + disabled, + actionsRef, + onFullscreen, +}: EditorToolbarProps) => { + const isEditMode = mode === 'edit'; + + const handleFormat = (actionName: keyof PromptEditorToolbarActionsRef) => () => { + if (!disabled && isEditMode) { + const fn = actionsRef?.current?.[actionName]; + if (typeof fn === 'function') fn(); + } + }; + + return ( +
` below at full width so the L/R outlines stay continuous with the editor body's + // `border-t-0` border underneath. + className="relative flex items-center justify-between gap-1 overflow-hidden rounded-t-md border border-b-0 bg-background px-2 py-1" + data-testid="editor-toolbar" + > + + {/* Left: Edit/Preview mode switcher */} +
+
+ + +
+
+ + {/* Right: formatting cluster (Bold/Italic/Strike) → list cluster (Numbered/Bullet) → Expand. */} +
+ + + + + + + + + + {onFullscreen && ( + <> + + + + )} +
+
+ ); +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/components/MarkdownPreview.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/components/MarkdownPreview.tsx new file mode 100644 index 000000000..b3668d11c --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/components/MarkdownPreview.tsx @@ -0,0 +1,177 @@ +import DOMPurify from 'dompurify'; +import { Marked } from 'marked'; +import { useMemo } from 'react'; +import { type PromptEditorToken } from '../types'; +import { buildTokenIconSvgMarkup } from './token-icon-markup'; + +const marked = new Marked({ async: false, gfm: true, breaks: true }); + +// Preview element styles live in the package stylesheet (`styles/tailwind.utilities.css`, scoped +// under `.prompt-editor-preview`) that consumers already import — see the note there. They were +// previously injected inline here, but a per-component `.css` import doesn't resolve next to the +// emitted JS in apollo-wind's bundless build, so the rules moved into the shared stylesheet. + +export interface MarkdownPreviewProps { + tokens: PromptEditorToken[]; + minRows?: number; +} + +/** + * Strict DOMPurify whitelist — replaces defaults, no style attribute allowed. Includes lucide's SVG + * primitives (`line`, `polyline`, `circle`, `rect`, `polygon`, `ellipse`) so the token-type icons + * built by `buildTokenIconSvgMarkup` round-trip through sanitization intact. + */ +const PURIFY_CONFIG = { + ALLOWED_TAGS: [ + // text + 'p', + 'br', + 'span', + 'strong', + 'em', + 'b', + 'i', + 'u', + 's', + 'del', + 'ins', + 'mark', + 'sub', + 'sup', + 'small', + // headings + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + // lists + 'ul', + 'ol', + 'li', + // code + 'code', + 'pre', + // block + 'blockquote', + 'hr', + 'div', + // links + 'a', + // table + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + // inline SVG for token icons (lucide shapes) + 'svg', + 'path', + 'line', + 'polyline', + 'polygon', + 'circle', + 'ellipse', + 'rect', + 'g', + ], + ALLOWED_ATTR: [ + 'class', + // links — `target` is intentionally disallowed so `target="_blank"` can't enable tabnabbing + 'href', + 'rel', + // SVG common + 'viewBox', + 'xmlns', + 'width', + 'height', + 'fill', + 'stroke', + 'stroke-width', + 'stroke-linecap', + 'stroke-linejoin', + 'fill-rule', + 'clip-rule', + // + 'd', + // + 'x1', + 'x2', + 'y1', + 'y2', + // / + 'points', + // / + 'cx', + 'cy', + 'r', + // + 'x', + 'y', + 'rx', + 'ry', + // table + 'colspan', + 'rowspan', + ], +}; + +const escapeHtml = (str: string): string => + str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +/** + * Convert tokens to a markdown string, replacing variable tokens with styled HTML spans. The chip + * text is the verbatim `token.value` (full path) so authors always see the path they're referencing; + * the leading icon is chosen by token type to match what the editor renders in edit mode. + */ +const tokensToMarkdownWithPills = (tokens: PromptEditorToken[]): string => { + let md = ''; + for (const token of tokens) { + if (token.type === 'text') { + md += token.value; + } else { + const iconSvg = buildTokenIconSvgMarkup(token.type); + md += `${iconSvg}${escapeHtml(token.value)}`; + } + } + return md; +}; + +const LINE_HEIGHT = 20; +const VERTICAL_PADDING = 8; +const EMPTY_MESSAGE = 'Nothing to preview'; + +/** + * Renders the prompt tokens as sanitized markdown for preview mode. + * + * Preview is **visual-only**: every variable token renders as a pill regardless of whether its path + * still exists in `autoCompleteOptions`. Unlike edit mode — where `ValidateTokensPlugin` flags + * stale/unknown tokens as invalid — preview does not receive the option set and so does not reflect + * token validity. Switch to edit mode to see validation state. + */ +export const MarkdownPreview = ({ tokens, minRows = 4 }: MarkdownPreviewProps) => { + const html = useMemo(() => { + if (tokens.length === 0) + return `

${escapeHtml(EMPTY_MESSAGE)}

`; + const md = tokensToMarkdownWithPills(tokens); + const rawHtml = marked.parse(md) as string; + return DOMPurify.sanitize(rawHtml, PURIFY_CONFIG); + }, [tokens]); + + return ( +
+ ); +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/components/PromptEditorAutocompleteMenu.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/components/PromptEditorAutocompleteMenu.tsx new file mode 100644 index 000000000..407e6f6fe --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/components/PromptEditorAutocompleteMenu.tsx @@ -0,0 +1,182 @@ +import { Database, Paperclip, SquareFunction, Variable } from 'lucide-react'; +import { useEffect, useReducer, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { + getPromptEditorTokenTypeLabel, + type PromptEditorAutoCompleteOption, + type PromptEditorTokenType, +} from '../types'; + +const MENU_WIDTH = 320; +const VIEWPORT_MARGIN = 8; + +const OPTION_ICON: Record< + Exclude, + React.ComponentType<{ className?: string }> +> = { + input: Variable, + output: SquareFunction, + state: Database, + resource: Paperclip, +}; + +export interface PromptEditorAutocompleteMenuProps { + open: boolean; + anchorEl: { getBoundingClientRect: () => DOMRect; contextElement?: Element } | null; + /** Pre-fill the search input with the user's typed prefix (everything after `$`). */ + initialSearch: string; + /** Variable options to offer. */ + options: PromptEditorAutoCompleteOption[]; + /** Called with the selected option's value (path). */ + onSelect: (path: string) => void; + /** Called when the menu should close (Escape, click-outside, selection committed). */ + onClose: () => void; +} + +/** + * Resolve the portal target. When the editor lives inside a Radix Dialog, portal into the dialog's + * content node so the menu joins the dialog's focus scope — otherwise the focus trap steals focus + * back the moment the menu's search input mounts. Everything else portals to `document.body` so + * ancestor `opacity` / `filter` / `transform` can't leak through. + */ +function resolvePortalTarget(contextElement: Element | null | undefined): HTMLElement { + const dialogContent = contextElement?.closest?.('[role="dialog"]'); + if (dialogContent instanceof HTMLElement) return dialogContent; + return document.body; +} + +/** + * Caret-anchored variable picker for the prompt editor's `$` trigger. Built on apollo-wind's + * `Command` (cmdk) for search + keyboard nav, anchored to the Lexical caret rect via a + * `position: fixed` container that tracks the caret across scroll/resize. + */ +export const PromptEditorAutocompleteMenu = ({ + open, + anchorEl, + initialSearch, + options, + onSelect, + onClose, +}: PromptEditorAutocompleteMenuProps) => { + const containerRef = useRef(null); + const [search, setSearch] = useState(initialSearch); + + // Re-seed the search box whenever the trigger re-opens with a different typed prefix. + useEffect(() => { + if (open) setSearch(initialSearch); + }, [open, initialSearch]); + + // Click-outside → close. Pointerdown so we beat the editor's selection capture. + useEffect(() => { + if (!open) return; + const handlePointerDown = (e: PointerEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) onClose(); + }; + document.addEventListener('pointerdown', handlePointerDown, true); + return () => document.removeEventListener('pointerdown', handlePointerDown, true); + }, [open, onClose]); + + // Track the live caret rect across ancestor scrolls and window resizes so the menu follows the + // caret. The `anchorEl.getBoundingClientRect()` payload is itself live (computed from the editor + // root's current screen position + a saved offset), so the rect is read directly during render; a + // counter bumped on scroll/resize forces the re-read. Scrolls *inside* the menu are ignored. + const [, forceUpdate] = useReducer((tick: number) => tick + 1, 0); + useEffect(() => { + if (!open || !anchorEl) return; + let rafId: number | null = null; + const onScrollOrResize = (event: Event) => { + if (event.type === 'scroll' && containerRef.current?.contains(event.target as Node)) return; + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + forceUpdate(); + }); + }; + document.addEventListener('scroll', onScrollOrResize, true); + window.addEventListener('resize', onScrollOrResize); + return () => { + document.removeEventListener('scroll', onScrollOrResize, true); + window.removeEventListener('resize', onScrollOrResize); + if (rafId !== null) cancelAnimationFrame(rafId); + }; + }, [open, anchorEl]); + + // Stop pointer events from bubbling past the menu: a mousedown inside would otherwise transfer + // focus and drop the editor's caret, and (when portaled to body inside a Radix Dialog) trip the + // dialog's `onInteractOutside`. The search input still receives focus via cmdk's autofocus. + useEffect(() => { + if (!open) return; + const node = containerRef.current; + if (!node) return; + const handlePointerDown = (e: PointerEvent) => { + // preventDefault suppresses the follow-up mousedown so the browser can't move focus off the + // Lexical editor before `commitChip` runs — keeping the trigger node's caret/key valid even if + // a racing state update would otherwise invalidate `triggerInfo.nodeKey`. + e.preventDefault(); + e.stopPropagation(); + }; + node.addEventListener('pointerdown', handlePointerDown); + return () => node.removeEventListener('pointerdown', handlePointerDown); + }, [open]); + + if (!open || !anchorEl) return null; + + const rect = anchorEl.getBoundingClientRect(); + const left = Math.max( + VIEWPORT_MARGIN, + Math.min(rect.left, window.innerWidth - MENU_WIDTH - VIEWPORT_MARGIN) + ); + const portalTarget = resolvePortalTarget(anchorEl.contextElement); + + return createPortal( + // biome-ignore lint/a11y/noStaticElementInteractions: positioning wrapper that forwards Escape to close; the interactive surface is the cmdk Command (a combobox with its own keyboard handling). +
{ + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + }} + > + + + + No variables found. + {options.map((option) => { + const Icon = OPTION_ICON[option.type]; + return ( + onSelect(option.value)} + > + + {option.value} + + {getPromptEditorTokenTypeLabel(option.type)} + + + ); + })} + + +
, + portalTarget + ); +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/components/TokenPill.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/components/TokenPill.tsx new file mode 100644 index 000000000..59b816de4 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/components/TokenPill.tsx @@ -0,0 +1,122 @@ +import { memo } from 'react'; +import { Database, Paperclip, SquareFunction, Variable } from 'lucide-react'; +import { getPromptEditorTokenColors } from '../types'; +import type { PromptEditorDiffType, PromptEditorTokenType } from '../types'; + +/** Leading icon per token type — mirrors the SVG markup `MarkdownPreview` inlines for preview mode. */ +const TOKEN_TYPE_ICON: Record< + PromptEditorTokenType, + React.ComponentType<{ className?: string }> +> = { + input: Variable, + output: SquareFunction, + state: Database, + resource: Paperclip, + text: Variable, +}; + +export interface TokenPillProps { + value: string; + /** Token type — selects the leading icon and (via the wrapper) the tooltip label. */ + tokenType: PromptEditorTokenType; + onRemove?: () => void; + diffType?: PromptEditorDiffType; + readonly?: boolean; + isInvalid?: boolean; + /** True when the underlying Lexical decorator is in `NodeSelection` — drives the focus outline. */ + isSelected?: boolean; + /** Mouse-down on the pill body (not the X). The wrapper uses it to set NodeSelection on the decorator. */ + onMouseDown?: (e: React.MouseEvent) => void; +} + +const TokenPillBase = ({ + value, + tokenType, + onRemove, + diffType, + readonly, + isInvalid, + isSelected, + onMouseDown, +}: TokenPillProps) => { + const PROMPT_EDITOR_TOKEN_COLORS = getPromptEditorTokenColors(); + const colors = + isInvalid && !diffType ? PROMPT_EDITOR_TOKEN_COLORS.invalid : PROMPT_EDITOR_TOKEN_COLORS.valid; + const Icon = TOKEN_TYPE_ICON[tokenType]; + + const handleRemoveMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleRemoveClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onRemove?.(); + }; + + const bgColor = diffType + ? diffType === 'added' + ? 'rgba(76, 175, 80, 0.15)' + : 'rgba(244, 67, 54, 0.15)' + : colors.background; + + const outerClassName = [ + 'relative inline-flex items-center align-middle gap-[3px] h-5 rounded px-1 text-[13px] leading-5 outline-offset-1', + readonly ? 'cursor-default' : 'cursor-pointer', + diffType === 'removed' ? 'line-through opacity-60' : '', + isSelected ? 'z-10' : '', + ] + .filter(Boolean) + .join(' '); + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: the pill is a Lexical decorator chip; mousedown sets node selection. Keyboard selection is handled by Lexical at the editor root, and the chip exposes a focusable Remove button. + + + + + + {value} + + {!readonly && ( + + )} + + ); +}; + +export const TokenPill = memo(TokenPillBase); diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/components/TokenPillWithTooltip.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/components/TokenPillWithTooltip.tsx new file mode 100644 index 000000000..db3368e71 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/components/TokenPillWithTooltip.tsx @@ -0,0 +1,94 @@ +import { useCallback, useMemo } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'; +import { $createNodeSelection, $setSelection, type NodeKey } from 'lexical'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { TokenPill, type TokenPillProps } from './TokenPill'; +import { getPromptEditorTokenTypeLabel } from '../types'; + +export interface TokenPillWithTooltipProps extends TokenPillProps { + /** Lexical node key for the underlying decorator — drives `NodeSelection` click-to-focus. */ + nodeKey: NodeKey; +} + +/** + * Wraps `TokenPill` with a hover tooltip showing the token's path and type label. Invalid chips + * (not present in the editor's autocomplete options) surface a "not found" message paired with the + * red-invalid pill styling. Suppressed when the chip is in a diff state so it doesn't clash with the + * diff styling. + */ +export const TokenPillWithTooltip = ({ nodeKey, ...props }: TokenPillWithTooltipProps) => { + // Pull primitives off props so the memo deps below stay stable (a fresh `props` bag is created on + // every parent render even when its contents are unchanged). + const { value: pillValue, tokenType, readonly, diffType, isInvalid, onRemove } = props; + const typeLabel = getPromptEditorTokenTypeLabel(tokenType); + + const [editor] = useLexicalComposerContext(); + // Only `isSelected` from the hook — its `setSelected(true)` *adds* to the current NodeSelection, + // which would let multiple pills be focused at once. We replace it with a fresh single-key + // NodeSelection on mousedown. + const [isSelected] = useLexicalNodeSelection(nodeKey); + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (readonly) return; + // Stops the browser from creating a RangeSelection inside the decorator DOM. + e.preventDefault(); + e.stopPropagation(); + editor.update(() => { + const sel = $createNodeSelection(); + sel.add(nodeKey); + $setSelection(sel); + }); + // Lexical 0.42 editor.focus() no-ops for NodeSelection; focus the root so arrows reach Lexical. + const rootElement = editor.getRootElement(); + if (rootElement) { + rootElement.focus({ preventScroll: true }); + // Clear the browser's auto-placed caret so the pill outline is the only focus cue. + globalThis.getSelection()?.removeAllRanges(); + } + }, + [editor, nodeKey, readonly] + ); + + const pillProps = useMemo( + () => ({ + value: pillValue, + tokenType, + readonly, + diffType, + isInvalid, + onRemove, + isSelected, + onMouseDown: handleMouseDown, + }), + [pillValue, tokenType, readonly, diffType, isInvalid, onRemove, isSelected, handleMouseDown] + ); + + if (diffType) return ; + + return ( + + + + + + + + {isInvalid ? ( +
+
Variable not found
+
+ {pillValue} isn't available in this scope. Fix or remove this reference before + publishing. +
+
+ ) : ( +
+ {pillValue} + {typeLabel} +
+ )} +
+
+ ); +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/components/token-icon-markup.test.ts b/packages/apollo-wind/src/components/ui/prompt-editor/components/token-icon-markup.test.ts new file mode 100644 index 000000000..a85c4061b --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/components/token-icon-markup.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { buildTokenIconSvgMarkup } from './token-icon-markup'; + +describe('buildTokenIconSvgMarkup', () => { + it('emits a self-contained 14px lucide svg', () => { + const svg = buildTokenIconSvgMarkup('input'); + expect(svg.startsWith(' { + expect(buildTokenIconSvgMarkup('input')).toContain('lucide-variable'); + expect(buildTokenIconSvgMarkup('output')).toContain('lucide-square-function'); + expect(buildTokenIconSvgMarkup('state')).toContain('lucide-database'); + expect(buildTokenIconSvgMarkup('resource')).toContain('lucide-paperclip'); + }); + + it('falls back to the input glyph for text tokens', () => { + expect(buildTokenIconSvgMarkup('text')).toContain('lucide-variable'); + }); + + it('renders the ellipse element for the database (state) icon', () => { + expect(buildTokenIconSvgMarkup('state')).toContain('.js`) so the preview SVGs match exactly what `` renders. + */ + +import type { PromptEditorTokenType } from '../types'; + +type IconElement = + | { kind: 'path'; d: string } + | { kind: 'line'; x1: string; x2: string; y1: string; y2: string } + | { kind: 'circle'; cx: string; cy: string; r: string } + | { kind: 'ellipse'; cx: string; cy: string; rx: string; ry: string } + | { kind: 'rect'; x: string; y: string; width: string; height: string; rx?: string; ry?: string }; + +/** lucide `variable` — input variables. */ +const VARIABLE_ICON: IconElement[] = [ + { kind: 'path', d: 'M8 21s-4-3-4-9 4-9 4-9' }, + { kind: 'path', d: 'M16 3s4 3 4 9-4 9-4 9' }, + { kind: 'line', x1: '15', x2: '9', y1: '9', y2: '15' }, + { kind: 'line', x1: '9', x2: '15', y1: '9', y2: '15' }, +]; + +/** lucide `square-function` — output variables. */ +const SQUARE_FUNCTION_ICON: IconElement[] = [ + { kind: 'rect', x: '3', y: '3', width: '18', height: '18', rx: '2', ry: '2' }, + { kind: 'path', d: 'M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3' }, + { kind: 'path', d: 'M9 11.2h5.7' }, +]; + +/** lucide `database` — state variables. */ +const DATABASE_ICON: IconElement[] = [ + { kind: 'ellipse', cx: '12', cy: '5', rx: '9', ry: '3' }, + { kind: 'path', d: 'M3 5V19A9 3 0 0 0 21 19V5' }, + { kind: 'path', d: 'M3 12A9 3 0 0 0 21 12' }, +]; + +/** lucide `paperclip` — resources. */ +const PAPERCLIP_ICON: IconElement[] = [ + { + kind: 'path', + d: 'm16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551', + }, +]; + +interface IconChoice { + name: string; + body: IconElement[]; +} + +const TOKEN_TYPE_ICON: Record, IconChoice> = { + input: { name: 'variable', body: VARIABLE_ICON }, + output: { name: 'square-function', body: SQUARE_FUNCTION_ICON }, + state: { name: 'database', body: DATABASE_ICON }, + resource: { name: 'paperclip', body: PAPERCLIP_ICON }, +}; + +const renderElement = (el: IconElement): string => { + switch (el.kind) { + case 'path': + return ``; + case 'line': + return ``; + case 'circle': + return ``; + case 'ellipse': + return ``; + case 'rect': + return ( + `` + ); + } +}; + +const wrapSvg = (iconName: string, body: IconElement[]): string => + `${body.map(renderElement).join('')}`; + +/** + * Build the inline SVG markup for a token chip's leading icon, keyed by token type. Output mirrors + * what `` renders in edit mode (lucide v0.577.0), suitable for embedding in a markdown + * HTML string. Non-variable token types fall back to the input (`variable`) glyph. + */ +export function buildTokenIconSvgMarkup(type: PromptEditorTokenType): string { + const choice = type === 'text' ? TOKEN_TYPE_ICON.input : TOKEN_TYPE_ICON[type]; + return wrapSvg(choice.name, choice.body); +} diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/index.ts b/packages/apollo-wind/src/components/ui/prompt-editor/index.ts new file mode 100644 index 000000000..237e16064 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/index.ts @@ -0,0 +1,8 @@ +export { PromptEditor } from './prompt-editor'; +export type { PromptEditorProps, PromptEditorRef } from './prompt-editor'; +export type { + PromptEditorToken, + PromptEditorTokenType, + PromptEditorAutoCompleteOption, + PromptEditorMode, +} from './types'; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/nodes/InputTokenNode.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/nodes/InputTokenNode.tsx new file mode 100644 index 000000000..808700ed3 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/nodes/InputTokenNode.tsx @@ -0,0 +1,136 @@ +import type { ReactNode } from 'react'; +import { + $applyNodeReplacement, + DecoratorNode, + type DOMConversionMap, + type DOMExportOutput, + type LexicalEditor, + type LexicalNode, + type NodeKey, + type SerializedLexicalNode, + type Spread, +} from 'lexical'; +import { TokenPillWithTooltip } from '../components/TokenPillWithTooltip'; +import type { PromptEditorDiffType } from '../types'; + +export type SerializedInputTokenNode = Spread<{ value: string }, SerializedLexicalNode>; + +export class InputTokenNode extends DecoratorNode { + __value: string; + __diffType?: PromptEditorDiffType; + __isInvalid?: boolean; + + static getType(): string { + return 'input-token'; + } + + static clone(node: InputTokenNode): InputTokenNode { + const cloned = new InputTokenNode(node.__value, node.__key, node.__diffType); + cloned.__isInvalid = node.__isInvalid; + return cloned; + } + + constructor(value: string, key?: NodeKey, diffType?: PromptEditorDiffType) { + super(key); + this.__value = value; + this.__diffType = diffType; + } + + getValue(): string { + return this.__value; + } + + setValue(value: string): this { + const self = this.getWritable(); + self.__value = value; + return self; + } + + getIsInvalid(): boolean { + return this.__isInvalid ?? false; + } + + setIsInvalid(isInvalid: boolean): this { + const self = this.getWritable(); + self.__isInvalid = isInvalid; + return self; + } + + createDOM(): HTMLElement { + const span = document.createElement('span'); + span.style.display = 'inline'; + return span; + } + + updateDOM(): false { + return false; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('span'); + element.dataset.lexicalInputToken = 'true'; + element.dataset.value = this.__value; + element.textContent = this.__value; + return { element }; + } + + static importDOM(): DOMConversionMap | null { + return { + span: (domNode: HTMLElement) => { + if (!('lexicalInputToken' in domNode.dataset)) return null; + return { + conversion: (element: HTMLElement) => ({ + node: new InputTokenNode(element.dataset.value ?? ''), + }), + priority: 1, + }; + }, + }; + } + + static importJSON(serializedNode: SerializedInputTokenNode): InputTokenNode { + return new InputTokenNode(serializedNode.value); + } + + exportJSON(): SerializedInputTokenNode { + return { type: 'input-token', value: this.__value, version: 1 }; + } + + getTextContent(): string { + return this.__value; + } + isInline(): boolean { + return true; + } + isKeyboardSelectable(): boolean { + return true; + } + + decorate(editor: LexicalEditor): ReactNode { + const readonly = !editor.isEditable(); + return ( + { + if (!readonly) + editor.update(() => { + this.remove(); + }); + }} + /> + ); + } +} + +export const createInputTokenNode = ( + value: string, + diffType?: PromptEditorDiffType +): InputTokenNode => $applyNodeReplacement(new InputTokenNode(value, undefined, diffType)); + +export const isInputTokenNode = (node: LexicalNode | null | undefined): node is InputTokenNode => + node instanceof InputTokenNode; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/nodes/OutputTokenNode.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/nodes/OutputTokenNode.tsx new file mode 100644 index 000000000..0a9c5ca47 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/nodes/OutputTokenNode.tsx @@ -0,0 +1,136 @@ +import type { ReactNode } from 'react'; +import { + $applyNodeReplacement, + DecoratorNode, + type DOMConversionMap, + type DOMExportOutput, + type LexicalEditor, + type LexicalNode, + type NodeKey, + type SerializedLexicalNode, + type Spread, +} from 'lexical'; +import { TokenPillWithTooltip } from '../components/TokenPillWithTooltip'; +import type { PromptEditorDiffType } from '../types'; + +export type SerializedOutputTokenNode = Spread<{ value: string }, SerializedLexicalNode>; + +export class OutputTokenNode extends DecoratorNode { + __value: string; + __diffType?: PromptEditorDiffType; + __isInvalid?: boolean; + + static getType(): string { + return 'output-token'; + } + + static clone(node: OutputTokenNode): OutputTokenNode { + const cloned = new OutputTokenNode(node.__value, node.__key, node.__diffType); + cloned.__isInvalid = node.__isInvalid; + return cloned; + } + + constructor(value: string, key?: NodeKey, diffType?: PromptEditorDiffType) { + super(key); + this.__value = value; + this.__diffType = diffType; + } + + getValue(): string { + return this.__value; + } + + setValue(value: string): this { + const self = this.getWritable(); + self.__value = value; + return self; + } + + getIsInvalid(): boolean { + return this.__isInvalid ?? false; + } + + setIsInvalid(isInvalid: boolean): this { + const self = this.getWritable(); + self.__isInvalid = isInvalid; + return self; + } + + createDOM(): HTMLElement { + const span = document.createElement('span'); + span.style.display = 'inline'; + return span; + } + + updateDOM(): false { + return false; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('span'); + element.dataset.lexicalOutputToken = 'true'; + element.dataset.value = this.__value; + element.textContent = this.__value; + return { element }; + } + + static importDOM(): DOMConversionMap | null { + return { + span: (domNode: HTMLElement) => { + if (!('lexicalOutputToken' in domNode.dataset)) return null; + return { + conversion: (element: HTMLElement) => ({ + node: new OutputTokenNode(element.dataset.value ?? ''), + }), + priority: 1, + }; + }, + }; + } + + static importJSON(serializedNode: SerializedOutputTokenNode): OutputTokenNode { + return new OutputTokenNode(serializedNode.value); + } + + exportJSON(): SerializedOutputTokenNode { + return { type: 'output-token', value: this.__value, version: 1 }; + } + + getTextContent(): string { + return this.__value; + } + isInline(): boolean { + return true; + } + isKeyboardSelectable(): boolean { + return true; + } + + decorate(editor: LexicalEditor): ReactNode { + const readonly = !editor.isEditable(); + return ( + { + if (!readonly) + editor.update(() => { + this.remove(); + }); + }} + /> + ); + } +} + +export const createOutputTokenNode = ( + value: string, + diffType?: PromptEditorDiffType +): OutputTokenNode => $applyNodeReplacement(new OutputTokenNode(value, undefined, diffType)); + +export const isOutputTokenNode = (node: LexicalNode | null | undefined): node is OutputTokenNode => + node instanceof OutputTokenNode; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/nodes/ResourceTokenNode.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/nodes/ResourceTokenNode.tsx new file mode 100644 index 000000000..72f1a43be --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/nodes/ResourceTokenNode.tsx @@ -0,0 +1,137 @@ +import type { ReactNode } from 'react'; +import { + $applyNodeReplacement, + DecoratorNode, + type DOMConversionMap, + type DOMExportOutput, + type LexicalEditor, + type LexicalNode, + type NodeKey, + type SerializedLexicalNode, + type Spread, +} from 'lexical'; +import { TokenPillWithTooltip } from '../components/TokenPillWithTooltip'; +import type { PromptEditorDiffType } from '../types'; + +export type SerializedResourceTokenNode = Spread<{ value: string }, SerializedLexicalNode>; + +export class ResourceTokenNode extends DecoratorNode { + __value: string; + __diffType?: PromptEditorDiffType; + __isInvalid?: boolean; + + static getType(): string { + return 'resource-token'; + } + + static clone(node: ResourceTokenNode): ResourceTokenNode { + const cloned = new ResourceTokenNode(node.__value, node.__key, node.__diffType); + cloned.__isInvalid = node.__isInvalid; + return cloned; + } + + constructor(value: string, key?: NodeKey, diffType?: PromptEditorDiffType) { + super(key); + this.__value = value; + this.__diffType = diffType; + } + + getValue(): string { + return this.__value; + } + + setValue(value: string): this { + const self = this.getWritable(); + self.__value = value; + return self; + } + + getIsInvalid(): boolean { + return this.__isInvalid ?? false; + } + + setIsInvalid(isInvalid: boolean): this { + const self = this.getWritable(); + self.__isInvalid = isInvalid; + return self; + } + + createDOM(): HTMLElement { + const span = document.createElement('span'); + span.style.display = 'inline'; + return span; + } + + updateDOM(): false { + return false; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('span'); + element.dataset.lexicalResourceToken = 'true'; + element.dataset.value = this.__value; + element.textContent = this.__value; + return { element }; + } + + static importDOM(): DOMConversionMap | null { + return { + span: (domNode: HTMLElement) => { + if (!('lexicalResourceToken' in domNode.dataset)) return null; + return { + conversion: (element: HTMLElement) => ({ + node: new ResourceTokenNode(element.dataset.value ?? ''), + }), + priority: 1, + }; + }, + }; + } + + static importJSON(serializedNode: SerializedResourceTokenNode): ResourceTokenNode { + return new ResourceTokenNode(serializedNode.value); + } + + exportJSON(): SerializedResourceTokenNode { + return { type: 'resource-token', value: this.__value, version: 1 }; + } + + getTextContent(): string { + return this.__value; + } + isInline(): boolean { + return true; + } + isKeyboardSelectable(): boolean { + return true; + } + + decorate(editor: LexicalEditor): ReactNode { + const readonly = !editor.isEditable(); + return ( + { + if (!readonly) + editor.update(() => { + this.remove(); + }); + }} + /> + ); + } +} + +export const createResourceTokenNode = ( + value: string, + diffType?: PromptEditorDiffType +): ResourceTokenNode => $applyNodeReplacement(new ResourceTokenNode(value, undefined, diffType)); + +export const isResourceTokenNode = ( + node: LexicalNode | null | undefined +): node is ResourceTokenNode => node instanceof ResourceTokenNode; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/nodes/StateTokenNode.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/nodes/StateTokenNode.tsx new file mode 100644 index 000000000..ddd2180db --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/nodes/StateTokenNode.tsx @@ -0,0 +1,136 @@ +import type { ReactNode } from 'react'; +import { + $applyNodeReplacement, + DecoratorNode, + type DOMConversionMap, + type DOMExportOutput, + type LexicalEditor, + type LexicalNode, + type NodeKey, + type SerializedLexicalNode, + type Spread, +} from 'lexical'; +import { TokenPillWithTooltip } from '../components/TokenPillWithTooltip'; +import type { PromptEditorDiffType } from '../types'; + +export type SerializedStateTokenNode = Spread<{ value: string }, SerializedLexicalNode>; + +export class StateTokenNode extends DecoratorNode { + __value: string; + __diffType?: PromptEditorDiffType; + __isInvalid?: boolean; + + static getType(): string { + return 'state-token'; + } + + static clone(node: StateTokenNode): StateTokenNode { + const cloned = new StateTokenNode(node.__value, node.__key, node.__diffType); + cloned.__isInvalid = node.__isInvalid; + return cloned; + } + + constructor(value: string, key?: NodeKey, diffType?: PromptEditorDiffType) { + super(key); + this.__value = value; + this.__diffType = diffType; + } + + getValue(): string { + return this.__value; + } + + setValue(value: string): this { + const self = this.getWritable(); + self.__value = value; + return self; + } + + getIsInvalid(): boolean { + return this.__isInvalid ?? false; + } + + setIsInvalid(isInvalid: boolean): this { + const self = this.getWritable(); + self.__isInvalid = isInvalid; + return self; + } + + createDOM(): HTMLElement { + const span = document.createElement('span'); + span.style.display = 'inline'; + return span; + } + + updateDOM(): false { + return false; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('span'); + element.dataset.lexicalStateToken = 'true'; + element.dataset.value = this.__value; + element.textContent = this.__value; + return { element }; + } + + static importDOM(): DOMConversionMap | null { + return { + span: (domNode: HTMLElement) => { + if (!('lexicalStateToken' in domNode.dataset)) return null; + return { + conversion: (element: HTMLElement) => ({ + node: new StateTokenNode(element.dataset.value ?? ''), + }), + priority: 1, + }; + }, + }; + } + + static importJSON(serializedNode: SerializedStateTokenNode): StateTokenNode { + return new StateTokenNode(serializedNode.value); + } + + exportJSON(): SerializedStateTokenNode { + return { type: 'state-token', value: this.__value, version: 1 }; + } + + getTextContent(): string { + return this.__value; + } + isInline(): boolean { + return true; + } + isKeyboardSelectable(): boolean { + return true; + } + + decorate(editor: LexicalEditor): ReactNode { + const readonly = !editor.isEditable(); + return ( + { + if (!readonly) + editor.update(() => { + this.remove(); + }); + }} + /> + ); + } +} + +export const createStateTokenNode = ( + value: string, + diffType?: PromptEditorDiffType +): StateTokenNode => $applyNodeReplacement(new StateTokenNode(value, undefined, diffType)); + +export const isStateTokenNode = (node: LexicalNode | null | undefined): node is StateTokenNode => + node instanceof StateTokenNode; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/nodes/index.ts b/packages/apollo-wind/src/components/ui/prompt-editor/nodes/index.ts new file mode 100644 index 000000000..7f8f8018b --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/nodes/index.ts @@ -0,0 +1,15 @@ +export { createInputTokenNode, InputTokenNode, isInputTokenNode } from './InputTokenNode'; +export type { SerializedInputTokenNode } from './InputTokenNode'; + +export { createOutputTokenNode, isOutputTokenNode, OutputTokenNode } from './OutputTokenNode'; +export type { SerializedOutputTokenNode } from './OutputTokenNode'; + +export { createStateTokenNode, isStateTokenNode, StateTokenNode } from './StateTokenNode'; +export type { SerializedStateTokenNode } from './StateTokenNode'; + +export { + createResourceTokenNode, + isResourceTokenNode, + ResourceTokenNode, +} from './ResourceTokenNode'; +export type { SerializedResourceTokenNode } from './ResourceTokenNode'; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/plugins/AutocompletePlugin.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/AutocompletePlugin.tsx new file mode 100644 index 000000000..bf6fae48f --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/AutocompletePlugin.tsx @@ -0,0 +1,351 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { mergeRegister } from '@lexical/utils'; +import { + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_HIGH, + getDOMSelectionFromTarget, + KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, + type LexicalEditor, +} from 'lexical'; +import { PromptEditorAutocompleteMenu } from '../components/PromptEditorAutocompleteMenu'; +import type { PromptEditorAutoCompleteOption } from '../types'; +import { createTokenNodeForOption } from '../utils/insert-token'; +import { + inferTokenTypeFromPath, + shouldSuppressOpenForDismissed, + VARIABLE_PATH_REGEX, +} from '../utils/autocomplete-segments'; + +const findTrigger = ( + text: string, + cursorPos: number +): { triggerIndex: number; query: string } | null => { + let triggerIndex = -1; + for (let i = cursorPos - 1; i >= 0; i--) { + const char = text[i]; + if (/\s/.test(char)) break; + if (char === '$') { + if (i === 0 || /\s/.test(text[i - 1])) triggerIndex = i; + break; + } + } + if (triggerIndex === -1) return null; + return { triggerIndex, query: text.slice(triggerIndex + 1, cursorPos) }; +}; + +interface VirtualAnchorElement { + getBoundingClientRect: () => DOMRect; + contextElement?: Element; +} + +const getCaretRectForEditor = (editor: LexicalEditor): DOMRect => { + const rootElement = editor.getRootElement(); + const fallbackRect = rootElement?.getBoundingClientRect() ?? new DOMRect(); + const domSelection = getDOMSelectionFromTarget(rootElement); + if (!domSelection || domSelection.rangeCount === 0) return fallbackRect; + + const range = domSelection.getRangeAt(0).cloneRange(); + range.collapse(false); + const rects = typeof range.getClientRects === 'function' ? range.getClientRects() : null; + const rect = rects?.item(0) ?? range.getBoundingClientRect(); + if (!Number.isFinite(rect.x) || !Number.isFinite(rect.y)) return fallbackRect; + if (rect.width === 0 && rect.height === 0) return fallbackRect; + return rect; +}; + +/** + * Drives the `$`-trigger flow: detects when the user has typed a `$` in a valid trigger context, + * opens `` anchored to the caret, threads selection back into a + * Lexical token chip on commit, and handles dismissal (Escape, click-outside, scroll, free-form + * Enter on a typed-but-unmatched `$prefix.path`). + * + * The picker (a wrapper around the canvas-wide `VariablePicker`) owns search, drill-in, and + * keyboard navigation while it has focus. This plugin only handles the parts that have to live in + * the Lexical editor: trigger detection, dismissal sentinel, free-form Enter fallback after + * dismissal, and refocusing the editor when the picker closes. + */ +export const AutocompletePlugin = ({ options }: { options: PromptEditorAutoCompleteOption[] }) => { + const [editor] = useLexicalComposerContext(); + const [open, setOpen] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + // Tracks the user's typed prefix after `$` so the `VariablePicker` can pre-fill its search box + // with whatever the user has already typed (e.g. `$vars.start.` → search seeded with `vars.start.`). + const [query, setQuery] = useState(''); + + const triggerInfoRef = useRef<{ triggerIndex: number; nodeKey: string } | null>(null); + // Snapshot of the last `$` position the picker was dismissed for. Used to suppress reopening when + // the user clicks back to the same `$` after dismissing — gives them a window to Backspace it + // without the picker re-grabbing focus. Reset whenever the editor's text content changes (any new + // typing means the user re-engaged the trigger flow). + const dismissedTriggerRef = useRef<{ triggerIndex: number; nodeKey: string } | null>(null); + const optionsRef = useRef(options); + + const openRef = useRef(open); + + useEffect(() => { + optionsRef.current = options; + openRef.current = open; + }); + + const closeMenu = useCallback( + (explicit = false) => { + // Only remember the dismissed `$` position on an *explicit* dismissal (Escape / click-outside), + // so the update listener can suppress re-opening for that exact position. Auto-closes from the + // update listener (selection moved, whitespace typed, no trigger) must NOT snapshot — otherwise + // moving the caret back to the same `$` would be wrongly suppressed. Cleared when the user types. + const wasOpen = openRef.current; + if (explicit && triggerInfoRef.current) + dismissedTriggerRef.current = { ...triggerInfoRef.current }; + setOpen(false); + setAnchorEl(null); + triggerInfoRef.current = null; + // Refocus the editor only when an open picker is actually closing (Escape, click-outside, + // selection-commit). Without this guard, every "no trigger detected" close path would call + // editor.focus() — Lexical's focus() runs an internal editor.update() that re-fires the same + // update listener, creating an infinite recursion that froze the properties panel on mount. + if (wasOpen) editor.focus(); + }, + [editor] + ); + + /** Commit a chip with the given option's value. Used by the picker's onSelect adapter and free-form Enter. */ + const commitChip = useCallback( + (option: PromptEditorAutoCompleteOption) => { + const triggerInfo = triggerInfoRef.current; + if (!triggerInfo) return; + + editor.update(() => { + const selection = $getSelection(); + if (!selection || !$isRangeSelection(selection)) return; + const anchorNode = selection.anchor.getNode(); + if (!$isTextNode(anchorNode)) return; + // The trigger sentinel is captured against a specific text node. If the cursor has since + // jumped to a different node — e.g. the user moved the caret, or a `queueMicrotask` deferred + // the commit past a node split — `triggerInfo.triggerIndex` is no longer a valid offset + // into `anchorNode.getTextContent()`. Bail rather than slice the wrong content. + if (anchorNode.getKey() !== triggerInfo.nodeKey) return; + + const textContent = anchorNode.getTextContent(); + const cursorOffset = selection.anchor.offset; + const textBefore = textContent.slice(0, triggerInfo.triggerIndex); + const textAfter = textContent.slice(cursorOffset); + + const tokenNode = createTokenNodeForOption(option); + + if (textBefore) { + anchorNode.setTextContent(textBefore); + anchorNode.insertAfter(tokenNode); + } else { + anchorNode.replace(tokenNode); + } + + if (textAfter) { + const textAfterNode = $createTextNode(textAfter); + tokenNode.insertAfter(textAfterNode); + textAfterNode.select(0, 0); + } else { + tokenNode.selectNext(0, 0); + } + }); + + closeMenu(); + }, + [closeMenu, editor] + ); + + /** + * Free-form commit: typed text doesn't match any expanded option, but is a syntactically valid + * `$prefix.path`. Commit it verbatim as a chip — `ValidateTokensPlugin` will mark it red so the user + * sees the resolution gap, but the leaf segment is preserved instead of silently dropped. Reachable + * after dismissal (Escape) when the trigger sentinel is still set: the user can keep typing and + * press Enter to commit the typed path. Returns `true` if a chip was committed. + */ + const commitTypedAsChip = useCallback((): boolean => { + const triggerInfo = triggerInfoRef.current; + if (!triggerInfo) return false; + + let committed = false; + editor.read(() => { + const selection = $getSelection(); + if (!selection || !$isRangeSelection(selection)) return; + const anchorNode = selection.anchor.getNode(); + if (!$isTextNode(anchorNode)) return; + // Same anchor-node sanity check as `commitChip`: the typed-prefix slice below uses + // `triggerInfo.triggerIndex` as an offset into `anchorNode.getTextContent()`, which is only + // meaningful when the anchor still matches the node the trigger sentinel was captured against. + if (anchorNode.getKey() !== triggerInfo.nodeKey) return; + + const textContent = anchorNode.getTextContent(); + const cursorOffset = selection.anchor.offset; + const typedQuery = textContent.slice(triggerInfo.triggerIndex + 1, cursorOffset); + if (!VARIABLE_PATH_REGEX.test(typedQuery)) return; + + // Commit the path WITHOUT the `$` trigger char so the chip value matches the convention used + // by menu-selected chips and `autoCompleteOptions` (e.g. `vars.foo`), keeping type inference + // and `ValidateTokensPlugin` validation consistent. + const inferredType = inferTokenTypeFromPath(typedQuery, optionsRef.current); + committed = true; + // Defer the actual mutation until after we exit the read. + queueMicrotask(() => commitChip({ type: inferredType, value: typedQuery })); + }); + return committed; + }, [commitChip, editor]); + + /** + * Open the picker for a freshly-detected `$` trigger. Extracted from the update listener so the + * `() => caretRect` lambda inside `setAnchorEl` doesn't bury us five callbacks deep + * (component → useEffect → registerUpdateListener → editorState.read → arrow), which Sonar's + * nested-callback rule flags. With the extraction, the lambda lives at depth 3 instead. + */ + const openPickerForTrigger = useCallback( + (trigger: { triggerIndex: number; query: string }, anchorNodeKey: string) => { + triggerInfoRef.current = { triggerIndex: trigger.triggerIndex, nodeKey: anchorNodeKey }; + setQuery(trigger.query); + + try { + const domSelection = getDOMSelectionFromTarget(editor.getRootElement()); + if (domSelection && domSelection.rangeCount > 0) { + // Capture the caret's position relative to the editor root NOW, while the DOM selection + // is still valid. Once the picker mounts, its search input auto-focuses and the editor's + // selection is dropped — so we can never recompute the caret rect from selection again. + // Instead, save the offset from the editor root's current rect and reconstruct the live + // caret rect on every `getBoundingClientRect()` call from the editor root's *current* + // screen position. The editor root's `getBoundingClientRect()` is always live (no + // selection needed), so as the page scrolls, the popover anchor naturally tracks. + const editorRoot = editor.getRootElement(); + const caretRect = getCaretRectForEditor(editor); + const editorRect = editorRoot?.getBoundingClientRect() ?? new DOMRect(); + const offsetTop = caretRect.top - editorRect.top; + const offsetLeft = caretRect.left - editorRect.left; + const caretWidth = caretRect.width; + const caretHeight = caretRect.height; + setAnchorEl({ + getBoundingClientRect: () => { + const live = editorRoot?.getBoundingClientRect() ?? new DOMRect(); + return new DOMRect( + live.left + offsetLeft, + live.top + offsetTop, + caretWidth, + caretHeight + ); + }, + contextElement: editorRoot ?? undefined, + }); + setOpen(true); + } else { + closeMenu(); + } + } catch { + closeMenu(); + } + }, + [closeMenu, editor] + ); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({ editorState, prevEditorState }) => { + // Detect whether this update changed the editor's text content (vs. just moving the + // selection / cursor). Any text change means the user re-engaged the trigger flow, so we + // forget the dismissed-`$` sentinel and let the next valid trigger open the picker fresh. + const prevText = prevEditorState.read(() => $getRoot().getTextContent()); + const currText = editorState.read(() => $getRoot().getTextContent()); + const textChanged = prevText !== currText; + if (textChanged) dismissedTriggerRef.current = null; + + editorState.read(() => { + const selection = $getSelection(); + if (!selection || !$isRangeSelection(selection) || !selection.isCollapsed()) { + closeMenu(); + return; + } + + const anchorNode = selection.anchor.getNode(); + if (!$isTextNode(anchorNode)) { + closeMenu(); + return; + } + + const textContent = anchorNode.getTextContent(); + const trigger = findTrigger(textContent, selection.anchor.offset); + if (!trigger) { + closeMenu(); + return; + } + + // Suppress reopening when the user has already dismissed this exact `$` position. They + // may have clicked away and back, or pressed Escape and parked the cursor next to the + // `$` — either way they want a window to Backspace without the picker grabbing focus. + if ( + shouldSuppressOpenForDismissed( + { triggerIndex: trigger.triggerIndex, nodeKey: anchorNode.getKey() }, + dismissedTriggerRef.current + ) + ) { + return; + } + + openPickerForTrigger(trigger, anchorNode.getKey()); + }); + }), + + editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + // Reachable when the user has typed a `$prefix.path` that doesn't match any scope entry, + // dismissed the picker, then pressed Enter to commit the typed text verbatim. The picker + // intercepts Enter while it has focus, so this handler only fires when focus is back on + // the editor and the trigger sentinel is still set. + if (!triggerInfoRef.current) return false; + if (commitTypedAsChip()) { + event?.preventDefault(); + return true; + } + return false; + }, + COMMAND_PRIORITY_HIGH + ), + + editor.registerCommand( + KEY_ESCAPE_COMMAND, + () => { + if (!openRef.current && !triggerInfoRef.current) return false; + closeMenu(true); // explicit dismissal — remember this `$` so it doesn't immediately reopen + return true; + }, + COMMAND_PRIORITY_HIGH + ) + ); + }, [closeMenu, commitTypedAsChip, editor, openPickerForTrigger]); + + /** + * The menu hands us the selected path; translate to the `{ type, value }` shape `commitChip` + * expects. The token type is looked up from the option set (exact match → the option's own type; + * otherwise inferred from the closest known ancestor path) so the chip renders as the correct + * workflow-role for validation purposes. + */ + const handleVariablePickerSelect = useCallback( + (path: string) => { + const inferredType = inferTokenTypeFromPath(path, optionsRef.current); + commitChip({ type: inferredType, value: path }); + }, + [commitChip] + ); + + return ( + closeMenu(true)} + /> + ); +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/plugins/CopyPastePlugin.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/CopyPastePlugin.tsx new file mode 100644 index 000000000..ca2a0b357 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/CopyPastePlugin.tsx @@ -0,0 +1,195 @@ +import { useEffect } from 'react'; +import { + $generateNodesFromSerializedNodes, + $getLexicalContent, + $insertGeneratedNodes, +} from '@lexical/clipboard'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { mergeRegister } from '@lexical/utils'; +import { + $createRangeSelectionFromDom, + $getSelection, + $isRangeSelection, + type BaseSelection, + COMMAND_PRIORITY_CRITICAL, + COPY_COMMAND, + CUT_COMMAND, + type LexicalEditor, + type LexicalNode, + PASTE_COMMAND, +} from 'lexical'; +import { + createInputTokenNode, + createOutputTokenNode, + createStateTokenNode, + createResourceTokenNode, +} from '../nodes'; +import type { PromptEditorToken } from '../types'; +import { + clipboardStringToTokens, + getEditorTokensFromSelection, + tokensToClipboardString, +} from '../utils'; + +const LEXICAL_MIME = 'application/x-lexical-editor'; + +const copySelectionToClipboard = ( + editor: LexicalEditor, + clipboardData: DataTransfer, + selection: BaseSelection +): boolean => { + const tokens = getEditorTokensFromSelection(selection); + if (tokens.length === 0) return false; + clipboardData.setData('text/plain', tokensToClipboardString(tokens)); + const lexicalJson = $getLexicalContent(editor, selection); + if (lexicalJson) clipboardData.setData(LEXICAL_MIME, lexicalJson); + return true; +}; + +const tryPasteLexicalContent = ( + editor: LexicalEditor, + clipboardData: DataTransfer, + selection: BaseSelection +): boolean => { + const lexicalJson = clipboardData.getData(LEXICAL_MIME); + if (!lexicalJson) return false; + try { + const parsed = JSON.parse(lexicalJson); + if (!parsed.nodes || !Array.isArray(parsed.nodes)) return false; + const nodes = $generateNodesFromSerializedNodes(parsed.nodes); + if (nodes.length === 0) return false; + $insertGeneratedNodes(editor, nodes, selection); + return true; + } catch { + return false; + } +}; + +const insertTokensAtSelection = (tokens: PromptEditorToken[], selection: BaseSelection) => { + if (!$isRangeSelection(selection)) return; + if (!selection.isCollapsed()) selection.removeText(); + + for (const token of tokens) { + if (token.type === 'text') { + const lines = token.value.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i]) selection.insertText(lines[i]); + if (i < lines.length - 1) selection.insertParagraph(); + } + } else { + let tokenNode: LexicalNode; + switch (token.type) { + case 'input': + tokenNode = createInputTokenNode(token.value); + break; + case 'output': + tokenNode = createOutputTokenNode(token.value); + break; + case 'state': + tokenNode = createStateTokenNode(token.value); + break; + case 'resource': + tokenNode = createResourceTokenNode(token.value); + break; + default: + throw new Error('Unknown token type'); + } + if (tokenNode) selection.insertNodes([tokenNode]); + } + } +}; + +const pasteTextContent = (text: string, selection: BaseSelection): boolean => { + if (!text) return false; + const tokens = clipboardStringToTokens(text); + if (tokens.length === 0) return false; + insertTokensAtSelection(tokens, selection); + return true; +}; + +export const CopyPastePlugin = () => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + COPY_COMMAND, + (event: ClipboardEvent | KeyboardEvent | null) => { + if (!event || !('clipboardData' in event) || !event.clipboardData) return false; + let handled = false; + editor.read(() => { + let selection = $getSelection(); + if (!selection || ($isRangeSelection(selection) && selection.isCollapsed())) { + const domSelection = window.getSelection(); + const rootElement = editor.getRootElement(); + if (domSelection && rootElement && !domSelection.isCollapsed) { + selection = $createRangeSelectionFromDom(domSelection, editor); + } + } + if (!selection || ($isRangeSelection(selection) && selection.isCollapsed())) return; + handled = copySelectionToClipboard(editor, event.clipboardData!, selection); + }); + if (handled) event.preventDefault(); + return handled; + }, + COMMAND_PRIORITY_CRITICAL + ), + + editor.registerCommand( + CUT_COMMAND, + (event: ClipboardEvent | KeyboardEvent | null) => { + if (!event || !('clipboardData' in event) || !event.clipboardData) return false; + let handled = false; + editor.read(() => { + let selection = $getSelection(); + if (!selection || ($isRangeSelection(selection) && selection.isCollapsed())) { + const domSelection = window.getSelection(); + const rootElement = editor.getRootElement(); + if (domSelection && rootElement && !domSelection.isCollapsed) { + selection = $createRangeSelectionFromDom(domSelection, editor); + } + } + if (!selection || ($isRangeSelection(selection) && selection.isCollapsed())) return; + handled = copySelectionToClipboard(editor, event.clipboardData!, selection); + }); + if (handled) { + event.preventDefault(); + editor.update(() => { + const selection = $getSelection(); + if (selection && $isRangeSelection(selection)) selection.removeText(); + }); + } + return handled; + }, + COMMAND_PRIORITY_CRITICAL + ), + + editor.registerCommand( + PASTE_COMMAND, + (event: ClipboardEvent | KeyboardEvent | null) => { + if (!event || !('clipboardData' in event) || !event.clipboardData) return false; + const clipboardData = event.clipboardData; + const hasLexicalJson = Boolean(clipboardData.getData(LEXICAL_MIME)); + const plainText = clipboardData.getData('text/plain'); + const hasPlainText = Boolean(plainText); + if (!hasLexicalJson && !hasPlainText) return false; + event.preventDefault(); + editor.update( + () => { + const selection = $getSelection(); + if (!selection) return; + if (hasLexicalJson && tryPasteLexicalContent(editor, clipboardData, selection)) + return; + if (hasPlainText) pasteTextContent(plainText, selection); + }, + { discrete: true } + ); + return true; + }, + COMMAND_PRIORITY_CRITICAL + ) + ); + }, [editor]); + + return null; +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/plugins/EditorRefPlugin.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/EditorRefPlugin.tsx new file mode 100644 index 000000000..0db80c0bb --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/EditorRefPlugin.tsx @@ -0,0 +1,18 @@ +import { useEffect, useRef } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import type { LexicalEditor } from 'lexical'; + +export const EditorRefPlugin = ({ onRef }: { onRef: (editor: LexicalEditor) => void }) => { + const [editor] = useLexicalComposerContext(); + const onRefRef = useRef(onRef); + + useEffect(() => { + onRefRef.current = onRef; + }); + + useEffect(() => { + onRefRef.current(editor); + }, [editor]); + + return null; +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/plugins/MultilinePlugin.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/MultilinePlugin.tsx new file mode 100644 index 000000000..adb1de4fc --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/MultilinePlugin.tsx @@ -0,0 +1,113 @@ +import { useEffect, useRef } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { mergeRegister } from '@lexical/utils'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $isLineBreakNode, + $isParagraphNode, + COMMAND_PRIORITY_NORMAL, + KEY_ENTER_COMMAND, + type LexicalNode, + type ParagraphNode, + TextNode, +} from 'lexical'; + +const collapseParagraphs = () => { + const root = $getRoot(); + const paragraphs = root.getChildren().filter($isParagraphNode) as ParagraphNode[]; + if (paragraphs.length <= 1) return; + const allNodes: LexicalNode[] = []; + + for (const [i, paragraph] of paragraphs.entries()) { + const children = paragraph.getChildren(); + if (i > 0 && children.length > 0) { + allNodes.push($createTextNode(' ')); + } + for (const child of children) { + allNodes.push(child); + } + } + + const newParagraph = $createParagraphNode(); + for (const node of allNodes) { + newParagraph.append(node); + } + + root.clear(); + root.append(newParagraph); +}; + +const stripNewlinesFromText = (text: string): string => text.replace(/\n+/g, ' '); + +export const MultilinePlugin = ({ multiline }: { multiline: boolean }) => { + const [editor] = useLexicalComposerContext(); + const prevMultilineRef = useRef(null); + + useEffect(() => { + const wasMultiline = prevMultilineRef.current; + prevMultilineRef.current = multiline; + + const isInitialMount = wasMultiline === null; + const switchedToSingleLine = wasMultiline === true && !multiline; + + if (!multiline && (isInitialMount || switchedToSingleLine)) { + const timeoutId = setTimeout(() => { + editor.update(() => { + collapseParagraphs(); + const root = $getRoot(); + const paragraphs = root.getChildren().filter($isParagraphNode) as ParagraphNode[]; + for (const paragraph of paragraphs) { + const children = paragraph.getChildren(); + for (const child of children) { + if ($isLineBreakNode(child)) { + const prev = child.getPreviousSibling(); + const next = child.getNextSibling(); + if (prev && next) { + child.replace($createTextNode(' ')); + } else { + child.remove(); + } + } else if (child instanceof TextNode) { + const text = child.getTextContent(); + if (text.includes('\n')) { + const newText = stripNewlinesFromText(text); + if (newText.trim() === '') { + child.remove(); + } else { + child.setTextContent(newText); + } + } + } + } + } + }); + }, 0); + // Clear the pending timeout if the plugin unmounts or `multiline` flips before it fires, + // so we never call editor.update() after teardown. + return () => clearTimeout(timeoutId); + } + }, [editor, multiline]); + + useEffect(() => { + if (multiline) return; + + return mergeRegister( + editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + event?.preventDefault(); + return true; + }, + COMMAND_PRIORITY_NORMAL + ), + editor.registerNodeTransform(TextNode, (node) => { + const text = node.getTextContent(); + if (text.includes('\n')) node.setTextContent(stripNewlinesFromText(text)); + }) + ); + }, [editor, multiline]); + + return null; +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/plugins/NodeSelectionFixPlugin.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/NodeSelectionFixPlugin.tsx new file mode 100644 index 000000000..5318573d3 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/NodeSelectionFixPlugin.tsx @@ -0,0 +1,424 @@ +import { useEffect } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + $createNodeSelection, + $createRangeSelection, + $createTextNode, + $getSelection, + $isLineBreakNode, + $isNodeSelection, + $isParagraphNode, + $isRangeSelection, + $isTextNode, + $setSelection, + COMMAND_PRIORITY_HIGH, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_ESCAPE_COMMAND, + type LexicalEditor, + type LexicalNode, +} from 'lexical'; +import { isPromptTokenNode, type PromptTokenNode } from './shared/token-nodes'; +import { WORD_JOINER } from '../utils'; + +/** + * Confluence-style pill focus controller. Pills are inline `DecoratorNode`s with no caret-anchor, + * so we drive focus through Lexical's `NodeSelection`: clicking or arrowing into a pill puts it in + * NodeSelection, and this plugin owns the keyboard transitions in and out of that state. + * + * - Pill selected + Backspace → remove, cursor BEFORE + * - Pill selected + Delete → remove, cursor AFTER + * - Cursor adjacent to pill + ArrowLeft/Right → focus the pill (no pass-through) + * - Pill selected + ArrowLeft/Right → cursor BEFORE / AFTER + * - Pill selected + ArrowUp/Down → cursor BEFORE / AFTER, then browser walks the line + * - Pill selected + Escape → cursor AFTER + * - Cursor at start-of-text after a `LineBreakNode` + Backspace → remove the BR (unrelated to pills) + * + * Shift-arrow defers to Lexical's default range expansion. + */ + +const $setCollapsedSelection = (key: string, offset: number, type: 'text' | 'element'): void => { + const sel = $createRangeSelection(); + sel.anchor.set(key, offset, type); + sel.focus.set(key, offset, type); + $setSelection(sel); +}; + +const getSelectedPromptToken = (): PromptTokenNode | null => { + const selection = $getSelection(); + if (!$isNodeSelection(selection)) return null; + const nodes = selection.getNodes(); + if (nodes.length !== 1) return null; + const node = nodes[0]; + return isPromptTokenNode(node) ? node : null; +}; + +const getCollapsedRangeSelection = () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null; + return selection; +}; + +/** Token immediately before the collapsed cursor, if any. Returns null otherwise. */ +const getPromptTokenBeforeCursor = (): PromptTokenNode | null => { + const selection = getCollapsedRangeSelection(); + if (!selection) return null; + const anchorNode = selection.anchor.getNode(); + + if ($isTextNode(anchorNode) && selection.anchor.offset === 0) { + const prev = anchorNode.getPreviousSibling(); + return isPromptTokenNode(prev) ? prev : null; + } + if ($isParagraphNode(anchorNode)) { + const offset = selection.anchor.offset; + if (offset === 0) return null; + const prev = anchorNode.getChildAtIndex(offset - 1); + return isPromptTokenNode(prev) ? prev : null; + } + return null; +}; + +/** Token immediately after the collapsed cursor, if any. Returns null otherwise. */ +const getPromptTokenAfterCursor = (): PromptTokenNode | null => { + const selection = getCollapsedRangeSelection(); + if (!selection) return null; + const anchorNode = selection.anchor.getNode(); + + if ($isTextNode(anchorNode) && selection.anchor.offset === anchorNode.getTextContentSize()) { + const next = anchorNode.getNextSibling(); + return isPromptTokenNode(next) ? next : null; + } + if ($isParagraphNode(anchorNode)) { + const next = anchorNode.getChildAtIndex(selection.anchor.offset); + return isPromptTokenNode(next) ? next : null; + } + return null; +}; + +/** + * When the cursor lands on a standalone word-joiner node (one whose entire + * content is the joiner character), skip past it in the given direction. + * Only fires for standalone nodes — joiners appended to real text (Case A) + * are traversed in a single native browser step and don't need this. + */ +const skipLineBreaks = (node: LexicalNode, direction: 'left' | 'right'): LexicalNode | null => { + let sibling: LexicalNode | null = + direction === 'left' ? node.getPreviousSibling() : node.getNextSibling(); + while (sibling && $isLineBreakNode(sibling)) { + sibling = direction === 'left' ? sibling.getPreviousSibling() : sibling.getNextSibling(); + } + return sibling; +}; + +const skipStandaloneJoiner = (direction: 'left' | 'right'): boolean => { + const selection = getCollapsedRangeSelection(); + if (!selection) return false; + const anchorNode = selection.anchor.getNode(); + if (!$isTextNode(anchorNode) || anchorNode.getTextContent() !== WORD_JOINER) return false; + + const sibling = skipLineBreaks(anchorNode, direction); + + if (sibling && isPromptTokenNode(sibling)) { + focusPromptToken(sibling); + return true; + } + if ($isTextNode(sibling)) { + $setCollapsedSelection( + sibling.getKey(), + direction === 'left' ? sibling.getTextContentSize() : 0, + 'text' + ); + return true; + } + const parent = anchorNode.getParent(); + if (!parent) return false; + const idx = anchorNode.getIndexWithinParent() + (direction === 'left' ? 0 : 1); + $setCollapsedSelection(parent.getKey(), idx, 'element'); + return true; +}; + +/** + * Place a collapsed `RangeSelection` immediately before `node`. + * + * At CSS line-wrap boundaries, caret positions between inline-block decorators + * are visually ambiguous — the browser may render the caret on the previous + * line. We insert a word-joiner before the decorator and position at offset 1 + * (after the joiner). The joiner prevents line breaks, so it wraps with the + * pill — and offset 1 is unambiguously on the pill's line. + */ +const placeCursorBefore = (node: LexicalNode): void => { + const prev = node.getPreviousSibling(); + + if ($isTextNode(prev)) { + const content = prev.getTextContent(); + if (!content.endsWith(WORD_JOINER)) { + prev.setTextContent(content + WORD_JOINER); + } + $setCollapsedSelection(prev.getKey(), prev.getTextContentSize(), 'text'); + } else { + const anchor = $createTextNode(WORD_JOINER); + node.insertBefore(anchor); + $setCollapsedSelection(anchor.getKey(), 1, 'text'); + } +}; + +/** Place a collapsed `RangeSelection` immediately after `node`. */ +const placeCursorAfter = (node: LexicalNode): void => { + const next = node.getNextSibling(); + if ($isTextNode(next)) { + $setCollapsedSelection(next.getKey(), 0, 'text'); + } else { + const parent = node.getParent(); + if (!parent) return; + $setCollapsedSelection(parent.getKey(), node.getIndexWithinParent() + 1, 'element'); + } +}; + +/** Replace the current selection with a `NodeSelection` containing only `node`. */ +const focusPromptToken = (node: LexicalNode): void => { + const newSelection = $createNodeSelection(); + newSelection.add(node.getKey()); + $setSelection(newSelection); +}; + +/** + * Registers the focus controller commands on a Lexical editor. Exported so tests can drive the + * exact same logic the React plugin wires up at mount time, against a headless `createEditor()`. + */ +export const registerNodeSelectionFixCommands = (editor: LexicalEditor): (() => void) => { + const handleBackspace = (event: KeyboardEvent): boolean => { + const selectedToken = getSelectedPromptToken(); + if (selectedToken) { + const before = (() => { + const prev = selectedToken.getPreviousSibling(); + if ($isTextNode(prev)) + return { kind: 'text' as const, key: prev.getKey(), offset: prev.getTextContentSize() }; + const parent = selectedToken.getParent(); + if (parent) + return { + kind: 'element' as const, + key: parent.getKey(), + offset: selectedToken.getIndexWithinParent(), + }; + return null; + })(); + selectedToken.remove(); + if (before) { + $setCollapsedSelection(before.key, before.offset, before.kind); + } + event.preventDefault(); + return true; + } + + // Cursor just after a pill — focus it instead of deleting characters. + const pillBefore = getPromptTokenBeforeCursor(); + if (pillBefore) { + focusPromptToken(pillBefore); + event.preventDefault(); + return true; + } + + // Cursor at start-of-text after a LineBreakNode — remove the BR. + const selection = getCollapsedRangeSelection(); + if (!selection) return false; + const anchorNode = selection.anchor.getNode(); + let paragraph = null; + let offset = 0; + if (selection.anchor.type === 'element' && $isParagraphNode(anchorNode)) { + paragraph = anchorNode; + offset = selection.anchor.offset; + } else if ( + selection.anchor.type === 'text' && + $isTextNode(anchorNode) && + selection.anchor.offset === 0 + ) { + const parent = anchorNode.getParent(); + if (!$isParagraphNode(parent)) return false; + paragraph = parent; + offset = anchorNode.getIndexWithinParent(); + } else { + return false; + } + if (offset <= 0) return false; + const previousNode = paragraph.getChildAtIndex(offset - 1); + if (!$isLineBreakNode(previousNode)) return false; + + const lineStartNode = paragraph.getChildAtIndex(offset); + previousNode.remove(); + if ($isTextNode(lineStartNode) && lineStartNode.isAttached()) { + $setCollapsedSelection(lineStartNode.getKey(), 0, 'text'); + } else { + $setCollapsedSelection(paragraph.getKey(), Math.max(0, offset - 1), 'element'); + } + event.preventDefault(); + return true; + }; + + const handleDelete = (event: KeyboardEvent): boolean => { + const selectedToken = getSelectedPromptToken(); + if (selectedToken) { + const after = (() => { + const next = selectedToken.getNextSibling(); + if ($isTextNode(next)) return { kind: 'text' as const, key: next.getKey(), offset: 0 }; + const parent = selectedToken.getParent(); + if (parent) + return { + kind: 'element' as const, + key: parent.getKey(), + offset: selectedToken.getIndexWithinParent(), + }; + return null; + })(); + selectedToken.remove(); + if (after) { + $setCollapsedSelection(after.key, after.offset, after.kind); + } + event.preventDefault(); + return true; + } + + // Cursor just before a pill — focus it instead of deleting characters. + const pillAfter = getPromptTokenAfterCursor(); + if (pillAfter) { + focusPromptToken(pillAfter); + event.preventDefault(); + return true; + } + return false; + }; + + const handleArrowLeft = (event: KeyboardEvent): boolean => { + if (event.shiftKey) return false; + + const selectedToken = getSelectedPromptToken(); + if (selectedToken) { + placeCursorBefore(selectedToken); + event.preventDefault(); + return true; + } + + const pillBefore = getPromptTokenBeforeCursor(); + if (pillBefore) { + focusPromptToken(pillBefore); + event.preventDefault(); + return true; + } + + if (skipStandaloneJoiner('left')) { + event.preventDefault(); + return true; + } + + return false; + }; + + const handleArrowRight = (event: KeyboardEvent): boolean => { + if (event.shiftKey) return false; + + const selectedToken = getSelectedPromptToken(); + if (selectedToken) { + placeCursorAfter(selectedToken); + event.preventDefault(); + return true; + } + + const pillAfter = getPromptTokenAfterCursor(); + if (pillAfter) { + focusPromptToken(pillAfter); + event.preventDefault(); + return true; + } + + if (skipStandaloneJoiner('right')) { + event.preventDefault(); + return true; + } + + return false; + }; + + const handleEscape = (event: KeyboardEvent): boolean => { + const selectedToken = getSelectedPromptToken(); + if (!selectedToken) return false; + placeCursorAfter(selectedToken); + event.preventDefault(); + return true; + }; + + // ArrowUp/Down on a focused pill: drop a RangeSelection adjacent to the pill so the DOM caret + // is in the contenteditable, then return false to let the browser walk the line. Without the + // cursor-placement step, the browser sees no DOM caret and treats it as page scroll. + // Single return is intentional — Lexical's command pipeline needs `false` ("not handled, + // continue propagating") on every path so the browser still moves the caret line-by-line. + const handleArrowUp = (event: KeyboardEvent): boolean => { + if (!event.shiftKey) { + const selectedToken = getSelectedPromptToken(); + if (selectedToken) placeCursorBefore(selectedToken); + } + return false; + }; + + const handleArrowDown = (event: KeyboardEvent): boolean => { + if (!event.shiftKey) { + const selectedToken = getSelectedPromptToken(); + if (selectedToken) placeCursorAfter(selectedToken); + } + return false; + }; + + const unregisterBackspace = editor.registerCommand( + KEY_BACKSPACE_COMMAND, + handleBackspace, + COMMAND_PRIORITY_HIGH + ); + const unregisterDelete = editor.registerCommand( + KEY_DELETE_COMMAND, + handleDelete, + COMMAND_PRIORITY_HIGH + ); + const unregisterArrowLeft = editor.registerCommand( + KEY_ARROW_LEFT_COMMAND, + handleArrowLeft, + COMMAND_PRIORITY_HIGH + ); + const unregisterArrowRight = editor.registerCommand( + KEY_ARROW_RIGHT_COMMAND, + handleArrowRight, + COMMAND_PRIORITY_HIGH + ); + const unregisterArrowUp = editor.registerCommand( + KEY_ARROW_UP_COMMAND, + handleArrowUp, + COMMAND_PRIORITY_HIGH + ); + const unregisterArrowDown = editor.registerCommand( + KEY_ARROW_DOWN_COMMAND, + handleArrowDown, + COMMAND_PRIORITY_HIGH + ); + const unregisterEscape = editor.registerCommand( + KEY_ESCAPE_COMMAND, + handleEscape, + COMMAND_PRIORITY_HIGH + ); + + return () => { + unregisterBackspace(); + unregisterDelete(); + unregisterArrowLeft(); + unregisterArrowRight(); + unregisterArrowUp(); + unregisterArrowDown(); + unregisterEscape(); + }; +}; + +export const NodeSelectionFixPlugin = () => { + const [editor] = useLexicalComposerContext(); + useEffect(() => registerNodeSelectionFixCommands(editor), [editor]); + return null; +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/plugins/RenameTokensPlugin.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/RenameTokensPlugin.tsx new file mode 100644 index 000000000..ee515c2ef --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/RenameTokensPlugin.tsx @@ -0,0 +1,203 @@ +import { useEffect, useRef } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + isInputTokenNode, + isOutputTokenNode, + isStateTokenNode, + isResourceTokenNode, +} from '../nodes'; +import type { + PromptEditorAutoCompleteOption, + PromptEditorToken, + PromptEditorTokenType, +} from '../types'; +import { $getEditorTokensInternal } from '../utils'; +import { getAllPromptTokenNodes } from './shared/token-nodes'; + +type NonTextTokenType = Exclude; + +interface RenameTokensPluginProps { + options: PromptEditorAutoCompleteOption[]; + onChange?: (tokens: PromptEditorToken[]) => void; +} + +const TOKEN_TYPES: NonTextTokenType[] = ['input', 'state', 'output', 'resource']; + +interface PathTreeNode { + children: Map; +} +interface TokenPathRename { + oldPath: string; + newPath: string; + type: NonTextTokenType; +} + +const createTreeNode = (): PathTreeNode => ({ children: new Map() }); + +const buildPathTree = (paths: Iterable): PathTreeNode => { + const root = createTreeNode(); + for (const path of paths) { + if (!path) continue; + const segments = path.split('.').filter(Boolean); + if (segments.length === 0) continue; + let current = root; + for (const segment of segments) { + const next = current.children.get(segment) ?? createTreeNode(); + if (!current.children.has(segment)) current.children.set(segment, next); + current = next; + } + } + return root; +}; + +const joinPath = (prefix: string, key: string): string => (prefix ? `${prefix}.${key}` : key); + +const detectPathRenames = ( + prevNode: PathTreeNode, + nextNode: PathTreeNode, + prevPrefix: string, + nextPrefix: string, + renames: { oldPath: string; newPath: string }[] +) => { + const prevKeys = [...prevNode.children.keys()]; + const nextKeys = [...nextNode.children.keys()]; + const removedKeys = prevKeys.filter((k) => !nextNode.children.has(k)); + const addedKeys = nextKeys.filter((k) => !prevNode.children.has(k)); + const commonKeys = prevKeys.filter((k) => nextNode.children.has(k)); + + if (removedKeys.length === 1 && addedKeys.length === 1) { + const oldPath = joinPath(prevPrefix, removedKeys[0]); + const newPath = joinPath(nextPrefix, addedKeys[0]); + renames.push({ oldPath, newPath }); + const prevChild = prevNode.children.get(removedKeys[0]); + const nextChild = nextNode.children.get(addedKeys[0]); + if (prevChild && nextChild) detectPathRenames(prevChild, nextChild, oldPath, newPath, renames); + } + + for (const key of commonKeys) { + const prevChild = prevNode.children.get(key); + const nextChild = nextNode.children.get(key); + if (prevChild && nextChild) + detectPathRenames( + prevChild, + nextChild, + joinPath(prevPrefix, key), + joinPath(nextPrefix, key), + renames + ); + } +}; + +const groupOptionsByType = (options: PromptEditorAutoCompleteOption[]) => { + const grouped: Record> = { + input: new Set(), + state: new Set(), + output: new Set(), + resource: new Set(), + }; + for (const opt of options) grouped[opt.type].add(opt.value); + return grouped; +}; + +const detectRenamesByType = ( + prevOptions: PromptEditorAutoCompleteOption[], + currOptions: PromptEditorAutoCompleteOption[] +): TokenPathRename[] => { + const prevGrouped = groupOptionsByType(prevOptions); + const currGrouped = groupOptionsByType(currOptions); + const renames: TokenPathRename[] = []; + for (const type of TOKEN_TYPES) { + const typeRenames: { oldPath: string; newPath: string }[] = []; + detectPathRenames( + buildPathTree(prevGrouped[type]), + buildPathTree(currGrouped[type]), + '', + '', + typeRenames + ); + for (const rename of typeRenames) renames.push({ ...rename, type }); + } + return renames; +}; + +const applyPathRename = (value: string, oldPath: string, newPath: string): string => { + if (value === oldPath) return newPath; + if (value.startsWith(`${oldPath}.`)) return `${newPath}${value.slice(oldPath.length)}`; + return value; +}; + +export const RenameTokensPlugin = ({ options, onChange }: RenameTokensPluginProps) => { + const [editor] = useLexicalComposerContext(); + const prevOptionsRef = useRef(options); + const renameSequenceRef = useRef(0); + const onChangeRef = useRef(onChange); + + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + const prevOptions = prevOptionsRef.current; + prevOptionsRef.current = options; + + const renames = detectRenamesByType(prevOptions, options); + if (renames.length === 0) return; + + const sortedRenames = [...renames].sort((a, b) => b.oldPath.length - a.oldPath.length); + const renameSequence = ++renameSequenceRef.current; + let cancelled = false; + + queueMicrotask(() => { + if (cancelled || renameSequenceRef.current !== renameSequence) return; + + let updatedTokens: PromptEditorToken[] | null = null; + editor.update( + () => { + let tokensChanged = false; + const tokenNodes = getAllPromptTokenNodes(); + + for (const node of tokenNodes) { + const nodeType = isInputTokenNode(node) + ? 'input' + : isOutputTokenNode(node) + ? 'output' + : isStateTokenNode(node) + ? 'state' + : isResourceTokenNode(node) + ? 'resource' + : null; + if (!nodeType) continue; + + const matchingRenames = sortedRenames.filter((r) => r.type === nodeType); + if (matchingRenames.length === 0) continue; + + const currentValue = node.getValue(); + let nextValue = currentValue; + for (const rename of matchingRenames) + nextValue = applyPathRename(nextValue, rename.oldPath, rename.newPath); + + if (nextValue !== currentValue) { + node.setValue(nextValue); + tokensChanged = true; + } + } + + if (tokensChanged) updatedTokens = $getEditorTokensInternal(); + }, + { + discrete: true, + onUpdate: () => { + if (cancelled || renameSequenceRef.current !== renameSequence) return; + if (updatedTokens) onChangeRef.current?.(updatedTokens); + }, + } + ); + }); + + return () => { + cancelled = true; + }; + }, [editor, options]); + + return null; +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/plugins/ToolbarActionsPlugin.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/ToolbarActionsPlugin.tsx new file mode 100644 index 000000000..e68bf9b4e --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/ToolbarActionsPlugin.tsx @@ -0,0 +1,145 @@ +import { type MutableRefObject, useEffect } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + $createTextNode, + $getRoot, + $getSelection, + $isParagraphNode, + $isRangeSelection, + $isTextNode, +} from 'lexical'; +import type { PromptEditorToolbarActionsRef } from '../types'; + +interface ToolbarActionsPluginProps { + actionsRef: MutableRefObject; +} + +/** + * Wrap current selection with start/end markers (e.g., **bold**, *italic*, `code`). + * Preserves token (decorator) nodes — only wraps the text boundaries with markers. + */ +const wrapSelectionWithMarkers = (startMarker: string, endMarker: string) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + + if (selection.isCollapsed()) { + const markerNode = $createTextNode(`${startMarker}${endMarker}`); + selection.insertNodes([markerNode]); + const offset = startMarker.length; + selection.setTextNodeRange(markerNode, offset, markerNode, offset); + return; + } + + // Insert end marker after the selection's focus point + const focusNode = selection.focus.getNode(); + const focusOffset = selection.focus.offset; + if ($isTextNode(focusNode)) { + const text = focusNode.getTextContent(); + focusNode.setTextContent(text.slice(0, focusOffset) + endMarker + text.slice(focusOffset)); + } else { + // Focus is on a non-text node (e.g., decorator) — insert after it + const endText = $createTextNode(endMarker); + focusNode.insertAfter(endText); + } + + // Insert start marker before the selection's anchor point + const anchorNode = selection.anchor.getNode(); + const anchorOffset = selection.anchor.offset; + if ($isTextNode(anchorNode)) { + const text = anchorNode.getTextContent(); + anchorNode.setTextContent(text.slice(0, anchorOffset) + startMarker + text.slice(anchorOffset)); + } else { + // Anchor is on a non-text node — insert before it + const startText = $createTextNode(startMarker); + anchorNode.insertBefore(startText); + } +}; + +/** + * Insert a prefix at the beginning of each selected paragraph. + * Handles multi-line selections, toggle-off, incremental numbering, and token nodes at line start. + */ +const insertLinePrefixForSelection = (getPrefix: (index: number) => string) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + + // Collect all paragraphs that are part of the selection + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + + // Get the paragraphs containing anchor and focus + const anchorParent = + $isTextNode(anchorNode) || !$isParagraphNode(anchorNode) ? anchorNode.getParent() : anchorNode; + const focusParent = + $isTextNode(focusNode) || !$isParagraphNode(focusNode) ? focusNode.getParent() : focusNode; + + if (!anchorParent || !focusParent) return; + + // Get all paragraphs in the root + const root = $getRoot(); + const allParagraphs = root.getChildren().filter($isParagraphNode); + + // Find the range of paragraphs to affect + const anchorIndex = allParagraphs.findIndex((p) => p.is(anchorParent)); + const focusIndex = allParagraphs.findIndex((p) => p.is(focusParent)); + if (anchorIndex === -1 || focusIndex === -1) return; + + const startIndex = Math.min(anchorIndex, focusIndex); + const endIndex = Math.max(anchorIndex, focusIndex); + + let lineCounter = 0; + for (let i = startIndex; i <= endIndex; i++) { + const paragraph = allParagraphs[i]; + const firstChild = paragraph.getFirstChild(); + const prefix = getPrefix(lineCounter); + lineCounter++; + + if ($isTextNode(firstChild)) { + const text = firstChild.getTextContent(); + // Toggle off: if prefix already exists at start, remove it + if (text.startsWith(prefix)) { + firstChild.setTextContent(text.slice(prefix.length)); + } else { + firstChild.setTextContent(prefix + text); + } + } else if (firstChild) { + // First child is a decorator node (token pill) — insert text node before it + const prefixNode = $createTextNode(prefix); + firstChild.insertBefore(prefixNode); + } else { + // Empty paragraph — just add the prefix as text + paragraph.append($createTextNode(prefix)); + } + } +}; + +export const ToolbarActionsPlugin = ({ actionsRef }: ToolbarActionsPluginProps) => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + actionsRef.current = { + formatBold: () => { + editor.update(() => wrapSelectionWithMarkers('**', '**')); + }, + formatItalic: () => { + editor.update(() => wrapSelectionWithMarkers('*', '*')); + }, + formatStrikethrough: () => { + // GFM strikethrough — `marked` (preview renderer) honours it natively. + editor.update(() => wrapSelectionWithMarkers('~~', '~~')); + }, + formatNumberedList: () => { + editor.update(() => insertLinePrefixForSelection((i) => `${i + 1}. `)); + }, + formatBulletedList: () => { + editor.update(() => insertLinePrefixForSelection(() => '- ')); + }, + }; + + return () => { + actionsRef.current = null; + }; + }, [editor, actionsRef]); + + return null; +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/plugins/ValidateTokensPlugin.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/ValidateTokensPlugin.tsx new file mode 100644 index 000000000..3b966f230 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/ValidateTokensPlugin.tsx @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import type { PromptEditorAutoCompleteOption } from '../types'; +import { getAllPromptTokenNodes, type PromptTokenNode } from './shared/token-nodes'; + +export const ValidateTokensPlugin = ({ + options, +}: { + options: PromptEditorAutoCompleteOption[]; +}) => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + const validValues = new Map>(); + for (const opt of options) { + if (!validValues.has(opt.type)) validValues.set(opt.type, new Set()); + validValues.get(opt.type)!.add(opt.value); + } + + const checkIsInvalid = (node: PromptTokenNode) => { + const nodeTypeToTokenType: Record = { + 'input-token': 'input', + 'output-token': 'output', + 'state-token': 'state', + 'resource-token': 'resource', + }; + const tokenType = nodeTypeToTokenType[node.getType()]; + if (!tokenType) return false; + const validSet = validValues.get(tokenType); + return !validSet || !validSet.has(node.getValue()); + }; + + const validateAllNodes = () => { + const tokenNodes = getAllPromptTokenNodes(); + for (const node of tokenNodes) { + const isInvalid = checkIsInvalid(node); + if (node.getIsInvalid() !== isInvalid) node.setIsInvalid(isInvalid); + } + }; + + editor.update(validateAllNodes); + + const unregister = editor.registerUpdateListener(({ editorState, prevEditorState }) => { + if (editorState === prevEditorState) return; + editorState.read(() => { + const tokenNodes = getAllPromptTokenNodes(); + let needsUpdate = false; + for (const node of tokenNodes) { + if (node.getIsInvalid() !== checkIsInvalid(node)) { + needsUpdate = true; + break; + } + } + if (needsUpdate) editor.update(validateAllNodes); + }); + }); + + return unregister; + }, [editor, options]); + + return null; +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/plugins/ValueSyncPlugin.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/ValueSyncPlugin.tsx new file mode 100644 index 000000000..0ea625131 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/ValueSyncPlugin.tsx @@ -0,0 +1,91 @@ +import { useEffect, useRef } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $addUpdateTag, type LexicalEditor, SKIP_DOM_SELECTION_TAG } from 'lexical'; +import type { PromptEditorToken } from '../types'; +import { areTokensEqual, $getEditorTokensInternal, $setEditorTokensInternal } from '../utils'; + +const getDeepActiveElement = (rootElement: HTMLElement): Element | null => { + const rootNode = rootElement.getRootNode(); + let activeElement: Element | null = null; + if (rootNode instanceof ShadowRoot || rootNode instanceof Document) { + activeElement = rootNode.activeElement; + } else { + activeElement = document.activeElement; + } + while (activeElement instanceof HTMLElement && activeElement.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + return activeElement; +}; + +const isEditorFocused = (rootElement: HTMLElement | null): boolean => { + if (!rootElement) return false; + const activeElement = getDeepActiveElement(rootElement); + return !!activeElement && (activeElement === rootElement || rootElement.contains(activeElement)); +}; + +export const ValueSyncPlugin = ({ + value, + editorRef, + lastEmittedValueRef, + isSyncingRef, +}: { + value: PromptEditorToken[] | undefined; + editorRef: React.MutableRefObject; + lastEmittedValueRef?: React.MutableRefObject; + isSyncingRef?: React.MutableRefObject; +}) => { + const [editor] = useLexicalComposerContext(); + const lastValueRef = useRef(value); + + useEffect(() => { + // Only skip when uncontrolled (undefined). An empty array is a valid controlled value and must + // sync so a controlled editor can be cleared by passing `[]`. + if (value === undefined) return; + if (value === lastValueRef.current) return; + + if (lastValueRef.current && areTokensEqual(lastValueRef.current, value)) { + lastValueRef.current = value; + return; + } + + if (lastEmittedValueRef?.current && areTokensEqual(lastEmittedValueRef.current, value)) { + lastValueRef.current = value; + return; + } + + const currentTokens = editorRef.current?.getEditorState().read($getEditorTokensInternal); + if (currentTokens && areTokensEqual(currentTokens, value)) { + lastValueRef.current = value; + return; + } + + const rootElement = editor.getRootElement(); + const editorIsFocused = isEditorFocused(rootElement); + const shouldSkipDomSelection = !editorIsFocused; + + try { + if (isSyncingRef) isSyncingRef.current = true; + + editor.update( + () => { + if (shouldSkipDomSelection) $addUpdateTag(SKIP_DOM_SELECTION_TAG); + $setEditorTokensInternal(value); + }, + { + discrete: true, + onUpdate: () => { + if (isSyncingRef) isSyncingRef.current = false; + }, + } + ); + + lastValueRef.current = value; + } catch (error) { + if (isSyncingRef) isSyncingRef.current = false; + console.error('ValueSyncPlugin: Error syncing value to editor', error); + } + }, [editor, value, editorRef, lastEmittedValueRef, isSyncingRef]); + + return null; +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/plugins/shared/token-nodes.ts b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/shared/token-nodes.ts new file mode 100644 index 000000000..230ac38d6 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/plugins/shared/token-nodes.ts @@ -0,0 +1,26 @@ +import type { LexicalNode } from 'lexical'; +import { $dfs } from '@lexical/utils'; +import { type InputTokenNode, isInputTokenNode } from '../../nodes/InputTokenNode'; +import { type OutputTokenNode, isOutputTokenNode } from '../../nodes/OutputTokenNode'; +import { type StateTokenNode, isStateTokenNode } from '../../nodes/StateTokenNode'; +import { type ResourceTokenNode, isResourceTokenNode } from '../../nodes/ResourceTokenNode'; + +export type PromptTokenNode = InputTokenNode | OutputTokenNode | StateTokenNode | ResourceTokenNode; + +export const isPromptTokenNode = (node: LexicalNode | null | undefined): node is PromptTokenNode => + isInputTokenNode(node) || + isOutputTokenNode(node) || + isStateTokenNode(node) || + isResourceTokenNode(node); + +export const getAllPromptTokenNodes = (): PromptTokenNode[] => { + const nodes: PromptTokenNode[] = []; + + for (const { node } of $dfs()) { + if (isPromptTokenNode(node)) { + nodes.push(node); + } + } + + return nodes; +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/prompt-editor.stories.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/prompt-editor.stories.tsx new file mode 100644 index 000000000..02ccecc77 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/prompt-editor.stories.tsx @@ -0,0 +1,149 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { PromptEditor } from './prompt-editor'; +import type { PromptEditorAutoCompleteOption, PromptEditorMode, PromptEditorToken } from './types'; + +const meta = { + title: 'Components/PromptEditor', + component: PromptEditor, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + // Token arrays + options are editable as JSON object controls; the component normalizes malformed + // input so a stray "Set object" can't crash it. Functions/refs have no meaningful control. + argTypes: { + value: { control: 'object' }, + initialValue: { control: 'object' }, + autoCompleteOptions: { control: 'object' }, + onChange: { control: false }, + onModeChange: { control: false }, + onFullscreen: { control: false }, + editorRef: { control: false }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const AUTOCOMPLETE_OPTIONS: PromptEditorAutoCompleteOption[] = [ + { type: 'input', value: 'vars.firstName' }, + { type: 'input', value: 'vars.lastName' }, + { type: 'output', value: 'vars.summary' }, + { type: 'state', value: 'state.retryCount' }, + { type: 'resource', value: 'resource.knowledgeBase' }, +]; + +const SAMPLE_VALUE: PromptEditorToken[] = [ + { type: 'text', value: 'Greet ' }, + { type: 'input', value: 'vars.firstName' }, + { type: 'text', value: ' and summarize the request into ' }, + { type: 'output', value: 'vars.summary' }, + { type: 'text', value: '.' }, +]; + +export const Default: Story = { + args: { + placeholder: 'Write your prompt…', + ariaLabel: 'Prompt', + }, +}; + +export const SingleLine: Story = { + args: { + multiline: false, + placeholder: 'Single-line prompt…', + ariaLabel: 'Prompt', + }, +}; + +export const WithTokens: Story = { + args: { + initialValue: SAMPLE_VALUE, + autoCompleteOptions: AUTOCOMPLETE_OPTIONS, + ariaLabel: 'Prompt', + }, +}; + +export const WithToolbar: Story = { + args: { + showToolbar: true, + initialValue: SAMPLE_VALUE, + autoCompleteOptions: AUTOCOMPLETE_OPTIONS, + ariaLabel: 'Prompt', + }, +}; + +/** Type `$` in the editor to open the variable autocomplete menu. */ +export const WithAutocomplete: Story = { + args: { + autoCompleteOptions: AUTOCOMPLETE_OPTIONS, + placeholder: 'Type $ to insert a variable…', + ariaLabel: 'Prompt', + }, +}; + +export const Preview: Story = { + args: { + showToolbar: true, + mode: 'preview', + initialValue: [ + { type: 'text', value: '# Summary\n\nGreet ' }, + { type: 'input', value: 'vars.firstName' }, + { type: 'text', value: ' then list:\n\n- item one\n- item two' }, + ], + autoCompleteOptions: AUTOCOMPLETE_OPTIONS, + ariaLabel: 'Prompt', + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + initialValue: SAMPLE_VALUE, + autoCompleteOptions: AUTOCOMPLETE_OPTIONS, + ariaLabel: 'Prompt', + }, +}; + +/** + * `borderless` drops the editor's own border/background so a parent can supply the field chrome; + * the text color is inherited from that parent surface. + */ +export const Borderless: Story = { + args: { + borderless: true, + initialValue: SAMPLE_VALUE, + autoCompleteOptions: AUTOCOMPLETE_OPTIONS, + ariaLabel: 'Prompt', + }, +}; + +/** Controlled editor whose value + preview-mode toggle are owned by the parent. */ +export const Controlled: Story = { + render: () => { + const ControlledExample = () => { + const [value, setValue] = useState(SAMPLE_VALUE); + const [mode, setMode] = useState('edit'); + return ( + + ); + }; + return ; + }, +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/prompt-editor.test.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/prompt-editor.test.tsx new file mode 100644 index 000000000..77c6cba25 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/prompt-editor.test.tsx @@ -0,0 +1,156 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { PromptEditor, type PromptEditorRef } from './prompt-editor'; +import type { PromptEditorAutoCompleteOption, PromptEditorMode, PromptEditorToken } from './types'; + +const OPTIONS: PromptEditorAutoCompleteOption[] = [ + { type: 'input', value: 'vars.firstName' }, + { type: 'output', value: 'vars.result' }, +]; + +describe('PromptEditor', () => { + describe('rendering', () => { + it('renders an editable textbox with the given aria-label', () => { + render(); + const editor = screen.getByRole('textbox', { name: 'Prompt' }); + expect(editor).toBeInTheDocument(); + expect(editor).toHaveAttribute('contenteditable', 'true'); + }); + + it('shows the placeholder while empty', () => { + render(); + expect(screen.getByText('Type your prompt…')).toBeInTheDocument(); + }); + + it('marks the editor non-editable when disabled', async () => { + render(); + await waitFor(() => + expect(screen.getByRole('textbox', { name: 'Prompt' })).toHaveAttribute( + 'contenteditable', + 'false' + ) + ); + }); + }); + + describe('toolbar', () => { + it('renders the formatting toolbar when showToolbar is set', () => { + render(); + expect(screen.getByTestId('editor-toolbar')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Bold' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Numbered List' })).toBeInTheDocument(); + }); + + it('does not render the toolbar by default', () => { + render(); + expect(screen.queryByTestId('editor-toolbar')).not.toBeInTheDocument(); + }); + + it('switches to preview mode via the toolbar (controlled value, uncontrolled mode)', async () => { + const user = userEvent.setup(); + const value: PromptEditorToken[] = [{ type: 'text', value: '# Heading' }]; + render(); + await user.click(screen.getByRole('button', { name: 'Preview' })); + const heading = await screen.findByText('Heading'); + expect(heading.tagName.toLowerCase()).toBe('h1'); + }); + + it('toggles edit↔preview in controlled mode via the toolbar', async () => { + const user = userEvent.setup(); + const ControlledMode = () => { + const [mode, setMode] = useState('edit'); + return ( + + ); + }; + const { container } = render(); + // edit mode: no preview pane + expect(container.querySelector('.prompt-editor-preview')).toBeNull(); + // → preview + await user.click(screen.getByRole('button', { name: 'Preview' })); + await waitFor(() => expect(container.querySelector('.prompt-editor-preview')).not.toBeNull()); + // → back to edit + await user.click(screen.getByRole('button', { name: 'Edit' })); + await waitFor(() => expect(container.querySelector('.prompt-editor-preview')).toBeNull()); + }); + }); + + describe('preview', () => { + it('renders markdown from controlled tokens', () => { + const value: PromptEditorToken[] = [{ type: 'text', value: '**bold**' }]; + render(); + const strong = screen.getByText('bold'); + expect(strong.tagName.toLowerCase()).toBe('strong'); + }); + + it('renders a variable token as a pill in preview', () => { + const value: PromptEditorToken[] = [{ type: 'input', value: 'vars.firstName' }]; + render(); + expect(screen.getByText('vars.firstName')).toBeInTheDocument(); + }); + + it('shows the empty message when there are no tokens', () => { + render(); + expect(screen.getByText('Nothing to preview')).toBeInTheDocument(); + }); + }); + + describe('tokens', () => { + it('mounts with an initial variable token without throwing', () => { + const initialValue: PromptEditorToken[] = [ + { type: 'text', value: 'Hi ' }, + { type: 'input', value: 'vars.firstName' }, + ]; + expect(() => + render() + ).not.toThrow(); + // Token→pill rendering is asserted in the preview tests; Lexical decorator painting is not + // reliable under jsdom, so here we only assert the editor mounts with the seeded value. + expect(screen.getByRole('textbox', { name: 'Prompt' })).toBeInTheDocument(); + }); + + it('exposes an imperative ref without throwing', () => { + const ref = createRef(); + render(); + expect(ref.current).toBeTruthy(); + expect(() => ref.current?.insertVariableToken(OPTIONS[0])).not.toThrow(); + }); + }); + + it('mounts cleanly with autocomplete options enabled', () => { + const onChange = vi.fn(); + expect(() => + render() + ).not.toThrow(); + expect(screen.getByRole('textbox', { name: 'Prompt' })).toBeInTheDocument(); + }); + + it('tolerates a non-array autoCompleteOptions without crashing', () => { + // Storybook's "Set object" control can inject `{}` for the autoCompleteOptions arg; the token + // plugins must not iterate a non-iterable and throw. The editor should still render. + const malformed = {} as unknown as PromptEditorAutoCompleteOption[]; + expect(() => + render() + ).not.toThrow(); + expect(screen.getByRole('textbox', { name: 'Prompt' })).toBeInTheDocument(); + }); + + it('tolerates a non-array value/initialValue without crashing (edit + preview)', () => { + const badTokens = {} as unknown as PromptEditorToken[]; + // edit mode, controlled value + expect(() => render()).not.toThrow(); + expect(screen.getByRole('textbox', { name: 'Prompt' })).toBeInTheDocument(); + // preview mode treats the malformed value as empty rather than rendering junk + const { getByText } = render( + + ); + expect(getByText('Nothing to preview')).toBeInTheDocument(); + }); +}); diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/prompt-editor.tsx b/packages/apollo-wind/src/components/ui/prompt-editor/prompt-editor.tsx new file mode 100644 index 000000000..123d4b133 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/prompt-editor.tsx @@ -0,0 +1,500 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import { LexicalComposer } from '@lexical/react/LexicalComposer'; +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; +import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'; +import { $getSelection, $isRangeSelection, type LexicalEditor } from 'lexical'; +import { InputTokenNode, OutputTokenNode, StateTokenNode, ResourceTokenNode } from './nodes'; +import { CopyPastePlugin } from './plugins/CopyPastePlugin'; +import { EditorRefPlugin } from './plugins/EditorRefPlugin'; +import { MultilinePlugin } from './plugins/MultilinePlugin'; +import { NodeSelectionFixPlugin } from './plugins/NodeSelectionFixPlugin'; +import { ValueSyncPlugin } from './plugins/ValueSyncPlugin'; +import { AutocompletePlugin } from './plugins/AutocompletePlugin'; +import { ValidateTokensPlugin } from './plugins/ValidateTokensPlugin'; +import { RenameTokensPlugin } from './plugins/RenameTokensPlugin'; +import { ToolbarActionsPlugin } from './plugins/ToolbarActionsPlugin'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { EditorToolbar } from './components/EditorToolbar'; +import { MarkdownPreview } from './components/MarkdownPreview'; +import type { + PromptEditorAutoCompleteOption, + PromptEditorMode, + PromptEditorToken, + PromptEditorToolbarActionsRef, +} from './types'; +import { + areTokensEqual, + $getEditorTokensInternal, + $setEditorTokensInternal, + $insertTokenAtCursor, +} from './utils'; + +const DEFAULT_MIN_ROWS = 4; +const DEFAULT_MAX_ROWS = 20; +const LINE_HEIGHT = 20; + +export interface PromptEditorRef { + setTokens: (tokens: PromptEditorToken[]) => void; + /** Focus the editor and insert the `$` trigger character to open the autocomplete menu. */ + insertAutocompleteTrigger: () => void; + /** Focus the editor and insert a variable token pill at the current cursor position. */ + insertVariableToken: (option: PromptEditorAutoCompleteOption) => void; +} + +export interface PromptEditorProps { + value?: PromptEditorToken[]; + initialValue?: PromptEditorToken[]; + onChange?: (value: PromptEditorToken[]) => void; + autoCompleteOptions?: PromptEditorAutoCompleteOption[]; + multiline?: boolean; + minRows?: number; + maxRows?: number; + placeholder?: string; + disabled?: boolean; + ariaLabel?: string; + showToolbar?: boolean; + mode?: PromptEditorMode; + onModeChange?: (mode: PromptEditorMode) => void; + onFullscreen?: () => void; + editorRef?: React.RefObject; + fillHeight?: boolean; + /** Drop the editor's own border/background/rounding so a parent can provide the field chrome. */ + borderless?: boolean; +} + +const EMPTY_AUTOCOMPLETE_OPTIONS: PromptEditorAutoCompleteOption[] = []; +const EMPTY_TOKENS: PromptEditorToken[] = []; + +/** Normalize a token-array prop so malformed input (e.g. an object injected by a Storybook control) can't crash the editor. */ +const toTokenArray = (v: PromptEditorToken[] | undefined): PromptEditorToken[] | undefined => + v === undefined ? undefined : Array.isArray(v) ? v : EMPTY_TOKENS; + +interface EditorInnerProps + extends Omit< + PromptEditorProps, + 'editorRef' | 'showToolbar' | 'mode' | 'onModeChange' | 'onFullscreen' + > { + toolbarActionsRef: React.MutableRefObject; + showToolbar?: boolean; +} + +const EditorInner = forwardRef( + ( + { + initialValue, + value, + onChange, + autoCompleteOptions = EMPTY_AUTOCOMPLETE_OPTIONS, + multiline = true, + minRows = DEFAULT_MIN_ROWS, + maxRows = DEFAULT_MAX_ROWS, + placeholder, + disabled, + ariaLabel, + fillHeight, + borderless, + toolbarActionsRef, + showToolbar, + }: EditorInnerProps, + ref: React.ForwardedRef + ) => { + const editorRef = useRef(null); + const [isEmpty, setIsEmpty] = useState(() => { + const seed = initialValue ?? value; + return !seed || seed.length === 0; + }); + const initializedRef = useRef(false); + const onChangeRef = useRef(onChange); + const isEmptyRef = useRef(isEmpty); + const debounceTimerRef = useRef | null>(null); + const pendingTokensRef = useRef(null); + const lastEmittedValueRef = useRef(null); + const isMountedRef = useRef(true); + const isSyncingRef = useRef(false); + + onChangeRef.current = onChange; + isEmptyRef.current = isEmpty; + + useImperativeHandle( + ref, + () => ({ + setTokens: (tokens: PromptEditorToken[]) => { + if (editorRef.current) { + editorRef.current.update(() => { + $setEditorTokensInternal(tokens); + }); + } + onChangeRef.current?.(tokens); + }, + insertAutocompleteTrigger: () => { + const editor = editorRef.current; + if (!editor) return; + editor.focus(); + // Use Lexical's own update mechanism to insert '$' at the cursor, + // which triggers the AutocompletePlugin's findTrigger detection. + editor.update(() => { + const selection = $getSelection(); + // insertRawText only exists on a RangeSelection; guard so a NodeSelection + // (a focused token pill) can't make this helper throw. + if ($isRangeSelection(selection)) { + selection.insertRawText('$'); + } + }); + }, + insertVariableToken: (option: PromptEditorAutoCompleteOption) => { + const editor = editorRef.current; + if (!editor) return; + editor.focus(); + editor.update(() => { + $insertTokenAtCursor(option); + }); + }, + }), + [] + ); + + const contentEditableStyle = useMemo(() => { + const verticalPadding = 8; + // Borderless means the parent supplies the field chrome, so inherit its text color instead of + // forcing the theme foreground — otherwise the editor can render e.g. white text on a parent's + // light surface. The bordered variant pairs the foreground with its own `bg-background`. + const textColor = borderless ? 'inherit' : 'var(--color-foreground)'; + const base = { + width: '100%', + outline: 'none', + userSelect: 'text' as const, + boxSizing: 'border-box' as const, + padding: '8px 12px', + fontFamily: "'Noto Sans', sans-serif", + fontSize: '14px', + lineHeight: `${LINE_HEIGHT}px`, + color: textColor, + }; + + if (!multiline) { + return { + ...base, + height: '36px', + maxHeight: '36px', + overflowX: 'auto' as const, + overflowY: 'hidden' as const, + whiteSpace: 'nowrap' as const, + }; + } + + // Clamp the floor to the cap: CSS `min-height` wins over `max-height`, so if `maxRows` is set + // below `minRows` the cap would otherwise be silently ignored. `maxRows` is the authoritative + // upper bound, so the effective minimum can't exceed it. + const effectiveMinRows = Math.min(minRows, maxRows); + const minHeight = effectiveMinRows * LINE_HEIGHT + verticalPadding * 2; + const maxHeight = maxRows * LINE_HEIGHT + verticalPadding * 2; + + return { + ...base, + minHeight: `${minHeight}px`, + ...(fillHeight ? { flex: 1 } : { maxHeight: `${maxHeight}px` }), + overflowY: 'auto' as const, + }; + }, [multiline, minRows, maxRows, fillHeight, borderless]); + + const handleEditorRef = useCallback((editor: LexicalEditor) => { + editorRef.current = editor; + }, []); + + const handleChange = useCallback(() => { + if (!editorRef.current) return; + + editorRef.current.getEditorState().read(() => { + const tokens = $getEditorTokensInternal(); + const newIsEmpty = + tokens.length === 0 || + (tokens.length === 1 && tokens[0].type === 'text' && tokens[0].value === ''); + if (newIsEmpty !== isEmptyRef.current) setIsEmpty(newIsEmpty); + if (!isSyncingRef.current) pendingTokensRef.current = tokens; + }); + + if (isSyncingRef.current || !onChangeRef.current) return; + + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = setTimeout(() => { + if ( + !isMountedRef.current || + !pendingTokensRef.current || + !onChangeRef.current || + !editorRef.current || + isSyncingRef.current + ) + return; + lastEmittedValueRef.current = pendingTokensRef.current; + onChangeRef.current(pendingTokensRef.current); + pendingTokensRef.current = null; + }, 0); + }, []); + + useEffect(() => { + if ( + value && + lastEmittedValueRef.current && + !areTokensEqual(value, lastEmittedValueRef.current) + ) { + lastEmittedValueRef.current = null; + } + }, [value]); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + pendingTokensRef.current = null; + }; + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: seeds the editor exactly once on mount — from initialValue, or from the controlled `value` when no initialValue is given so controlled-only usage renders. Later updates flow through `value` + ValueSyncPlugin. + useEffect(() => { + const seed = initialValue ?? value; + if (!editorRef.current || !seed || initializedRef.current) return; + const valueToSet = seed; + queueMicrotask(() => { + if (!editorRef.current || initializedRef.current) return; + editorRef.current.update(() => { + $setEditorTokensInternal(valueToSet); + setIsEmpty(valueToSet.length === 0); + initializedRef.current = true; + }); + }); + }, []); + + useEffect(() => { + if (!editorRef.current) return; + editorRef.current.setEditable(!disabled); + }, [disabled]); + + // Defensive: tolerate a non-array `autoCompleteOptions` (e.g. an empty object injected by + // Storybook's "Set object" control) so the token plugins never iterate a non-iterable and crash. + const options = Array.isArray(autoCompleteOptions) + ? autoCompleteOptions + : EMPTY_AUTOCOMPLETE_OPTIONS; + + const wrapperClassName = borderless + ? 'flex flex-col w-full relative' + : `prompt-editor-shell flex flex-col w-full relative border bg-background ${showToolbar ? 'border-t-0 rounded-b-md' : 'rounded-md'}`; + + return ( +
+ +
+ } + placeholder={null} + ErrorBoundary={LexicalErrorBoundary} + /> + {placeholder && isEmpty && ( +
+ {placeholder} +
+ )} +
+ + + + + + + + + {options.length > 0 && } + + {options.length > 0 && } +
+ ); + } +); + +EditorInner.displayName = 'PromptEditorInner'; + +export const PromptEditor = ({ + value: rawValue, + initialValue: rawInitialValue, + onChange, + multiline = true, + minRows = DEFAULT_MIN_ROWS, + maxRows = DEFAULT_MAX_ROWS, + placeholder, + disabled, + ariaLabel, + autoCompleteOptions, + showToolbar = false, + mode: controlledMode, + onModeChange, + onFullscreen, + editorRef, + fillHeight, + borderless, +}: PromptEditorProps) => { + // Normalize the token-array props once so malformed input (e.g. `{}` from a Storybook object + // control) can't crash the editor, the preview, or ValueSyncPlugin. + const value = toTokenArray(rawValue); + const initialValue = toTokenArray(rawInitialValue); + + const [internalMode, setInternalMode] = useState('edit'); + const mode = controlledMode ?? internalMode; + const toolbarActionsRef = useRef(null); + const [uncontrolledPreviewTokens, setUncontrolledPreviewTokens] = useState( + initialValue ?? [] + ); + + const handleModeChange = useCallback( + (newMode: PromptEditorMode) => { + if (onModeChange) onModeChange(newMode); + else setInternalMode(newMode); + }, + [onModeChange] + ); + + const initialConfig = useMemo( + () => ({ + namespace: 'PromptEditor', + theme: { paragraph: 'prompt-editor-paragraph' }, + onError: (error: Error) => console.error('PromptEditor error:', error), + nodes: [InputTokenNode, OutputTokenNode, StateTokenNode, ResourceTokenNode], + }), + [] + ); + + const isControlled = value !== undefined; + const previewTokens = isControlled ? value : uncontrolledPreviewTokens; + + const handleEditorChange = useCallback( + (tokens: PromptEditorToken[]) => { + if (!isControlled) setUncontrolledPreviewTokens(tokens); + onChange?.(tokens); + }, + [isControlled, onChange] + ); + + return ( + +
+ {showToolbar && ( + + )} + + {/* Preview mode — mirror `borderless`: when set, the parent supplies the chrome, so drop + the editor's own border/background here too (keeps edit/preview consistent). */} + {mode === 'preview' && ( +
+ +
+ )} + + {/* Editor — keep mounted but hide in preview mode */} +
+ + } + autoCompleteOptions={autoCompleteOptions} + disabled={disabled} + ariaLabel={ariaLabel} + initialValue={initialValue} + maxRows={maxRows} + minRows={minRows} + multiline={multiline} + placeholder={placeholder} + fillHeight={fillHeight} + borderless={borderless} + showToolbar={showToolbar} + toolbarActionsRef={toolbarActionsRef} + value={value} + onChange={handleEditorChange} + /> + +
+
+
+ ); +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/types.ts b/packages/apollo-wind/src/components/ui/prompt-editor/types.ts new file mode 100644 index 000000000..e78322d44 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/types.ts @@ -0,0 +1,116 @@ +export type PromptEditorTokenType = 'input' | 'output' | 'state' | 'resource' | 'text'; + +interface BaseToken { + type: PromptEditorTokenType; + /** + * Variable path **without** a leading `$` — e.g. `vars.firstName`, `state.retryCount`. apollo-wind + * owns this no-`$` convention (unlike flow-workbench's `$vars.firstName`). Serialization and + * `ValidateTokensPlugin` compare these by exact string equality against + * {@link PromptEditorAutoCompleteOption.value}, so the two must use the same `$`-less form. + */ + value: string; +} + +export interface PromptEditorTextToken extends BaseToken { + type: 'text'; +} + +export interface PromptEditorInputToken extends BaseToken { + type: 'input'; +} + +export interface PromptEditorOutputToken extends BaseToken { + type: 'output'; +} + +export interface PromptEditorStateToken extends BaseToken { + type: 'state'; +} + +export interface PromptEditorResourceToken extends BaseToken { + type: 'resource'; +} + +export type PromptEditorToken = + | PromptEditorInputToken + | PromptEditorOutputToken + | PromptEditorStateToken + | PromptEditorResourceToken + | PromptEditorTextToken; + +export interface PromptEditorAutoCompleteOption { + type: Exclude; + /** Variable path without a leading `$` (e.g. `vars.firstName`). See {@link PromptEditorToken} value. */ + value: string; +} + +export type PromptEditorMode = 'edit' | 'preview'; + +export interface PromptEditorToolbarActionsRef { + formatBold: () => void; + formatItalic: () => void; + formatStrikethrough: () => void; + formatNumberedList: () => void; + formatBulletedList: () => void; +} + +export type PromptEditorToolbarFormatAction = + | 'bold' + | 'bulletedList' + | 'italic' + | 'numberedList' + | 'strikethrough'; + +export interface PromptEditorTokenColorConfig { + background: string; + border: string; + text: string; + icon: string; +} + +/** + * Two-state palette: invalid = red, valid = blue. Backed by apollo-wind's semantic design tokens, + * which already resolve per light/dark theme via CSS — no JS theme detection needed. + */ +export const getPromptEditorTokenColors = (): Record< + 'valid' | 'invalid', + PromptEditorTokenColorConfig +> => ({ + valid: { + background: 'var(--color-primary-lighter)', + border: 'var(--color-primary)', + text: 'var(--color-foreground)', + icon: 'var(--color-primary)', + }, + invalid: { + background: 'var(--color-error-background)', + border: 'var(--color-error)', + text: 'var(--color-error-text)', + icon: 'var(--color-error-icon)', + }, +}); + +/** Human-readable label for a token type, used in the chip's hover tooltip. */ +export const getPromptEditorTokenTypeLabel = (type: PromptEditorTokenType): string => { + switch (type) { + case 'input': + return 'Input variable'; + case 'output': + return 'Output variable'; + case 'state': + return 'State variable'; + case 'resource': + return 'Resource'; + default: + return 'Text'; + } +}; + +export type PromptEditorDiffType = 'added' | 'removed'; + +export const isPromptEditorTextToken = (token: PromptEditorToken): token is PromptEditorTextToken => + token.type === 'text'; + +export const isPromptEditorNonTextToken = ( + token: PromptEditorToken +): token is Exclude => token.type !== 'text'; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/utils/autocomplete-segments.test.ts b/packages/apollo-wind/src/components/ui/prompt-editor/utils/autocomplete-segments.test.ts new file mode 100644 index 000000000..47e29dc3d --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/utils/autocomplete-segments.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import type { PromptEditorAutoCompleteOption } from '../types'; +import { + inferTokenTypeFromPath, + shouldSuppressOpenForDismissed, + VARIABLE_PATH_REGEX, +} from './autocomplete-segments'; + +const OPTIONS: PromptEditorAutoCompleteOption[] = [ + { type: 'input', value: 'vars.firstName' }, + { type: 'output', value: 'vars.summary' }, + { type: 'state', value: 'state.retry' }, +]; + +describe('inferTokenTypeFromPath', () => { + it('returns the exact option type when the path matches', () => { + expect(inferTokenTypeFromPath('vars.summary', OPTIONS)).toBe('output'); + }); + + it("falls back to the closest known ancestor's type", () => { + expect(inferTokenTypeFromPath('vars.summary.first', OPTIONS)).toBe('output'); + }); + + it("defaults to 'input' when nothing matches", () => { + expect(inferTokenTypeFromPath('unknown.path', OPTIONS)).toBe('input'); + expect(inferTokenTypeFromPath('vars.unmatched', OPTIONS)).toBe('input'); + }); +}); + +describe('VARIABLE_PATH_REGEX (free-form Enter commit)', () => { + it.each([ + 'vars.firstName', + 'metadata.run.id', + 'agent.name', + 'vars.a.b.c', + ])('accepts valid path %s', (path) => expect(VARIABLE_PATH_REGEX.test(path)).toBe(true)); + + it.each([ + 'vars', + 'vars.', + 'foo.bar', + '$vars.firstName', + 'vars.1bad', + '', + ])('rejects invalid path %s', (path) => expect(VARIABLE_PATH_REGEX.test(path)).toBe(false)); +}); + +describe('shouldSuppressOpenForDismissed', () => { + it('does not suppress when nothing was dismissed', () => { + expect(shouldSuppressOpenForDismissed({ triggerIndex: 0, nodeKey: 'a' }, null)).toBe(false); + }); + + it('suppresses when the trigger matches the dismissed position', () => { + const t = { triggerIndex: 3, nodeKey: 'node-1' }; + expect(shouldSuppressOpenForDismissed(t, { ...t })).toBe(true); + }); + + it('does not suppress a different node or index', () => { + const dismissed = { triggerIndex: 3, nodeKey: 'node-1' }; + expect(shouldSuppressOpenForDismissed({ triggerIndex: 3, nodeKey: 'node-2' }, dismissed)).toBe( + false + ); + expect(shouldSuppressOpenForDismissed({ triggerIndex: 4, nodeKey: 'node-1' }, dismissed)).toBe( + false + ); + }); +}); diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/utils/autocomplete-segments.ts b/packages/apollo-wind/src/components/ui/prompt-editor/utils/autocomplete-segments.ts new file mode 100644 index 000000000..b93e40f5c --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/utils/autocomplete-segments.ts @@ -0,0 +1,56 @@ +import type { PromptEditorAutoCompleteOption, PromptEditorTokenType } from '../types'; + +/** + * Helpers for the prompt editor's `$`-trigger flow that survived after the bespoke segment-aware + * drill-in menu was replaced by ``. The picker itself owns search, + * keyboard nav, and visual rendering — these helpers cover the parts of the trigger flow that live + * in `AutocompletePlugin`: free-form Enter commit (when the typed `$prefix.path` doesn't match any + * scope entry) and the dismissal sentinel that suppresses re-opening on cursor returns. + */ + +/** + * Infer the token type for a typed-but-unmatched path by walking up parent prefixes in the option set. + * Mirrors `findVariableForPath`'s ancestor-fallback shape so a free-form chip inherits the closest + * known parent's type. Returns `'input'` when no ancestor matches. + */ +export const inferTokenTypeFromPath = ( + path: string, + options: PromptEditorAutoCompleteOption[] +): Exclude => { + for (const opt of options) { + if (opt.value === path) return opt.type; + } + const segments = path.split('.'); + while (segments.length > 1) { + segments.pop(); + const prefix = segments.join('.'); + for (const opt of options) { + if (opt.value === prefix) return opt.type; + } + } + return 'input'; +}; + +/** Recognised free-form path syntax for committing typed-but-unmatched paths as chips. */ +export const VARIABLE_PATH_REGEX = /^(?:vars|metadata|agent)\.[a-zA-Z_][a-zA-Z0-9_.]*$/; + +/** Identifier for the `$` position the picker was last dismissed for. */ +export interface TriggerKey { + triggerIndex: number; + nodeKey: string; +} + +/** + * Decide whether the autocomplete should suppress reopening. After the user dismisses the picker + * (Escape, click-outside), we remember the dismissed `$` position and don't reopen for it on a + * cursor-only return — gives the user a window to Backspace the `$` without the picker grabbing + * focus again. Once the editor's text changes (any new typing), the dismissal sentinel is cleared + * and the next valid trigger opens the picker fresh. + */ +export function shouldSuppressOpenForDismissed( + trigger: TriggerKey, + dismissed: TriggerKey | null +): boolean { + if (!dismissed) return false; + return dismissed.nodeKey === trigger.nodeKey && dismissed.triggerIndex === trigger.triggerIndex; +} diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/utils/comparison.test.ts b/packages/apollo-wind/src/components/ui/prompt-editor/utils/comparison.test.ts new file mode 100644 index 000000000..669ee263f --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/utils/comparison.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import type { PromptEditorToken } from '../types'; +import { areTokensEqual } from './comparison'; + +const base: PromptEditorToken[] = [ + { type: 'text', value: 'Hi ' }, + { type: 'input', value: 'vars.firstName' }, +]; + +describe('areTokensEqual', () => { + it('treats two empty arrays as equal', () => { + expect(areTokensEqual([], [])).toBe(true); + }); + + it('returns true for identical token arrays', () => { + expect(areTokensEqual(base, [...base.map((t) => ({ ...t }))])).toBe(true); + }); + + it('returns false when lengths differ', () => { + expect(areTokensEqual(base, base.slice(0, 1))).toBe(false); + }); + + it('returns false when a token type differs', () => { + expect( + areTokensEqual(base, [ + { type: 'text', value: 'Hi ' }, + { type: 'output', value: 'vars.firstName' }, + ]) + ).toBe(false); + }); + + it('returns false when a token value differs', () => { + expect( + areTokensEqual(base, [ + { type: 'text', value: 'Hi ' }, + { type: 'input', value: 'vars.lastName' }, + ]) + ).toBe(false); + }); +}); diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/utils/comparison.ts b/packages/apollo-wind/src/components/ui/prompt-editor/utils/comparison.ts new file mode 100644 index 000000000..8c57628d8 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/utils/comparison.ts @@ -0,0 +1,7 @@ +import type { PromptEditorToken } from '../types'; + +/** Check if two token arrays are deeply equal */ +export const areTokensEqual = (a: PromptEditorToken[], b: PromptEditorToken[]): boolean => { + if (a.length !== b.length) return false; + return a.every((token, i) => token.type === b[i].type && token.value === b[i].value); +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/utils/index.ts b/packages/apollo-wind/src/components/ui/prompt-editor/utils/index.ts new file mode 100644 index 000000000..8d87ca889 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/utils/index.ts @@ -0,0 +1,12 @@ +export { + $getEditorTokensInternal, + $setEditorTokensInternal, + getEditorTokens, + setEditorTokens, + tokensToClipboardString, + clipboardStringToTokens, + getEditorTokensFromSelection, + WORD_JOINER, +} from './serialization'; +export { areTokensEqual } from './comparison'; +export { $insertTokenAtCursor, createTokenNodeForOption } from './insert-token'; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/utils/insert-token.ts b/packages/apollo-wind/src/components/ui/prompt-editor/utils/insert-token.ts new file mode 100644 index 000000000..55d4a059b --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/utils/insert-token.ts @@ -0,0 +1,108 @@ +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isElementNode, + $isRangeSelection, + $isTextNode, +} from 'lexical'; +import { + createInputTokenNode, + createOutputTokenNode, + createStateTokenNode, + createResourceTokenNode, +} from '../nodes'; +import type { PromptEditorAutoCompleteOption } from '../types'; + +/** Create the correct Lexical token decorator node for an autocomplete option. */ +export const createTokenNodeForOption = (option: PromptEditorAutoCompleteOption) => { + switch (option.type) { + case 'input': + return createInputTokenNode(option.value); + case 'output': + return createOutputTokenNode(option.value); + case 'state': + return createStateTokenNode(option.value); + case 'resource': + return createResourceTokenNode(option.value); + default: + return createInputTokenNode(option.value); + } +}; + +/** + * Insert a token pill at the current cursor position. Must be called inside an + * `editor.update(() => ...)` block. + * + * - Text anchor: splits the text and inserts between the halves (or appends/prepends at edges). + * - Element anchor: inserts as a child at the selection offset. + * - No selection: appends to the root so the action is not silently dropped. + */ +export const $insertTokenAtCursor = (option: PromptEditorAutoCompleteOption): void => { + const tokenNode = createTokenNodeForOption(option); + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + // No selection — append to the last paragraph/element of the root so the user sees the result. + const root = $getRoot(); + const lastChild = root.getLastChild(); + if (lastChild && $isElementNode(lastChild)) { + lastChild.append(tokenNode); + } else { + // RootNode only accepts block/element children, so wrap the inline token in a paragraph + // rather than appending the decorator directly to the root (which would corrupt the state). + const paragraph = $createParagraphNode(); + paragraph.append(tokenNode); + root.append(paragraph); + } + tokenNode.selectNext(0, 0); + return; + } + + // Collapse any non-empty selection first so we replace it with the token. + if (!selection.isCollapsed()) { + selection.removeText(); + } + + const anchorNode = selection.anchor.getNode(); + const anchorOffset = selection.anchor.offset; + + if ($isTextNode(anchorNode)) { + const textContent = anchorNode.getTextContent(); + const textBefore = textContent.slice(0, anchorOffset); + const textAfter = textContent.slice(anchorOffset); + + if (textBefore && textAfter) { + anchorNode.setTextContent(textBefore); + anchorNode.insertAfter(tokenNode); + const textAfterNode = $createTextNode(textAfter); + tokenNode.insertAfter(textAfterNode); + textAfterNode.select(0, 0); + } else if (textBefore) { + anchorNode.setTextContent(textBefore); + anchorNode.insertAfter(tokenNode); + tokenNode.selectNext(0, 0); + } else if (textAfter) { + anchorNode.insertBefore(tokenNode); + tokenNode.selectNext(0, 0); + } else { + anchorNode.replace(tokenNode); + tokenNode.selectNext(0, 0); + } + return; + } + + if ($isElementNode(anchorNode)) { + const children = anchorNode.getChildren(); + const childAtOffset = children[anchorOffset]; + if (childAtOffset) { + childAtOffset.insertBefore(tokenNode); + } else { + anchorNode.append(tokenNode); + } + tokenNode.selectNext(0, 0); + } + // Any other anchor type (line breaks, decorator nodes) is a rare edge case — + // silently no-op rather than risk corrupting the editor state. +}; diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/utils/serialization.test.ts b/packages/apollo-wind/src/components/ui/prompt-editor/utils/serialization.test.ts new file mode 100644 index 000000000..e5bc716d4 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/utils/serialization.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import type { PromptEditorToken } from '../types'; +import { clipboardStringToTokens, tokensToClipboardString } from './serialization'; + +describe('clipboard serialization', () => { + describe('tokensToClipboardString', () => { + it('writes text tokens verbatim and wraps variable tokens in {{ }}', () => { + const tokens: PromptEditorToken[] = [ + { type: 'text', value: 'Hi ' }, + { type: 'input', value: 'vars.firstName' }, + { type: 'text', value: ', see ' }, + { type: 'output', value: 'vars.summary' }, + ]; + expect(tokensToClipboardString(tokens)).toBe( + 'Hi {{ vars.firstName }}, see {{ vars.summary }}' + ); + }); + + it('returns an empty string for no tokens', () => { + expect(tokensToClipboardString([])).toBe(''); + }); + }); + + describe('clipboardStringToTokens', () => { + it('parses plain text into a single text token', () => { + expect(clipboardStringToTokens('just text')).toEqual([{ type: 'text', value: 'just text' }]); + }); + + it('parses a {{ }} segment into a variable token (trimmed)', () => { + expect(clipboardStringToTokens('{{ vars.firstName }}')).toEqual([ + { type: 'input', value: 'vars.firstName' }, + ]); + }); + + it('splits mixed text and variable segments', () => { + expect(clipboardStringToTokens('Hi {{ vars.x }}!')).toEqual([ + { type: 'text', value: 'Hi ' }, + { type: 'input', value: 'vars.x' }, + { type: 'text', value: '!' }, + ]); + }); + + it('keeps an empty {{}} as literal text rather than an empty token', () => { + // leading text is flushed before the brace is handled, so the literal `{{}}` is kept as text + // and emitted as its own (unmerged) text token + expect(clipboardStringToTokens('a {{}} b')).toEqual([ + { type: 'text', value: 'a ' }, + { type: 'text', value: '{{}} b' }, + ]); + }); + + it('keeps an unclosed {{ as literal text', () => { + expect(clipboardStringToTokens('a {{ vars.x')).toEqual([ + { type: 'text', value: 'a ' }, + { type: 'text', value: '{{ vars.x' }, + ]); + }); + }); + + describe('round-trip', () => { + it('preserves text and variable values (non-text types normalize to input on paste)', () => { + const original: PromptEditorToken[] = [ + { type: 'text', value: 'Greet ' }, + { type: 'output', value: 'vars.summary' }, + ]; + const reparsed = clipboardStringToTokens(tokensToClipboardString(original)); + expect(reparsed).toEqual([ + { type: 'text', value: 'Greet ' }, + // pasted variables default to 'input' — value + text round-trip, type does not + { type: 'input', value: 'vars.summary' }, + ]); + }); + }); +}); diff --git a/packages/apollo-wind/src/components/ui/prompt-editor/utils/serialization.ts b/packages/apollo-wind/src/components/ui/prompt-editor/utils/serialization.ts new file mode 100644 index 000000000..60bf0a379 --- /dev/null +++ b/packages/apollo-wind/src/components/ui/prompt-editor/utils/serialization.ts @@ -0,0 +1,288 @@ +import { + $createLineBreakNode, + $createParagraphNode, + $createTextNode, + $getRoot, + $isLineBreakNode, + $isParagraphNode, + $isRangeSelection, + $isTextNode, + type BaseSelection, + type LexicalEditor, + type LexicalNode, + type ParagraphNode, +} from 'lexical'; +import { + createInputTokenNode, + InputTokenNode, + isInputTokenNode, + createOutputTokenNode, + isOutputTokenNode, + OutputTokenNode, + createStateTokenNode, + isStateTokenNode, + StateTokenNode, + createResourceTokenNode, + isResourceTokenNode, + ResourceTokenNode, +} from '../nodes'; +import type { PromptEditorToken } from '../types'; + +/** Convert Lexical editor state to PromptEditorToken[] */ +export const WORD_JOINER = '⁠'; + +const appendTextToken = (tokens: PromptEditorToken[], raw: string) => { + const text = raw.split(WORD_JOINER).join(''); + if (!text) return; + const lastToken = tokens[tokens.length - 1]; + if (lastToken && lastToken.type === 'text') { + lastToken.value += text; + } else { + tokens.push({ type: 'text', value: text }); + } +}; + +export const $getEditorTokensInternal = (): PromptEditorToken[] => { + const root = $getRoot(); + const tokens: PromptEditorToken[] = []; + + const addText = (raw: string) => appendTextToken(tokens, raw); + + const traverseChildren = (children: LexicalNode[]) => { + for (const node of children) { + if ($isTextNode(node)) { + addText(node.getTextContent()); + } else if ($isLineBreakNode(node)) { + addText('\n'); + } else if (isInputTokenNode(node)) { + tokens.push({ type: 'input', value: (node as InputTokenNode).getValue() }); + } else if (isOutputTokenNode(node)) { + tokens.push({ type: 'output', value: (node as OutputTokenNode).getValue() }); + } else if (isStateTokenNode(node)) { + tokens.push({ type: 'state', value: (node as StateTokenNode).getValue() }); + } else if (isResourceTokenNode(node)) { + tokens.push({ type: 'resource', value: (node as ResourceTokenNode).getValue() }); + } + } + }; + + const paragraphs = root.getChildren(); + for (let i = 0; i < paragraphs.length; i++) { + const paragraph = paragraphs[i]; + if ($isParagraphNode(paragraph)) { + traverseChildren((paragraph as ParagraphNode).getChildren()); + if (i < paragraphs.length - 1) { + addText('\n'); + } + } + } + + return tokens; +}; + +/** Convert PromptEditorToken[] to Lexical editor state */ +export const $setEditorTokensInternal = (tokens: PromptEditorToken[]) => { + if (!Array.isArray(tokens)) { + throw new TypeError('setEditorTokensInternal: tokens must be an array'); + } + + const root = $getRoot(); + root.clear(); + + const currentParagraph = $createParagraphNode(); + + for (const token of tokens) { + if (token.type === 'text') { + const lines = token.value.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i]) { + currentParagraph.append($createTextNode(lines[i])); + } + if (i < lines.length - 1) { + currentParagraph.append($createLineBreakNode()); + } + } + } else { + let node: LexicalNode; + switch (token.type) { + case 'input': + node = createInputTokenNode(token.value); + break; + case 'output': + node = createOutputTokenNode(token.value); + break; + case 'state': + node = createStateTokenNode(token.value); + break; + case 'resource': + node = createResourceTokenNode(token.value); + break; + default: + throw new Error(`Unknown token type: ${(token as { type: string }).type}`); + } + currentParagraph.append(node); + } + } + + root.append(currentParagraph); +}; + +export const getEditorTokens = (editor: LexicalEditor): PromptEditorToken[] => { + let tokens: PromptEditorToken[] = []; + editor.getEditorState().read(() => { + tokens = $getEditorTokensInternal(); + }); + return tokens; +}; + +export const setEditorTokens = (editor: LexicalEditor, tokens: PromptEditorToken[]) => { + editor.update(() => $setEditorTokensInternal(tokens), { discrete: true }); +}; + +/** Serialize tokens to clipboard-friendly string (using {{ }} for variables) */ +export const tokensToClipboardString = (tokens: PromptEditorToken[]): string => { + let result = ''; + for (const token of tokens) { + if (token.type === 'text') { + result += token.value; + } else { + // All non-text tokens use {{ }} syntax for clipboard + result += `{{ ${token.value} }}`; + } + } + return result; +}; + +/** Parse a clipboard string back into tokens */ +export const clipboardStringToTokens = (str: string): PromptEditorToken[] => { + const tokens: PromptEditorToken[] = []; + let currentText = ''; + let i = 0; + + const flushText = () => { + if (currentText.length > 0) { + tokens.push({ type: 'text', value: currentText }); + currentText = ''; + } + }; + + while (i < str.length) { + // Check for {{ }} token + if (str[i] === '{' && i + 1 < str.length && str[i + 1] === '{') { + flushText(); + i += 2; + let inner = ''; + let foundClosing = false; + while (i < str.length) { + if (str[i] === '}' && i + 1 < str.length && str[i + 1] === '}') { + i += 2; + foundClosing = true; + break; + } + inner += str[i]; + i++; + } + if (foundClosing && inner.trim()) { + // Default to input type for pasted variables + tokens.push({ type: 'input', value: inner.trim() }); + } else if (foundClosing) { + currentText += '{{}}'; + } else { + currentText += `{{${inner}`; + } + continue; + } + + currentText += str[i]; + i++; + } + + flushText(); + return tokens; +}; + +/** Get tokens from a selection (for copy/cut operations) */ +export const getEditorTokensFromSelection = (selection: BaseSelection): PromptEditorToken[] => { + if (!$isRangeSelection(selection)) return []; + if (selection.isCollapsed()) return []; + + const tokens: PromptEditorToken[] = []; + const addText = (raw: string) => appendTextToken(tokens, raw); + + const selectedNodes = selection.getNodes(); + const anchorKey = selection.anchor.key; + const focusKey = selection.focus.key; + const anchorOffset = selection.anchor.offset; + const focusOffset = selection.focus.offset; + + const anchorNode = selection.anchor.getNode(); + const nodeKeys = new Set(selectedNodes.map((n) => n.getKey())); + const nodesToProcess: LexicalNode[] = []; + + if ($isTextNode(anchorNode) && !nodeKeys.has(anchorKey)) { + nodesToProcess.push(anchorNode); + } + nodesToProcess.push(...selectedNodes); + + if (nodesToProcess.length === 0) return []; + + const firstSelectedNode = nodesToProcess[0]; + const isForward = + nodesToProcess.length === 1 + ? anchorOffset <= focusOffset + : firstSelectedNode.getKey() === anchorKey || + firstSelectedNode.getParent()?.getKey() === anchorKey; + + let prevNodeParagraph: ParagraphNode | null = null; + + for (const node of nodesToProcess) { + const nodeKey = node.getKey(); + const nodeType = node.getType(); + const nodeParagraph = $isParagraphNode(node) + ? node + : (node.getParent() as ParagraphNode | null); + + if (prevNodeParagraph && nodeParagraph && prevNodeParagraph !== nodeParagraph) { + addText('\n'); + } + prevNodeParagraph = nodeParagraph; + + if ($isParagraphNode(node)) continue; + + if (nodeType === 'input-token') { + tokens.push({ type: 'input', value: (node as InputTokenNode).getValue() }); + } else if (nodeType === 'output-token') { + tokens.push({ type: 'output', value: (node as OutputTokenNode).getValue() }); + } else if (nodeType === 'state-token') { + tokens.push({ type: 'state', value: (node as StateTokenNode).getValue() }); + } else if (nodeType === 'resource-token') { + tokens.push({ type: 'resource', value: (node as ResourceTokenNode).getValue() }); + } else if ($isTextNode(node)) { + const textContent = node.getTextContent(); + let selectedText = textContent; + + const isAnchorNode = nodeKey === anchorKey; + const isFocusNode = nodeKey === focusKey; + + if (isAnchorNode && isFocusNode) { + const start = Math.min(anchorOffset, focusOffset); + const end = Math.max(anchorOffset, focusOffset); + selectedText = textContent.slice(start, end); + } else if (isAnchorNode) { + selectedText = isForward + ? textContent.slice(anchorOffset) + : textContent.slice(0, anchorOffset); + } else if (isFocusNode) { + selectedText = isForward + ? textContent.slice(0, focusOffset) + : textContent.slice(focusOffset); + } + + if (selectedText) addText(selectedText); + } else if ($isLineBreakNode(node)) { + addText('\n'); + } + } + + return tokens; +}; diff --git a/packages/apollo-wind/src/index.ts b/packages/apollo-wind/src/index.ts index 12d90f7d3..ddb056525 100644 --- a/packages/apollo-wind/src/index.ts +++ b/packages/apollo-wind/src/index.ts @@ -391,3 +391,16 @@ export { isFileField, isCustomField, } from './components/forms/form-schema'; + +// ----------------------------------------------------------------------------- +// Prompt Editor +// ----------------------------------------------------------------------------- +export { PromptEditor } from './components/ui/prompt-editor'; +export type { + PromptEditorProps, + PromptEditorRef, + PromptEditorToken, + PromptEditorTokenType, + PromptEditorAutoCompleteOption, + PromptEditorMode, +} from './components/ui/prompt-editor'; diff --git a/packages/apollo-wind/src/styles/tailwind.utilities.css b/packages/apollo-wind/src/styles/tailwind.utilities.css index b9ea8ef74..2d15dffc3 100644 --- a/packages/apollo-wind/src/styles/tailwind.utilities.css +++ b/packages/apollo-wind/src/styles/tailwind.utilities.css @@ -60,4 +60,39 @@ @utility animate-fade-in { animation: apollo-fade-in 250ms ease-out; -} \ No newline at end of file +} + +/* ============================================================================ + * PromptEditor markdown preview + * + * Element styles for the sanitized HTML that PromptEditor's preview mode renders + * from `marked` output (headings, lists, code, token pills, …). Scoped under + * `.prompt-editor-preview` and colored with design tokens. Lives here — in the + * package stylesheet consumers already import — rather than a per-component + * `.css` import, which doesn't resolve next to the emitted JS in a bundless build. + * ========================================================================== */ +.prompt-editor-preview { color: var(--color-foreground); } +.prompt-editor-preview-empty { color: var(--color-muted-foreground); } +.prompt-editor-preview h1 { font-size: 1.5em; font-weight: 700; margin: 0.5em 0 0.25em; line-height: 1.3; } +.prompt-editor-preview h2 { font-size: 1.25em; font-weight: 700; margin: 0.5em 0 0.25em; line-height: 1.3; } +.prompt-editor-preview h3 { font-size: 1.1em; font-weight: 600; margin: 0.4em 0 0.2em; line-height: 1.3; } +.prompt-editor-preview h4, .prompt-editor-preview h5, .prompt-editor-preview h6 { font-size: 1em; font-weight: 600; margin: 0.4em 0 0.2em; line-height: 1.3; } +.prompt-editor-preview p { margin: 0.25em 0; } +.prompt-editor-preview code { font-family: 'Fira Code', 'Consolas', monospace; font-size: 0.875em; padding: 0.15em 0.4em; border-radius: 4px; background-color: var(--color-muted); color: var(--color-foreground); } +.prompt-editor-preview pre { margin: 0.5em 0; padding: 0.75em 1em; border-radius: 6px; overflow-x: auto; background-color: var(--color-muted); color: var(--color-foreground); } +.prompt-editor-preview pre code { padding: 0; background: none; font-size: 0.85em; } +.prompt-editor-preview blockquote { margin: 0.5em 0; padding: 0.25em 0.75em; border-left: 3px solid var(--color-border); color: var(--color-muted-foreground); } +.prompt-editor-preview ul, .prompt-editor-preview ol { margin: 0.25em 0; padding-left: 1.5em; } +.prompt-editor-preview ul { list-style-type: disc; } +.prompt-editor-preview ol { list-style-type: decimal; } +.prompt-editor-preview li { margin: 0.1em 0; } +.prompt-editor-preview a { color: var(--color-primary); text-decoration: underline; text-underline-offset: 2px; } +.prompt-editor-preview a:hover { opacity: 0.8; } +.prompt-editor-preview hr { margin: 0.75em 0; border: none; border-top: 1px solid var(--color-border); } +.prompt-editor-preview table { border-collapse: collapse; margin: 0.5em 0; width: 100%; } +.prompt-editor-preview th, .prompt-editor-preview td { border: 1px solid var(--color-border); padding: 0.35em 0.75em; text-align: left; } +.prompt-editor-preview th { font-weight: 600; background-color: var(--color-muted); } +.prompt-editor-preview strong { font-weight: 700; } +.prompt-editor-preview em { font-style: italic; } +.prompt-editor-preview .token-pill { display: inline-flex; align-items: center; gap: 3px; height: 20px; padding: 0 4px; border-radius: 4px; font-size: 13px; line-height: 20px; vertical-align: middle; background: var(--color-primary-lighter); color: var(--color-foreground); } +.prompt-editor-preview .token-pill svg { display: block; flex-shrink: 0; color: var(--color-primary); width: 14px; height: 14px; } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f68f57ee..9bd5edfe7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -580,35 +580,35 @@ importers: specifier: ^0.27.15 version: 0.27.19(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@lexical/code': - specifier: 0.16.0 - version: 0.16.0 + specifier: 0.42.0 + version: 0.42.0 '@lexical/html': - specifier: 0.16.0 - version: 0.16.0 + specifier: 0.42.0 + version: 0.42.0 '@lexical/link': - specifier: 0.16.0 - version: 0.16.0 + specifier: 0.42.0 + version: 0.42.0 '@lexical/list': - specifier: 0.16.0 - version: 0.16.0 + specifier: 0.42.0 + version: 0.42.0 '@lexical/markdown': - specifier: 0.16.0 - version: 0.16.0 + specifier: 0.42.0 + version: 0.42.0 '@lexical/react': - specifier: 0.16.0 - version: 0.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(yjs@13.6.30) + specifier: 0.42.0 + version: 0.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(yjs@13.6.30) '@lexical/rich-text': - specifier: 0.16.0 - version: 0.16.0 + specifier: 0.42.0 + version: 0.42.0 '@lexical/selection': - specifier: 0.16.0 - version: 0.16.0 + specifier: 0.42.0 + version: 0.42.0 '@lexical/table': - specifier: 0.16.0 - version: 0.16.0 + specifier: 0.42.0 + version: 0.42.0 '@lexical/utils': - specifier: 0.16.0 - version: 0.16.0 + specifier: 0.42.0 + version: 0.42.0 '@lingui/core': specifier: ^5.6.1 version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.6.1(babel-plugin-macros@3.1.0)(typescript@5.9.3))(babel-plugin-macros@3.1.0) @@ -703,8 +703,8 @@ importers: specifier: ^0.16.27 version: 0.16.27 lexical: - specifier: 0.16.0 - version: 0.16.0 + specifier: 0.42.0 + version: 0.42.0 lodash: specifier: ^4.18.1 version: 4.18.1 @@ -916,6 +916,15 @@ importers: '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.66.1(react@19.2.3)) + '@lexical/clipboard': + specifier: 0.42.0 + version: 0.42.0 + '@lexical/react': + specifier: 0.42.0 + version: 0.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(yjs@13.6.30) + '@lexical/utils': + specifier: 0.42.0 + version: 0.42.0 '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1021,15 +1030,24 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + dompurify: + specifier: ^3.4.0 + version: 3.4.0 framer-motion: specifier: ^12.26.2 version: 12.26.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) jsep: specifier: ^1.4.0 version: 1.4.0 + lexical: + specifier: 0.42.0 + version: 0.42.0 lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.3) + marked: + specifier: ^17.0.6 + version: 17.0.6 next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -3283,74 +3301,83 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@lexical/clipboard@0.16.0': - resolution: {integrity: sha512-eYMJ6jCXpWBVC05Mu9HLMysrBbfi++xFfsm+Yo7A6kYGrqYUhpXqjJkYnw1xdZYL3bV73Oe4ByVJuq42GU+Mqw==} + '@lexical/clipboard@0.42.0': + resolution: {integrity: sha512-D3K2ID0zew/+CKpwxnUTTh/N46yU4IK8bFWV9Htz+g1vFhgUF9UnDOQCmqpJbdP7z+9U1F8rk3fzf9OmP2Fm2w==} - '@lexical/code@0.16.0': - resolution: {integrity: sha512-1EKCBSFV745UI2zn5v75sKcvVdmd+y2JtZhw8CItiQkRnBLv4l4d/RZYy+cKOuXJGsoBrKtxXn5sl7HebwQbPw==} + '@lexical/code-core@0.42.0': + resolution: {integrity: sha512-vrZTUPWDJkHjAAvuV2+Qte4vYE80s7hIO7wxipiJmWojGx6lcmQjO+UqJ8AIrqI4Wjy8kXrK74kisApWmwxuCw==} - '@lexical/devtools-core@0.16.0': - resolution: {integrity: sha512-Jt8p0J0UoMHf3UMh3VdyrXbLLwpEZuMqihTmbPRpwo+YQ6NGQU35QgwY2K0DpPAThpxL/Cm7uaFqGOy8Kjrhqw==} + '@lexical/code-prism@0.42.0': + resolution: {integrity: sha512-KgngkUtgcgC8ocBnfGyN71CC3EnP5PMFAmH1KcGp/+jSgl11nRpCjwYYIoUHm6AB7jKJ8dLbd/UUmShARjUnGA==} + + '@lexical/code@0.42.0': + resolution: {integrity: sha512-KMu1nWae9pHvA9nl6dlJacbt3QBBNemgalmLJcZ5QhdGEQA1cVIU4gBPJ5TJqgY9XF7WZgj5JvDIPxjrZmf+XQ==} + + '@lexical/devtools-core@0.42.0': + resolution: {integrity: sha512-8nP8eE9i8JImgSrvInkWFfMCmXVKp3w3VaOvbJysdlK/Zal6xd8EWJEi6elj0mUW5T/oycfipPs2Sfl7Z+n14A==} peerDependencies: react: '>=17.x' react-dom: '>=17.x' - '@lexical/dragon@0.16.0': - resolution: {integrity: sha512-Yr29SFZzOPs+S6UrEZaXnnso1fJGVfZOXVJQZbyzlspqJpSHXVH7InOXYHWN6JSWQ8Hs/vU3ksJXwqz+0TCp2g==} + '@lexical/dragon@0.42.0': + resolution: {integrity: sha512-/TQzP+7PLJMqq9+MlgQWiJsxS9GOOa8Gp0svCD8vNIOciYmXfd28TR1Go+ZnBWwr7k/2W++3XUYVQU2KUcQsDQ==} + + '@lexical/extension@0.42.0': + resolution: {integrity: sha512-rkZq/h8d1BenKRqU4t/zQUVfY/RinMX1Tz7t+Ee3ss0sk+kzP4W+URXNAxpn7r39Vn6wrFBqmCziah3dLAIqPw==} - '@lexical/hashtag@0.16.0': - resolution: {integrity: sha512-2EdAvxYVYqb0nv6vgxCRgE8ip7yez5p0y0oeUyxmdbcfZdA+Jl90gYH3VdevmZ5Bk3wE0/fIqiLD+Bb5smqjCQ==} + '@lexical/hashtag@0.42.0': + resolution: {integrity: sha512-WOg5nFOfhabNBXzEIutdWDj+TUHtJEezj6w8jyYDGqZ31gu0cgrXSeV8UIynz/1oj+rpzEeEB7P6ODnwgjt7qA==} - '@lexical/history@0.16.0': - resolution: {integrity: sha512-xwFxgDZGviyGEqHmgt6A6gPhsyU/yzlKRk9TBUVByba3khuTknlJ1a80H5jb+OYcrpiElml7iVuGYt+oC7atCA==} + '@lexical/history@0.42.0': + resolution: {integrity: sha512-YfCZ1ICUt6BCg2ncJWFMuS4yftnB7FEHFRf3qqTSTf6oGZ4IZfzabMNEy47xybUuf7FXBbdaCKJrc/zOM+wGxw==} - '@lexical/html@0.16.0': - resolution: {integrity: sha512-okxn3q/1qkUpCZNEFRI39XeJj4YRjb6prm3WqZgP4d39DI1W24feeTZJjYRCW+dc3NInwFaolU3pNA2MGkjRtg==} + '@lexical/html@0.42.0': + resolution: {integrity: sha512-KgBUDLXehufCsXW3w0XsuoI2xecIhouOishnaNOH4zIA7dAtnNAfdPN/kWrWs0s83gz44OrnqccP+Bprw3UDEQ==} - '@lexical/link@0.16.0': - resolution: {integrity: sha512-ppvJSh/XGqlzbeymOiwcXJcUcrqgQqTK2QXTBAZq7JThtb0WsJxYd2CSLSN+Ycu23prnwqOqILcU0+34+gAVFw==} + '@lexical/link@0.42.0': + resolution: {integrity: sha512-cdeM/+f+kn7aGwW/3FIi6USjl1gBNdEEwg0/ZS+KlYcsy8gxx2e4cyVjsomBu/WU17Qxa0NC0paSr7qEJ/1Fig==} - '@lexical/list@0.16.0': - resolution: {integrity: sha512-nBx/DMM7nCgnOzo1JyNnVaIrk/Xi5wIPNi8jixrEV6w9Om2K6dHutn/79Xzp2dQlNGSLHEDjky6N2RyFgmXh0g==} + '@lexical/list@0.42.0': + resolution: {integrity: sha512-TIezILnmIVuvfqEEbcMnsT4xQRlswI6ysHISqsvKL6l5EBhs1gqmNYjHa/Yrfzaq5y52TM1PAtxbFts+G7N6kg==} - '@lexical/mark@0.16.0': - resolution: {integrity: sha512-WMR4nqygSgIQ6Vdr5WAzohxBGjH+m44dBNTbWTGZGVlRvPzvBT6tieCoxFqpceIq/ko67HGTCNoFj2cMKVwgIA==} + '@lexical/mark@0.42.0': + resolution: {integrity: sha512-H1aGjbMEcL4B8GT7bm/ePHm7j3Wema+wIRNPmxMtXGMz5gpVN3gZlvg2UcUHHJb00SrBA95OUVT5I2nu/KP06w==} - '@lexical/markdown@0.16.0': - resolution: {integrity: sha512-7HQLFrBbpY68mcq4A6C1qIGmjgA+fAByditi2WRe7tD2eoIKb/B5baQAnDKis0J+m5kTaCBmdlT6csSzyOPzeQ==} + '@lexical/markdown@0.42.0': + resolution: {integrity: sha512-+mOxgBiumlgVX8Acna+9HjJfSOw1jywufGcAQq3/8S11wZ4gE0u13AaR8LMmU8ydVeOQg09y8PNzGNQ/avZJbg==} - '@lexical/offset@0.16.0': - resolution: {integrity: sha512-4TqPEC2qA7sgO8Tm65nOWnhJ8dkl22oeuGv9sUB+nhaiRZnw3R45mDelg23r56CWE8itZnvueE7TKvV+F3OXtQ==} + '@lexical/offset@0.42.0': + resolution: {integrity: sha512-V+4af1KmTOnBZrR+kU3e6eD33W/g3QqMPPp3cpFwyXk/dKRc4K8HfyDsSDrjop1mPd9pl3lKSiEmX6uQG8K9XQ==} - '@lexical/overflow@0.16.0': - resolution: {integrity: sha512-a7gtIRxleEuMN9dj2yO4CdezBBfIr9Mq+m7G5z62+xy7VL7cfMfF+xWjy3EmDYDXS4vOQgAXAUgO4oKz2AKGhQ==} + '@lexical/overflow@0.42.0': + resolution: {integrity: sha512-wlrHaM27rODJP5m+CTgfZGLg3qWlQ0ptGodcqoGdq6HSbV8nGFY6TvcLMaMtYQ1lm4v9G7Xe9LwjooR6xS3Gug==} - '@lexical/plain-text@0.16.0': - resolution: {integrity: sha512-BK7/GSOZUHRJTbNPkpb9a/xN9z+FBCdunTsZhnOY8pQ7IKws3kuMO2Tk1zXfTd882ZNAxFdDKNdLYDSeufrKpw==} + '@lexical/plain-text@0.42.0': + resolution: {integrity: sha512-YWvBwIxLltrIaZDcv0rK4s44P6Yt17yhOb0E+g3+tjF8GGPrrocox+Pglu0m2RHR+G7zULN3isolmWIm/HhWiw==} - '@lexical/react@0.16.0': - resolution: {integrity: sha512-WKFQbI0/m1YkLjL5t90YLJwjGcl5QRe6mkfm3ljQuL7Ioj3F92ZN/J2gHFVJ9iC8/lJs6Zzw6oFjiP8hQxJf9Q==} + '@lexical/react@0.42.0': + resolution: {integrity: sha512-ujWJXhvlFVVTpwDcnSgEYWRuqUbreZaMB+4bjIDT5r7hkAplUHQndlkeuFHKFiJBasSAreleV7zhXrLL5xa9eA==} peerDependencies: react: '>=17.x' react-dom: '>=17.x' - '@lexical/rich-text@0.16.0': - resolution: {integrity: sha512-AGTD6yJZ+kj2TNah1r7/6vyufs6fZANeSvv9x5eG+WjV4uyUJYkd1qR8C5gFZHdkyr+bhAcsAXvS039VzAxRrQ==} + '@lexical/rich-text@0.42.0': + resolution: {integrity: sha512-v4YgiM3oK3FZcRrfB+LetvLbQ5aee9MRO9tHf0EFweXg19XnSjHV0cfPAW7TyPxRELzB69+K0Q3AybRlTMjG4Q==} - '@lexical/selection@0.16.0': - resolution: {integrity: sha512-trT9gQVJ2j6AwAe7tHJ30SRuxCpV6yR9LFtggxphHsXSvJYnoHC0CXh1TF2jHl8Gd5OsdWseexGLBE4Y0V3gwQ==} + '@lexical/selection@0.42.0': + resolution: {integrity: sha512-iWTjLA5BSEuUnvWe9Xwu9FSdZFl3Yi0NqalabXKI+7KgCIlIVXE74y4NvWPUSLkSCB/Z1RPKiHmZqZ1vyu/yGQ==} - '@lexical/table@0.16.0': - resolution: {integrity: sha512-A66K779kxdr0yH2RwT2itsMnkzyFLFNPXyiWGLobCH8ON4QPuBouZvjbRHBe8Pe64yJ0c1bRDxSbTqUi9Wt3Gg==} + '@lexical/table@0.42.0': + resolution: {integrity: sha512-GKiZyjQsHDXRckq5VBrOowyvds51WoVRECfDgcl8pqLMnKyEdCa58E7fkSJrr5LS80Scod+Cjn6SBRzOcdsrKg==} - '@lexical/text@0.16.0': - resolution: {integrity: sha512-9ilaOhuNIIGHKC8g8j3K/mEvJ09af9B6RKbm3GNoRcf/WNHD4dEFWNTEvgo/3zCzAS8EUBI6UINmfQQWlMjdIQ==} + '@lexical/text@0.42.0': + resolution: {integrity: sha512-hT3EYVtBmONXyXe4TFVgtFcG1tf6JhLEuAf95+cOjgFGFSgvkZ/64BPbKLNTj2/9n6cU7EGPUNNwVigCSECJ2g==} - '@lexical/utils@0.16.0': - resolution: {integrity: sha512-GWmFEmd7o3GHqJBaEwzuZQbfTNI3Gg8ReGuHMHABgrkhZ8j2NggoRBlxsQLG0f7BewfTMVwbye22yBPq78775w==} + '@lexical/utils@0.42.0': + resolution: {integrity: sha512-wGNdCW3QWEyVdFiSTLZfFPtiASPyYLcekIiYYZmoRVxVimT/jY+QPfnkO4JYgkO7Z70g/dsg9OhqyQSChQfvkQ==} - '@lexical/yjs@0.16.0': - resolution: {integrity: sha512-YIJr87DfAXTwoVHDjR7cci//hr4r/a61Nn95eo2JNwbTqQo65Gp8rwJivqVxNfvKZmRdwHTKgvdEDoBmI/tGog==} + '@lexical/yjs@0.42.0': + resolution: {integrity: sha512-DplzWnYhfFceGPR+UyDFpZdB287wF/vNOHFuDsBF/nGDdTezvr0Gf60opzyBEF3oXym6p3xTmGygxvO97LZ+vw==} peerDependencies: yjs: '>=13.5.22' @@ -9448,8 +9475,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lexical@0.16.0: - resolution: {integrity: sha512-Skn45Qhriazq4fpAtwnAB11U//GKc4vjzx54xsV3TkDLDvWpbL4Z9TNRwRoN3g7w8AkWnqjeOSODKkrjgfRSrg==} + lexical@0.42.0: + resolution: {integrity: sha512-GY9Lg3YEIU7nSFaiUlLspZ1fm4NfIcfABaxy9nT+fRVDkX7iV005T5Swil83gXUmxFUNKGal3j+hUxHOUDr+Aw==} lib0@0.2.117: resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} @@ -9810,6 +9837,11 @@ packages: engines: {node: '>= 20'} hasBin: true + marked@17.0.6: + resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -11181,12 +11213,6 @@ packages: peerDependencies: react: '>= 16.8 || 18.0.0' - react-error-boundary@3.1.4: - resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} - engines: {node: '>=10', npm: '>=6'} - peerDependencies: - react: '>=16.13.1' - react-error-boundary@6.1.1: resolution: {integrity: sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==} peerDependencies: @@ -15250,149 +15276,173 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@lexical/clipboard@0.16.0': + '@lexical/clipboard@0.42.0': dependencies: - '@lexical/html': 0.16.0 - '@lexical/list': 0.16.0 - '@lexical/selection': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/html': 0.42.0 + '@lexical/list': 0.42.0 + '@lexical/selection': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 - '@lexical/code@0.16.0': + '@lexical/code-core@0.42.0': dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + lexical: 0.42.0 + + '@lexical/code-prism@0.42.0': + dependencies: + '@lexical/code-core': 0.42.0 + lexical: 0.42.0 prismjs: 1.30.0 - '@lexical/devtools-core@0.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@lexical/code@0.42.0': + dependencies: + '@lexical/code-core': 0.42.0 + '@lexical/code-prism': 0.42.0 + lexical: 0.42.0 + + '@lexical/devtools-core@0.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@lexical/html': 0.16.0 - '@lexical/link': 0.16.0 - '@lexical/mark': 0.16.0 - '@lexical/table': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/html': 0.42.0 + '@lexical/link': 0.42.0 + '@lexical/mark': 0.42.0 + '@lexical/table': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@lexical/dragon@0.16.0': + '@lexical/dragon@0.42.0': + dependencies: + '@lexical/extension': 0.42.0 + lexical: 0.42.0 + + '@lexical/extension@0.42.0': dependencies: - lexical: 0.16.0 + '@lexical/utils': 0.42.0 + '@preact/signals-core': 1.12.2 + lexical: 0.42.0 - '@lexical/hashtag@0.16.0': + '@lexical/hashtag@0.42.0': dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/text': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 - '@lexical/history@0.16.0': + '@lexical/history@0.42.0': dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/extension': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 - '@lexical/html@0.16.0': + '@lexical/html@0.42.0': dependencies: - '@lexical/selection': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/selection': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 - '@lexical/link@0.16.0': + '@lexical/link@0.42.0': dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/extension': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 - '@lexical/list@0.16.0': + '@lexical/list@0.42.0': dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/extension': 0.42.0 + '@lexical/selection': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 - '@lexical/mark@0.16.0': + '@lexical/mark@0.42.0': dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 - '@lexical/markdown@0.16.0': + '@lexical/markdown@0.42.0': dependencies: - '@lexical/code': 0.16.0 - '@lexical/link': 0.16.0 - '@lexical/list': 0.16.0 - '@lexical/rich-text': 0.16.0 - '@lexical/text': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/code-core': 0.42.0 + '@lexical/link': 0.42.0 + '@lexical/list': 0.42.0 + '@lexical/rich-text': 0.42.0 + '@lexical/text': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 - '@lexical/offset@0.16.0': + '@lexical/offset@0.42.0': dependencies: - lexical: 0.16.0 + lexical: 0.42.0 - '@lexical/overflow@0.16.0': + '@lexical/overflow@0.42.0': dependencies: - lexical: 0.16.0 + lexical: 0.42.0 - '@lexical/plain-text@0.16.0': + '@lexical/plain-text@0.42.0': dependencies: - '@lexical/clipboard': 0.16.0 - '@lexical/selection': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/clipboard': 0.42.0 + '@lexical/dragon': 0.42.0 + '@lexical/selection': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 - '@lexical/react@0.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(yjs@13.6.30)': + '@lexical/react@0.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(yjs@13.6.30)': dependencies: - '@lexical/clipboard': 0.16.0 - '@lexical/code': 0.16.0 - '@lexical/devtools-core': 0.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@lexical/dragon': 0.16.0 - '@lexical/hashtag': 0.16.0 - '@lexical/history': 0.16.0 - '@lexical/link': 0.16.0 - '@lexical/list': 0.16.0 - '@lexical/mark': 0.16.0 - '@lexical/markdown': 0.16.0 - '@lexical/overflow': 0.16.0 - '@lexical/plain-text': 0.16.0 - '@lexical/rich-text': 0.16.0 - '@lexical/selection': 0.16.0 - '@lexical/table': 0.16.0 - '@lexical/text': 0.16.0 - '@lexical/utils': 0.16.0 - '@lexical/yjs': 0.16.0(yjs@13.6.30) - lexical: 0.16.0 + '@floating-ui/react': 0.27.19(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@lexical/devtools-core': 0.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@lexical/dragon': 0.42.0 + '@lexical/extension': 0.42.0 + '@lexical/hashtag': 0.42.0 + '@lexical/history': 0.42.0 + '@lexical/link': 0.42.0 + '@lexical/list': 0.42.0 + '@lexical/mark': 0.42.0 + '@lexical/markdown': 0.42.0 + '@lexical/overflow': 0.42.0 + '@lexical/plain-text': 0.42.0 + '@lexical/rich-text': 0.42.0 + '@lexical/table': 0.42.0 + '@lexical/text': 0.42.0 + '@lexical/utils': 0.42.0 + '@lexical/yjs': 0.42.0(yjs@13.6.30) + lexical: 0.42.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - react-error-boundary: 3.1.4(react@19.2.3) + react-error-boundary: 6.1.1(react@19.2.3) transitivePeerDependencies: - yjs - '@lexical/rich-text@0.16.0': + '@lexical/rich-text@0.42.0': dependencies: - '@lexical/clipboard': 0.16.0 - '@lexical/selection': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/clipboard': 0.42.0 + '@lexical/dragon': 0.42.0 + '@lexical/selection': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 - '@lexical/selection@0.16.0': + '@lexical/selection@0.42.0': dependencies: - lexical: 0.16.0 + lexical: 0.42.0 - '@lexical/table@0.16.0': + '@lexical/table@0.42.0': dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 + '@lexical/clipboard': 0.42.0 + '@lexical/extension': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 - '@lexical/text@0.16.0': + '@lexical/text@0.42.0': dependencies: - lexical: 0.16.0 + lexical: 0.42.0 - '@lexical/utils@0.16.0': + '@lexical/utils@0.42.0': dependencies: - '@lexical/list': 0.16.0 - '@lexical/selection': 0.16.0 - '@lexical/table': 0.16.0 - lexical: 0.16.0 + '@lexical/selection': 0.42.0 + lexical: 0.42.0 - '@lexical/yjs@0.16.0(yjs@13.6.30)': + '@lexical/yjs@0.42.0(yjs@13.6.30)': dependencies: - '@lexical/offset': 0.16.0 - lexical: 0.16.0 + '@lexical/offset': 0.42.0 + '@lexical/selection': 0.42.0 + lexical: 0.42.0 yjs: 13.6.30 '@lingui/babel-plugin-extract-messages@5.6.1': {} @@ -22024,7 +22074,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lexical@0.16.0: {} + lexical@0.42.0: {} lib0@0.2.117: dependencies: @@ -22327,6 +22377,8 @@ snapshots: marked@16.4.2: {} + marked@17.0.6: {} + math-intrinsics@1.1.0: {} mathjax-full@3.2.2: @@ -24034,11 +24086,6 @@ snapshots: prop-types: 15.8.1 react: 19.2.3 - react-error-boundary@3.1.4(react@19.2.3): - dependencies: - '@babel/runtime': 7.29.2 - react: 19.2.3 - react-error-boundary@6.1.1(react@19.2.3): dependencies: react: 19.2.3