diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 9f7c3408..d6ad5d5d 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -3,3 +3,4 @@ @source "../**/*.{ts,tsx,mdx}"; @source "../node_modules/@repo/*/**/*.{ts,tsx}"; +@source "../../../packages/elements/src/**/*.{ts,tsx}"; diff --git a/apps/docs/content/components/(chatbot)/prompt-input.mdx b/apps/docs/content/components/(chatbot)/prompt-input.mdx index ad2c9f5b..88f2c012 100644 --- a/apps/docs/content/components/(chatbot)/prompt-input.mdx +++ b/apps/docs/content/components/(chatbot)/prompt-input.mdx @@ -270,6 +270,14 @@ Buttons can display tooltips with optional keyboard shortcut hints. Hover over t +### Richtext input + +`PromptInputRichtext` is an alternative text input to `PromptInputTextarea` when you want an extensible composer-style input with markdown shortcuts, autolink support, and Lexical plugins. + + + +`PromptInputRichtext` integrates with `PromptInputProvider`, so the editor content can stay in sync with external state. On submit, the rich text content is serialized back to markdown and returned as `message.text`. + ## Props ### `` @@ -330,6 +338,59 @@ Buttons can display tooltips with optional keyboard shortcut hints. Hover over t }} /> +### `` + +Lexical-powered rich text input for `PromptInput`. It supports markdown import/export, URL autolinking, keyboard submit behavior, attachment paste, and custom Lexical plugins. + + void", + }, + onKeyDown: { + description: + "Optional keydown handler attached to the editor root element.", + type: "(event: KeyboardEvent) => void", + }, + placeholder: { + description: "Placeholder text shown when the editor is empty.", + type: "string", + default: '"What would you like to know?"', + }, + placeholderClassName: { + description: "Additional class names for the placeholder element.", + type: "string", + }, + className: { + description: "Additional class names for the content editable element.", + type: "string", + }, + children: { + description: "Optional Lexical plugins rendered inside the composer.", + type: "React.ReactNode", + }, + "...props": { + description: + "Any other props are spread to the underlying Lexical `ContentEditable` element.", + type: 'Omit', + }, + }} +/> + +`PromptInputRichtext` submits on `Enter`, inserts a newline on `Shift+Enter`, removes the last attachment with `Backspace` when empty, and converts pasted files into prompt attachments. + ### `` { return { getDisplayMedia, pause, play, stopTrack, toBlob }; }; +const getRichtextEditor = (container: HTMLElement) => { + const editor = container.querySelector('[contenteditable="true"]'); + expect(editor).toBeInstanceOf(HTMLElement); + return editor as HTMLElement; +}; + describe("promptInput", () => { it("renders form", () => { setupPromptInputTests(); @@ -759,6 +768,468 @@ describe("promptInputTextarea", () => { }); }); +describe("promptInputRichtext", () => { + it("renders a contenteditable input with placeholder", () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const { container } = render( + + + + + + ); + + expect(screen.getByText("Ask anything")).toBeInTheDocument(); + expect(getRichtextEditor(container)).toHaveAttribute( + "aria-placeholder", + "Ask anything" + ); + }); + + it("submits richtext on Enter without provider and clears the editor", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + const { container } = render( + + + + + + + ); + + const editor = getRichtextEditor(container); + await user.click(editor); + await user.keyboard("Hello from Lexical"); + + expect(editor).toHaveTextContent("Hello from Lexical"); + + await user.keyboard("{Enter}"); + + await vi.waitFor(() => { + expect(onSubmit).toHaveBeenCalledOnce(); + }); + + const [[message]] = onSubmit.mock.calls; + expect(message).toHaveProperty("text", "Hello from Lexical"); + + await vi.waitFor(() => { + expect(editor).toHaveTextContent(""); + }); + }); + + it("submits richtext on Enter with provider and clears the editor", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + const { container } = render( + + + + + + + + + ); + + const editor = getRichtextEditor(container); + await user.click(editor); + await user.keyboard("Hello with provider"); + await user.keyboard("{Enter}"); + + await vi.waitFor(() => { + expect(onSubmit).toHaveBeenCalledOnce(); + }); + + const [[message]] = onSubmit.mock.calls; + expect(message).toHaveProperty("text", "Hello with provider"); + + await vi.waitFor(() => { + expect(editor).toHaveTextContent(""); + }); + }); + + it("does not submit on Shift+Enter", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + const { container } = render( + + + + + + + ); + + const editor = getRichtextEditor(container); + await user.click(editor); + await user.keyboard("Line 1"); + await user.keyboard("{Shift>}{Enter}{/Shift}"); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("does not submit on Enter during IME composition", () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + + const { container } = render( + + + + + + + ); + + const editor = getRichtextEditor(container); + editor.focus(); + + const enterKeyDuringComposition = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Enter", + }); + + Object.defineProperty(enterKeyDuringComposition, "isComposing", { + value: true, + writable: false, + }); + + editor.dispatchEvent(enterKeyDuringComposition); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("renders bold formatting after markdown shortcut resolves", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + const { container } = render( + + + + + + ); + + const editor = getRichtextEditor(container); + await user.click(editor); + await user.keyboard("**bold** "); + + await vi.waitFor(() => { + const boldNode = editor.querySelector("strong, b, span.font-semibold"); + expect(boldNode).toHaveTextContent("bold"); + expect(editor).toHaveTextContent("bold"); + expect(editor.textContent).not.toContain("**"); + }); + }); + + it("renders italic formatting after underscore markdown shortcut resolves", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + const { container } = render( + + + + + + ); + + const editor = getRichtextEditor(container); + await user.click(editor); + await user.keyboard("_italic_ "); + + await vi.waitFor(() => { + const italicNode = editor.querySelector("em, i, span.italic"); + expect(italicNode).toHaveTextContent("italic"); + expect(editor).toHaveTextContent("italic"); + expect(editor.textContent).not.toContain("_italic_"); + }); + }); + + it("renders italic formatting after asterisk markdown shortcut resolves", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + const { container } = render( + + + + + + ); + + const editor = getRichtextEditor(container); + await user.click(editor); + await user.keyboard("*italic* "); + + await vi.waitFor(() => { + const italicNode = editor.querySelector("em, i, span.italic"); + expect(italicNode).toHaveTextContent("italic"); + expect(editor).toHaveTextContent("italic"); + expect(editor.textContent).not.toContain("*italic*"); + }); + }); + + it("syncs initialInput into the richtext editor", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + + const { container } = render( + + + + + + + + ); + + const editor = getRichtextEditor(container); + + await vi.waitFor(() => { + expect(editor).toHaveTextContent("Initial provider value"); + }); + }); + + it("renders provider markdown initialInput as styled text instead of literal markers", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + + const { container } = render( + + + + + + + + ); + + const editor = getRichtextEditor(container); + + await vi.waitFor(() => { + const boldNode = editor.querySelector("strong, b, span.font-semibold"); + expect(boldNode).toHaveTextContent("hello"); + expect(editor).toHaveTextContent("hello"); + expect(editor.textContent).not.toContain("**hello**"); + }); + }); + + it("syncs provider state in both directions", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + const ProviderProbe = () => { + const controller = usePromptInputController(); + return ( + <> + +
{controller.textInput.value}
+ + ); + }; + + const { container } = render( + + + + + + + + + ); + + const editor = getRichtextEditor(container); + + await user.click(screen.getByRole("button", { name: "Set Input" })); + + await vi.waitFor(() => { + expect(editor).toHaveTextContent("Updated externally"); + }); + + await user.click(editor); + await user.keyboard("!"); + + await vi.waitFor(() => { + expect(screen.getByTestId("provider-value")).toHaveTextContent( + "Updated externally!" + ); + }); + }); + + it("preserves richtext content when async submit rejects", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(() => Promise.reject(new Error("submit failed"))); + const user = userEvent.setup(); + + const { container } = render( + + + + + + + ); + + const editor = getRichtextEditor(container); + await user.click(editor); + await user.keyboard("Keep this content"); + await user.keyboard("{Enter}"); + + await vi.waitFor(() => { + expect(onSubmit).toHaveBeenCalledOnce(); + }); + + await vi.waitFor(() => { + expect(editor).toHaveTextContent("Keep this content"); + }); + }); + + it("submits typed URLs as markdown links", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + const { container } = render( + + + + + + + ); + + const editor = getRichtextEditor(container); + await user.click(editor); + await user.keyboard("https://example.com "); + + await vi.waitFor(() => { + const linkNode = editor.querySelector("a"); + expect(linkNode).toHaveTextContent("https://example.com"); + }); + + await user.keyboard("{Enter}"); + + await vi.waitFor(() => { + expect(onSubmit).toHaveBeenCalledOnce(); + }); + + const [[message]] = onSubmit.mock.calls; + expect(message.text).toContain("[https://example.com](https://example.com)"); + }); + + it("clears richtext editor when provider textInput.clear() is called", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + const ProviderProbe = () => { + const controller = usePromptInputController(); + return ( + <> + +
{controller.textInput.value}
+ + ); + }; + + const { container } = render( + + + + + + + + + ); + + const editor = getRichtextEditor(container); + + await vi.waitFor(() => { + expect(editor).toHaveTextContent("Initial text"); + }); + + await user.click(screen.getByRole("button", { name: "Clear Input" })); + + await vi.waitFor(() => { + expect(editor).toHaveTextContent(""); + }); + + await vi.waitFor(() => { + expect(screen.getByTestId("provider-value")).toHaveTextContent(""); + }); + }); + + it("clears richtext editor via external setInput to empty string", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + + const ProviderProbe = () => { + const controller = usePromptInputController(); + return ( + <> + + + ); + }; + + const { container } = render( + + + + + + + + + ); + + const editor = getRichtextEditor(container); + + await vi.waitFor(() => { + expect(editor).toHaveTextContent("Some content"); + }); + + await user.click(screen.getByRole("button", { name: "Clear Input" })); + + await vi.waitFor(() => { + expect(editor).toHaveTextContent(""); + }); + }); +}); + describe("promptInputTools", () => { it("renders tools", () => { setupPromptInputTests(); @@ -1997,6 +2468,7 @@ describe("paste functionality", () => { // Mock clipboardData items pasteEvent.clipboardData = { + getData: () => "", items: [ { getAsFile: () => file, @@ -2014,6 +2486,53 @@ describe("paste functionality", () => { }); }); + it("adds files from clipboard in richtext editor", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + + const AttachmentConsumer = () => { + const attachments = usePromptInputAttachments(); + return
{attachments.files.length}
; + }; + + const { container } = render( + + + + + + + ); + + const editor = getRichtextEditor(container); + editor.focus(); + + const file = new File(["image"], "test.png", { type: "image/png" }); + const pasteEvent = new Event("paste", { + bubbles: true, + cancelable: true, + // oxlint-disable-next-line typescript-eslint(no-explicit-any) + }) as any; + + pasteEvent.clipboardData = { + getData: () => "", + items: [ + { + getAsFile: () => file, + kind: "file", + }, + ], + }; + + await act(() => { + editor.dispatchEvent(pasteEvent); + }); + + await vi.waitFor(() => { + expect(screen.getByTestId("count")).toHaveTextContent("1"); + }); + }); + it("handles paste with no files", () => { setupPromptInputTests(); const onSubmit = vi.fn(); @@ -2036,11 +2555,36 @@ describe("paste functionality", () => { cancelable: true, // oxlint-disable-next-line typescript-eslint(no-explicit-any) }) as any; - pasteEvent.clipboardData = { items: [] }; + pasteEvent.clipboardData = { getData: () => "", items: [] }; // Should not throw expect(() => textarea.dispatchEvent(pasteEvent)).not.toThrow(); }); + + it("handles richtext paste with no files", () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + + const { container } = render( + + + + + + ); + + const editor = getRichtextEditor(container); + editor.focus(); + + const pasteEvent = new Event("paste", { + bubbles: true, + cancelable: true, + // oxlint-disable-next-line typescript-eslint(no-explicit-any) + }) as any; + pasteEvent.clipboardData = { getData: () => "", items: [] }; + + expect(() => editor.dispatchEvent(pasteEvent)).not.toThrow(); + }); }); describe("promptInputAttachment", () => { @@ -3155,7 +3699,9 @@ describe("promptInputSelect components", () => { // Mock hasPointerCapture and releasePointerCapture for select vi.spyOn(Element.prototype, "hasPointerCapture").mockReturnValue(false); - vi.spyOn(Element.prototype, "releasePointerCapture").mockImplementation(); + vi + .spyOn(Element.prototype, "releasePointerCapture") + .mockImplementation((_pointerId: number) => {}); render( diff --git a/packages/elements/package.json b/packages/elements/package.json index 0ac0705f..39201241 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -12,6 +12,9 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@lexical/link": "^0.42.0", + "@lexical/markdown": "^0.42.0", + "@lexical/react": "^0.42.0", "@radix-ui/react-use-controllable-state": "^1.2.2", "@repo/shadcn-ui": "workspace:*", "@rive-app/react-webgl2": "^4.26.1", @@ -24,6 +27,7 @@ "ansi-to-react": "^6.2.6", "class-variance-authority": "^0.7.1", "katex": "^0.16.28", + "lexical": "^0.42.0", "lucide-react": "^0.577.0", "media-chrome": "^4.17.2", "motion": "^12.26.2", diff --git a/packages/elements/src/prompt-input.tsx b/packages/elements/src/prompt-input.tsx index 412c846d..88d28418 100644 --- a/packages/elements/src/prompt-input.tsx +++ b/packages/elements/src/prompt-input.tsx @@ -50,6 +50,36 @@ import { XIcon, } from "lucide-react"; import { nanoid } from "nanoid"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import type { InitialConfigType } from "@lexical/react/LexicalComposer"; +import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import type { ContentEditableProps } from "@lexical/react/LexicalContentEditable"; +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; +import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; +import { AutoLinkPlugin } from "@lexical/react/LexicalAutoLinkPlugin"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { EditorRefPlugin } from "@lexical/react/LexicalEditorRefPlugin"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + $convertFromMarkdownString, + $convertToMarkdownString, + LINK, + TEXT_FORMAT_TRANSFORMERS, + TEXT_MATCH_TRANSFORMERS, +} from "@lexical/markdown"; +import { $isLinkNode, AutoLinkNode, LinkNode } from "@lexical/link"; +import { + $getRoot, + COMMAND_PRIORITY_LOW, + KEY_BACKSPACE_COMMAND, + KEY_ENTER_COMMAND, + TextNode, +} from "lexical"; +import type { EditorState, LexicalEditor, LexicalNode } from "lexical"; import type { ChangeEvent, ChangeEventHandler, @@ -174,6 +204,70 @@ const captureScreenshot = async (): Promise => { } }; +const syncEditorTextContent = (editor: LexicalEditor | null, value: string) => { + if (!editor) { + return; + } + editor.update(() => { + const root = $getRoot(); + const currentValue = $convertToMarkdownString(PROMPT_INPUT_TRANSFORMERS); + + if (value === currentValue) { + return; + } + + root.clear(); + + if (value) { + $convertFromMarkdownString(value, PROMPT_INPUT_TRANSFORMERS); + } + + root.selectEnd(); + }); +}; + +const URL_MATCHER = + /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; + +const AUTO_LINK_MATCHERS = [ + (text: string) => { + const match = URL_MATCHER.exec(text); + if (match === null) { + return null; + } + const [fullMatch] = match; + return { + index: match.index, + length: fullMatch.length, + text: fullMatch, + url: fullMatch.startsWith("http") ? fullMatch : `https://${fullMatch}`, + // attributes: { rel: 'noreferrer', target: '_blank' }, // Optional link attributes + }; + }, +]; + +const LINK_TRANSFORMER = { + ...LINK, + export: (node: LexicalNode, exportChildren: (node: LinkNode) => string) => { + if (!$isLinkNode(node)) { + return null; + } + + const textContent = exportChildren(node); + const title = node.getTitle(); + + return title + ? `[${textContent}](${node.getURL()} "${title.replaceAll(/([\\"])/g, "\\$1")}")` + : `[${textContent}](${node.getURL()})`; + }, +}; + +const PROMPT_INPUT_TRANSFORMERS = [ + LINK_TRANSFORMER, + ...TEXT_FORMAT_TRANSFORMERS, + ...TEXT_MATCH_TRANSFORMERS, +]; + // ============================================================================ // Provider Context & Types // ============================================================================ @@ -193,28 +287,41 @@ export interface TextInputContext { clear: () => void; } +interface PromptInputSubmitAdapter { + getText: () => string; + clear: () => void; + setText?: (value: string) => void; +} + export interface PromptInputControllerProps { textInput: TextInputContext; attachments: AttachmentsContext; /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ __registerFileInput: ( ref: RefObject, - open: () => void + open: () => void, ) => void; } +interface PromptInputSubmitAdapterValue { + registerSubmitAdapter: (adapter: PromptInputSubmitAdapter | null) => void; +} + +const PromptInputSubmitAdapterContext = + createContext(null); + const PromptInputController = createContext( - null + null, ); const ProviderAttachmentsContext = createContext( - null + null, ); export const usePromptInputController = () => { const ctx = useContext(PromptInputController); if (!ctx) { throw new Error( - "Wrap your component inside to use usePromptInputController()." + "Wrap your component inside to use usePromptInputController().", ); } return ctx; @@ -228,7 +335,7 @@ export const useProviderAttachments = () => { const ctx = useContext(ProviderAttachmentsContext); if (!ctx) { throw new Error( - "Wrap your component inside to use useProviderAttachments()." + "Wrap your component inside to use useProviderAttachments().", ); } return ctx; @@ -316,7 +423,7 @@ export const PromptInputProvider = ({ } } }, - [] + [], ); const openFileDialog = useCallback(() => { @@ -332,7 +439,7 @@ export const PromptInputProvider = ({ openFileDialog, remove, }), - [attachmentFiles, add, remove, clear, openFileDialog] + [attachmentFiles, add, remove, clear, openFileDialog], ); const __registerFileInput = useCallback( @@ -340,7 +447,7 @@ export const PromptInputProvider = ({ fileInputRef.current = ref.current; openRef.current = open; }, - [] + [], ); const controller = useMemo( @@ -353,7 +460,7 @@ export const PromptInputProvider = ({ value: textInput, }, }), - [textInput, clearInput, attachments, __registerFileInput] + [textInput, clearInput, attachments, __registerFileInput], ); return ( @@ -378,7 +485,7 @@ export const usePromptInputAttachments = () => { const context = local ?? provider; if (!context) { throw new Error( - "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider" + "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider", ); } return context; @@ -402,7 +509,7 @@ export const usePromptInputReferencedSources = () => { const ctx = useContext(LocalReferencedSourcesContext); if (!ctx) { throw new Error( - "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider" + "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider", ); } return ctx; @@ -425,7 +532,7 @@ export const PromptInputActionAddAttachments = ({ e.preventDefault(); attachments.openFileDialog(); }, - [attachments] + [attachments], ); return ( @@ -470,7 +577,7 @@ export const PromptInputActionAddScreenshot = ({ throw error; } }, - [onSelect, attachments] + [onSelect, attachments], ); return ( @@ -507,7 +614,7 @@ export type PromptInputProps = Omit< }) => void; onSubmit: ( message: PromptInputMessage, - event: FormEvent + event: FormEvent, ) => void | Promise; }; @@ -572,7 +679,7 @@ export const PromptInput = ({ return f.type === pattern; }); }, - [accept] + [accept], ); const addLocal = useCallback( @@ -623,7 +730,7 @@ export const PromptInput = ({ return [...prev, ...next]; }); }, - [matchesAccept, maxFiles, maxFileSize, onError] + [matchesAccept, maxFiles, maxFileSize, onError], ); const removeLocal = useCallback( @@ -635,7 +742,7 @@ export const PromptInput = ({ } return prev.filter((file) => file.id !== id); }), - [] + [], ); // Wrapper that validates files before calling provider's add @@ -679,7 +786,7 @@ export const PromptInput = ({ controller?.attachments.add(capped); } }, - [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller] + [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller], ); const clearAttachments = useCallback( @@ -694,12 +801,12 @@ export const PromptInput = ({ } return []; }), - [usingProvider, controller] + [usingProvider, controller], ); const clearReferencedSources = useCallback( () => setReferencedSources([]), - [] + [], ); const add = usingProvider ? addWithProviderValidation : addLocal; @@ -797,7 +904,7 @@ export const PromptInput = ({ } } }, - [usingProvider] + [usingProvider], ); const handleChange: ChangeEventHandler = useCallback( @@ -808,7 +915,7 @@ export const PromptInput = ({ // Reset input value to allow selecting files that were previously removed event.currentTarget.value = ""; }, - [add] + [add], ); const attachmentsCtx = useMemo( @@ -820,7 +927,7 @@ export const PromptInput = ({ openFileDialog, remove, }), - [files, add, remove, clearAttachments, openFileDialog] + [files, add, remove, clearAttachments, openFileDialog], ); const refsCtx = useMemo( @@ -838,7 +945,16 @@ export const PromptInput = ({ }, sources: referencedSources, }), - [referencedSources, clearReferencedSources] + [referencedSources, clearReferencedSources], + ); + + const promptSubmitAdapterRef = useRef(null); + + const registerSubmitAdapter = useCallback( + (adapter: PromptInputSubmitAdapter | null) => { + promptSubmitAdapterRef.current = adapter; + }, + [], ); const handleSubmit: FormEventHandler = useCallback( @@ -846,13 +962,18 @@ export const PromptInput = ({ event.preventDefault(); const form = event.currentTarget; - const text = usingProvider - ? controller.textInput.value - : (() => { - const formData = new FormData(form); - return (formData.get("message") as string) || ""; - })(); - + const adapter = promptSubmitAdapterRef.current; + let text = ""; + if (adapter) { + text = adapter.getText(); + } else if (usingProvider) { + text = controller.textInput.value; + } else { + text = (() => { + const formData = new FormData(form); + return (formData.get("message") as string) || ""; + })(); + } // Reset form immediately after capturing text to avoid race condition // where user input during async blob conversion would be lost if (!usingProvider) { @@ -872,7 +993,7 @@ export const PromptInput = ({ }; } return item; - }) + }), ); const result = onSubmit({ files: convertedFiles, text }, event); @@ -882,24 +1003,32 @@ export const PromptInput = ({ try { await result; clear(); - if (usingProvider) { + adapter?.clear(); + if (!adapter && usingProvider) { controller.textInput.clear(); } + if (!adapter && !usingProvider) { + form.reset(); + } } catch { // Don't clear on error - user may want to retry } } else { // Sync function completed without throwing, clear inputs clear(); - if (usingProvider) { + adapter?.clear(); + if (!adapter && usingProvider) { controller.textInput.clear(); } + if (!adapter && !usingProvider) { + form.reset(); + } } } catch { // Don't clear on error - user may want to retry } }, - [usingProvider, controller, files, onSubmit, clear] + [usingProvider, controller, files, onSubmit, clear], ); // Render with or without local provider @@ -934,9 +1063,11 @@ export const PromptInput = ({ // Always provide LocalAttachmentsContext so children get validated add function return ( - - {withReferencedSources} - + + + {withReferencedSources} + + ); }; @@ -986,7 +1117,7 @@ export const PromptInputTextarea = ({ // Check if the submit button is disabled before submitting const { form } = e.currentTarget; const submitButton = form?.querySelector( - 'button[type="submit"]' + 'button[type="submit"]', ) as HTMLButtonElement | null; if (submitButton?.disabled) { return; @@ -1008,7 +1139,7 @@ export const PromptInputTextarea = ({ } } }, - [onKeyDown, isComposing, attachments] + [onKeyDown, isComposing, attachments], ); const handlePaste: ClipboardEventHandler = useCallback( @@ -1035,7 +1166,7 @@ export const PromptInputTextarea = ({ attachments.add(files); } }, - [attachments] + [attachments], ); const handleCompositionEnd = useCallback(() => setIsComposing(false), []); @@ -1068,6 +1199,284 @@ export const PromptInputTextarea = ({ ); }; +const PromptInputKeyBindingsPlugin = ({ + onKeyDown, +}: { + onKeyDown?: (event: KeyboardEvent) => void; +}) => { + const [editor] = useLexicalComposerContext(); + const attachments = usePromptInputAttachments(); + + useEffect(() => { + const unregisterEnter = editor.registerCommand( + KEY_ENTER_COMMAND, + (event: KeyboardEvent | null) => { + if (editor.isComposing() || event?.isComposing || event?.shiftKey) { + return false; + } + + // Check if the submit button is disabled before submitting + const form = editor.getRootElement()?.closest("form"); + const submitButton = form?.querySelector( + 'button[type="submit"]', + ) as HTMLButtonElement | null; + + if (submitButton?.disabled) { + event?.preventDefault(); + return true; + } + + event?.preventDefault(); + form?.requestSubmit(); + return true; + }, + COMMAND_PRIORITY_LOW, + ); + + const unregisterBackspace = editor.registerCommand( + KEY_BACKSPACE_COMMAND, + (event: KeyboardEvent | null) => { + // Remove last attachment when Backspace is pressed and textarea is empty + if ( + $getRoot().getTextContent() !== "" || + attachments.files.length === 0 + ) { + return false; + } + if ( + $getRoot().getTextContent() === "" && + attachments.files.length > 0 + ) { + event?.preventDefault(); + const lastAttachment = attachments.files.at(-1); + if (lastAttachment) { + attachments.remove(lastAttachment.id); + return true; + } + } + return false; + }, + COMMAND_PRIORITY_LOW, + ); + + const unregisters = [unregisterEnter, unregisterBackspace]; + + if (onKeyDown) { + unregisters.push( + editor.registerRootListener((rootElement, prevRootElement) => { + if (prevRootElement) { + prevRootElement.removeEventListener("keydown", onKeyDown); + } + if (rootElement) { + rootElement.addEventListener("keydown", onKeyDown); + } + }), + ); + } + + return () => { + for (const unregister of unregisters) { + unregister(); + } + }; + }, [editor, attachments, onKeyDown]); + + return null; +}; + +export type PromptInputRichtextProps = Omit< + ContentEditableProps, + "placeholder" +> & { + className?: string; + editorConfig?: InitialConfigType; + autoFocus?: boolean; + onChange?: (editorState: EditorState) => void; + onKeyDown?: (event: KeyboardEvent) => void; + placeholder?: string; + placeholderClassName?: string; +}; + +export const PromptInputRichtext = ({ + className, + editorConfig, + autoFocus = true, + onChange, + onKeyDown, + placeholder = "What would you like to know?", + placeholderClassName, + children, + ...props +}: PromptInputRichtextProps) => { + const editorRef = useRef(null); + const attachments = usePromptInputAttachments(); + const submitAdapterContext = useContext(PromptInputSubmitAdapterContext); + const controller = useOptionalPromptInputController(); + const controllerValue = controller?.textInput.value; + + // register adapter into context using editor ref + useEffect(() => { + if (!submitAdapterContext) { + return; + } + + const adapter: PromptInputSubmitAdapter = { + clear: () => { + editorRef.current?.update(() => { + const root = $getRoot(); + root.clear(); + root.selectEnd(); + }); + }, + getText: () => + editorRef.current + ?.getEditorState() + .read(() => $convertToMarkdownString(PROMPT_INPUT_TRANSFORMERS)) ?? + "", + setText: (value) => { + syncEditorTextContent(editorRef.current, value); + }, + }; + + submitAdapterContext.registerSubmitAdapter(adapter); + + return () => { + submitAdapterContext.registerSubmitAdapter(null); + }; + }, [submitAdapterContext]); + + // sync editor text content in provider mode + useEffect(() => { + if (controllerValue === undefined) { + return; + } + syncEditorTextContent(editorRef.current, controllerValue); + }, [controllerValue]); + + const handlePaste: ClipboardEventHandler = useCallback( + (event) => { + const items = event.clipboardData?.items; + + if (!items) { + return; + } + + const files: File[] = []; + + for (const item of items) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + + if (files.length > 0) { + event.preventDefault(); + attachments.add(files); + } + }, + [attachments], + ); + + const onChangeHandler = useCallback( + (editorState: EditorState) => { + editorState.read(() => { + const markdown = $convertToMarkdownString(PROMPT_INPUT_TRANSFORMERS); + controller?.textInput.setInput(markdown); + }); + onChange?.(editorState); + }, + [onChange, controller?.textInput], + ); + + const initialConfig: InitialConfigType = { + namespace: "Editor", + nodes: [TextNode, LinkNode, AutoLinkNode], + onError: (error: Error) => { + console.error(error); + }, + theme: { + link: "rounded-md bg-blue-100 px-1 py-0.5 font-medium text-blue-600 dark:bg-blue-950 dark:text-blue-300", + paragraph: "mb-0", + text: { + bold: "font-semibold", + italic: "italic", + underline: "underline", + }, + }, + }; + + return ( + +
+
+
+ ); +}; + +export const PromptInputRichtextPlaceholder = ({ + placeholder = "What would you like to know?", + className, + ...props +}: ComponentProps<"span"> & { + placeholder?: string; +}) => ( + +); + export type PromptInputHeaderProps = Omit< ComponentProps, "align" @@ -1244,7 +1653,7 @@ export const PromptInputSubmit = ({ } onClick?.(e); }, - [isGenerating, onStop, onClick] + [isGenerating, onStop, onClick], ); return ( @@ -1280,7 +1689,7 @@ export const PromptInputSelectTrigger = ({ className={cn( "border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors", "hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground", - className + className, )} {...props} /> @@ -1330,7 +1739,7 @@ export type PromptInputHoverCardTriggerProps = ComponentProps< >; export const PromptInputHoverCardTrigger = ( - props: PromptInputHoverCardTriggerProps + props: PromptInputHoverCardTriggerProps, ) => ; export type PromptInputHoverCardContentProps = ComponentProps< @@ -1369,7 +1778,7 @@ export const PromptInputTabLabel = ({

@@ -1393,7 +1802,7 @@ export const PromptInputTabItem = ({
diff --git a/packages/elements/vitest.config.mts b/packages/elements/vitest.config.mts index 8e90c9b9..a5febec3 100644 --- a/packages/elements/vitest.config.mts +++ b/packages/elements/vitest.config.mts @@ -7,6 +7,23 @@ import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vitest/config"; export default defineConfig({ + optimizeDeps: { + include: [ + "lexical", + "@lexical/link", + "@lexical/markdown", + "@lexical/react/LexicalAutoLinkPlugin", + "@lexical/react/LexicalComposer", + "@lexical/react/LexicalContentEditable", + "@lexical/react/LexicalEditorRefPlugin", + "@lexical/react/LexicalErrorBoundary", + "@lexical/react/LexicalHistoryPlugin", + "@lexical/react/LexicalLinkPlugin", + "@lexical/react/LexicalMarkdownShortcutPlugin", + "@lexical/react/LexicalOnChangePlugin", + "@lexical/react/LexicalRichTextPlugin", + ], + }, plugins: [react()], resolve: { alias: { diff --git a/packages/examples/src/prompt-input-richtext.tsx b/packages/examples/src/prompt-input-richtext.tsx new file mode 100644 index 00000000..833f83e6 --- /dev/null +++ b/packages/examples/src/prompt-input-richtext.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { + PromptInput, + PromptInputBody, + PromptInputButton, + PromptInputFooter, + PromptInputMessage, + PromptInputProvider, + PromptInputRichtext, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, +} from "@repo/elements/prompt-input"; +import { GlobeIcon, MicIcon, PaperclipIcon } from "lucide-react"; + +const handleSubmit = (message: PromptInputMessage) => { + const hasText = Boolean(message.text); + const hasAttachments = Boolean(message.files?.length); + + if (!(hasText || hasAttachments)) { + return; + } + + // eslint-disable-next-line no-console + console.log("Submitting message:", message); +}; + +const Example = () => ( + + + + + + + + + + + + + + + + + + + + + +); + +export default Example; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d82cfd3a..76b27dab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,6 +200,15 @@ importers: packages/elements: dependencies: + '@lexical/link': + specifier: ^0.42.0 + version: 0.42.0 + '@lexical/markdown': + 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) '@radix-ui/react-use-controllable-state': specifier: ^1.2.2 version: 1.2.2(@types/react@19.2.8)(react@19.2.3) @@ -236,6 +245,9 @@ importers: katex: specifier: ^0.16.28 version: 0.16.28 + lexical: + specifier: ^0.42.0 + version: 0.42.0 lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.3) @@ -1130,18 +1142,39 @@ packages: '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + '@floating-ui/dom@1.7.3': resolution: {integrity: sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==} + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/react-dom@2.1.5': resolution: {integrity: sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@formatjs/intl-localematcher@0.6.2': resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} @@ -1372,6 +1405,80 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lexical/clipboard@0.42.0': + resolution: {integrity: sha512-D3K2ID0zew/+CKpwxnUTTh/N46yU4IK8bFWV9Htz+g1vFhgUF9UnDOQCmqpJbdP7z+9U1F8rk3fzf9OmP2Fm2w==} + + '@lexical/code-core@0.42.0': + resolution: {integrity: sha512-vrZTUPWDJkHjAAvuV2+Qte4vYE80s7hIO7wxipiJmWojGx6lcmQjO+UqJ8AIrqI4Wjy8kXrK74kisApWmwxuCw==} + + '@lexical/devtools-core@0.42.0': + resolution: {integrity: sha512-8nP8eE9i8JImgSrvInkWFfMCmXVKp3w3VaOvbJysdlK/Zal6xd8EWJEi6elj0mUW5T/oycfipPs2Sfl7Z+n14A==} + peerDependencies: + react: '>=17.x' + react-dom: '>=17.x' + + '@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.42.0': + resolution: {integrity: sha512-WOg5nFOfhabNBXzEIutdWDj+TUHtJEezj6w8jyYDGqZ31gu0cgrXSeV8UIynz/1oj+rpzEeEB7P6ODnwgjt7qA==} + + '@lexical/history@0.42.0': + resolution: {integrity: sha512-YfCZ1ICUt6BCg2ncJWFMuS4yftnB7FEHFRf3qqTSTf6oGZ4IZfzabMNEy47xybUuf7FXBbdaCKJrc/zOM+wGxw==} + + '@lexical/html@0.42.0': + resolution: {integrity: sha512-KgBUDLXehufCsXW3w0XsuoI2xecIhouOishnaNOH4zIA7dAtnNAfdPN/kWrWs0s83gz44OrnqccP+Bprw3UDEQ==} + + '@lexical/link@0.42.0': + resolution: {integrity: sha512-cdeM/+f+kn7aGwW/3FIi6USjl1gBNdEEwg0/ZS+KlYcsy8gxx2e4cyVjsomBu/WU17Qxa0NC0paSr7qEJ/1Fig==} + + '@lexical/list@0.42.0': + resolution: {integrity: sha512-TIezILnmIVuvfqEEbcMnsT4xQRlswI6ysHISqsvKL6l5EBhs1gqmNYjHa/Yrfzaq5y52TM1PAtxbFts+G7N6kg==} + + '@lexical/mark@0.42.0': + resolution: {integrity: sha512-H1aGjbMEcL4B8GT7bm/ePHm7j3Wema+wIRNPmxMtXGMz5gpVN3gZlvg2UcUHHJb00SrBA95OUVT5I2nu/KP06w==} + + '@lexical/markdown@0.42.0': + resolution: {integrity: sha512-+mOxgBiumlgVX8Acna+9HjJfSOw1jywufGcAQq3/8S11wZ4gE0u13AaR8LMmU8ydVeOQg09y8PNzGNQ/avZJbg==} + + '@lexical/offset@0.42.0': + resolution: {integrity: sha512-V+4af1KmTOnBZrR+kU3e6eD33W/g3QqMPPp3cpFwyXk/dKRc4K8HfyDsSDrjop1mPd9pl3lKSiEmX6uQG8K9XQ==} + + '@lexical/overflow@0.42.0': + resolution: {integrity: sha512-wlrHaM27rODJP5m+CTgfZGLg3qWlQ0ptGodcqoGdq6HSbV8nGFY6TvcLMaMtYQ1lm4v9G7Xe9LwjooR6xS3Gug==} + + '@lexical/plain-text@0.42.0': + resolution: {integrity: sha512-YWvBwIxLltrIaZDcv0rK4s44P6Yt17yhOb0E+g3+tjF8GGPrrocox+Pglu0m2RHR+G7zULN3isolmWIm/HhWiw==} + + '@lexical/react@0.42.0': + resolution: {integrity: sha512-ujWJXhvlFVVTpwDcnSgEYWRuqUbreZaMB+4bjIDT5r7hkAplUHQndlkeuFHKFiJBasSAreleV7zhXrLL5xa9eA==} + peerDependencies: + react: '>=17.x' + react-dom: '>=17.x' + + '@lexical/rich-text@0.42.0': + resolution: {integrity: sha512-v4YgiM3oK3FZcRrfB+LetvLbQ5aee9MRO9tHf0EFweXg19XnSjHV0cfPAW7TyPxRELzB69+K0Q3AybRlTMjG4Q==} + + '@lexical/selection@0.42.0': + resolution: {integrity: sha512-iWTjLA5BSEuUnvWe9Xwu9FSdZFl3Yi0NqalabXKI+7KgCIlIVXE74y4NvWPUSLkSCB/Z1RPKiHmZqZ1vyu/yGQ==} + + '@lexical/table@0.42.0': + resolution: {integrity: sha512-GKiZyjQsHDXRckq5VBrOowyvds51WoVRECfDgcl8pqLMnKyEdCa58E7fkSJrr5LS80Scod+Cjn6SBRzOcdsrKg==} + + '@lexical/text@0.42.0': + resolution: {integrity: sha512-hT3EYVtBmONXyXe4TFVgtFcG1tf6JhLEuAf95+cOjgFGFSgvkZ/64BPbKLNTj2/9n6cU7EGPUNNwVigCSECJ2g==} + + '@lexical/utils@0.42.0': + resolution: {integrity: sha512-wGNdCW3QWEyVdFiSTLZfFPtiASPyYLcekIiYYZmoRVxVimT/jY+QPfnkO4JYgkO7Z70g/dsg9OhqyQSChQfvkQ==} + + '@lexical/yjs@0.42.0': + resolution: {integrity: sha512-DplzWnYhfFceGPR+UyDFpZdB287wF/vNOHFuDsBF/nGDdTezvr0Gf60opzyBEF3oXym6p3xTmGygxvO97LZ+vw==} + peerDependencies: + yjs: '>=13.5.22' + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -1709,6 +1816,9 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@preact/signals-core@1.14.0': + resolution: {integrity: sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -4490,6 +4600,9 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -4617,6 +4730,14 @@ packages: layout-base@2.0.1: resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lexical@0.42.0: + resolution: {integrity: sha512-GY9Lg3YEIU7nSFaiUlLspZ1fm4NfIcfABaxy9nT+fRVDkX7iV005T5Swil83gXUmxFUNKGal3j+hUxHOUDr+Aw==} + + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + lie@3.1.1: resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} @@ -5467,6 +5588,11 @@ packages: peerDependencies: react: ^19.2.3 + react-error-boundary@6.1.1: + resolution: {integrity: sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-hook-form@7.71.1: resolution: {integrity: sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==} engines: {node: '>=18.0.0'} @@ -5976,6 +6102,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -6519,6 +6648,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yjs@13.6.30: + resolution: {integrity: sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yoctocolors-cjs@2.1.3: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} @@ -7278,19 +7411,44 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.10 + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + '@floating-ui/dom@1.7.3': dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/utils': 0.2.10 + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + '@floating-ui/react-dom@2.1.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@floating-ui/dom': 1.7.3 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + '@floating-ui/react-dom@2.1.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@floating-ui/react@0.27.19(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@floating-ui/utils': 0.2.11 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + tabbable: 6.4.0 + '@floating-ui/utils@0.2.10': {} + '@floating-ui/utils@0.2.11': {} + '@formatjs/intl-localematcher@0.6.2': dependencies: tslib: 2.8.1 @@ -7480,6 +7638,163 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lexical/clipboard@0.42.0': + dependencies: + '@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-core@0.42.0': + dependencies: + 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.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.42.0': + dependencies: + '@lexical/extension': 0.42.0 + lexical: 0.42.0 + + '@lexical/extension@0.42.0': + dependencies: + '@lexical/utils': 0.42.0 + '@preact/signals-core': 1.14.0 + lexical: 0.42.0 + + '@lexical/hashtag@0.42.0': + dependencies: + '@lexical/text': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 + + '@lexical/history@0.42.0': + dependencies: + '@lexical/extension': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 + + '@lexical/html@0.42.0': + dependencies: + '@lexical/selection': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 + + '@lexical/link@0.42.0': + dependencies: + '@lexical/extension': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 + + '@lexical/list@0.42.0': + dependencies: + '@lexical/extension': 0.42.0 + '@lexical/selection': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 + + '@lexical/mark@0.42.0': + dependencies: + '@lexical/utils': 0.42.0 + lexical: 0.42.0 + + '@lexical/markdown@0.42.0': + dependencies: + '@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.42.0': + dependencies: + lexical: 0.42.0 + + '@lexical/overflow@0.42.0': + dependencies: + lexical: 0.42.0 + + '@lexical/plain-text@0.42.0': + dependencies: + '@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.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(yjs@13.6.30)': + dependencies: + '@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: 6.1.1(react@19.2.3) + transitivePeerDependencies: + - yjs + + '@lexical/rich-text@0.42.0': + dependencies: + '@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.42.0': + dependencies: + lexical: 0.42.0 + + '@lexical/table@0.42.0': + dependencies: + '@lexical/clipboard': 0.42.0 + '@lexical/extension': 0.42.0 + '@lexical/utils': 0.42.0 + lexical: 0.42.0 + + '@lexical/text@0.42.0': + dependencies: + lexical: 0.42.0 + + '@lexical/utils@0.42.0': + dependencies: + '@lexical/selection': 0.42.0 + lexical: 0.42.0 + + '@lexical/yjs@0.42.0(yjs@13.6.30)': + dependencies: + '@lexical/offset': 0.42.0 + '@lexical/selection': 0.42.0 + lexical: 0.42.0 + yjs: 13.6.30 + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.28.6 @@ -7858,6 +8173,8 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@preact/signals-core@1.14.0': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -10890,6 +11207,8 @@ snapshots: isexe@3.1.1: {} + isomorphic.js@0.2.5: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -11010,6 +11329,12 @@ snapshots: layout-base@2.0.1: {} + lexical@0.42.0: {} + + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + lie@3.1.1: dependencies: immediate: 3.0.6 @@ -12204,6 +12529,10 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-error-boundary@6.1.1(react@19.2.3): + dependencies: + react: 19.2.3 + react-hook-form@7.71.1(react@19.2.3): dependencies: react: 19.2.3 @@ -12925,6 +13254,8 @@ snapshots: symbol-tree@3.2.4: optional: true + tabbable@6.4.0: {} + tagged-tag@1.0.0: {} tailwind-merge@3.4.0: {} @@ -13428,6 +13759,10 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yjs@13.6.30: + dependencies: + lib0: 0.2.117 + yoctocolors-cjs@2.1.3: {} yoctocolors@2.1.2: {}