UNPKG

@liveblocks/react-ui

Version:

A set of React pre-built components for the Liveblocks products. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.

357 lines (354 loc) 11 kB
import { jsx } from 'react/jsx-runtime'; import { Signal, kInternal } from '@liveblocks/core'; import { useClient } from '@liveblocks/react'; import { useSignal, useLayoutEffect } from '@liveblocks/react/_private'; import { Slot } from '@radix-ui/react-slot'; import { forwardRef, useRef, useState, useCallback, useImperativeHandle, useMemo } from 'react'; import { createEditor, Transforms, Editor } from 'slate'; import { withHistory } from 'slate-history'; import { withReact, ReactEditor, Slate, Editable } from 'slate-react'; import { requestSubmit } from '../../utils/request-submit.js'; import { useInitial } from '../../utils/use-initial.js'; import { withNormalize } from '../slate/plugins/normalize.js'; import { isEmpty } from '../slate/utils/is-empty.js'; import { AiComposerEditorContext, AiComposerContext, useAiComposerEditorContext, useAiComposer } from './contexts.js'; const AI_COMPOSER_SUBMIT_NAME = "AiComposerSubmit"; const AI_COMPOSER_ABORT_NAME = "AiComposerAbort"; const AI_COMPOSER_EDITOR_NAME = "AiComposerEditor"; const AI_COMPOSER_FORM_NAME = "AiComposerForm"; const emptyMessages\u03A3 = new Signal([]); function getLastMessageId(messages) { const lastMessage = messages[messages.length - 1]; if (lastMessage === void 0) { return null; } return lastMessage.id; } function getAbortableMessageId(messages) { return messages.find( (message) => message.role === "assistant" && (message.status === "generating" || message.status === "awaiting-tool") )?.id; } const AiComposerForm = forwardRef( ({ onComposerSubmit, onSubmit, disabled, chatId, branchId, asChild, ...props }, forwardedRef) => { const Component = asChild ? Slot : "form"; const client = useClient(); const formRef = useRef(null); const editor = useInitial( () => withNormalize(withHistory(withReact(createEditor()))) ); const [isEditorEmpty, setEditorEmpty] = useState(true); const [isSubmitting, setSubmitting] = useState(false); const [isFocused, setFocused] = useState(false); const messages\u03A3 = chatId ? client[kInternal].ai.signals.getChatMessagesForBranch\u03A3(chatId, branchId) : emptyMessages\u03A3; const lastMessageId = useSignal(messages\u03A3, getLastMessageId); const abortableMessageId = useSignal(messages\u03A3, getAbortableMessageId); const isAvailable = useSignal( // Subscribe to connection status signal client[kInternal].ai.signals.status\u03A3, // "Disconnected" means the AI service is not available // as it represents a final error status. (status) => status !== "disconnected" ); const isDisabled = isSubmitting || disabled === true; const canAbort = isAvailable && abortableMessageId !== void 0; const canSubmit = isAvailable && !isEditorEmpty && !canAbort; const clear = useCallback(() => { Transforms.delete(editor, { at: { anchor: Editor.start(editor, []), focus: Editor.end(editor, []) } }); }, [editor]); const select = useCallback(() => { Transforms.select(editor, Editor.end(editor, [])); }, [editor]); const focus = useCallback( (resetSelection = true) => { try { if (!ReactEditor.isFocused(editor)) { Transforms.select( editor, resetSelection || !editor.selection ? Editor.end(editor, []) : editor.selection ); ReactEditor.focus(editor); } } catch { } }, [editor] ); const blur = useCallback(() => { try { ReactEditor.blur(editor); } catch { } }, [editor]); const onSubmitEnd = useCallback(() => { clear(); setSubmitting(false); }, [clear]); const handleSubmit = useCallback( (event) => { if (disabled) { return; } const isEditorEmpty2 = isEmpty(editor, editor.children); if (isEditorEmpty2) { event.preventDefault(); return; } onSubmit?.(event); if (onComposerSubmit === void 0 || event.isDefaultPrevented()) { event.preventDefault(); return; } const content = editor.children.map((block) => { if ("type" in block && block.type === "paragraph") { return block.children.map((child) => { if ("text" in child) { return child.text; } return ""; }).join(""); } return ""; }).join("\n"); const promise = onComposerSubmit( { text: content, lastMessageId }, event ); event.preventDefault(); if (promise) { setSubmitting(true); promise.then(onSubmitEnd); } else { onSubmitEnd(); } }, [disabled, editor, onSubmit, onComposerSubmit, onSubmitEnd, lastMessageId] ); useLayoutEffect(() => { setEditorEmpty(isEmpty(editor, editor.children)); }, [editor]); const handleEditorValueChange = useCallback(() => { setEditorEmpty(isEmpty(editor, editor.children)); }, [editor]); const submit = useCallback(() => { if (!canSubmit) { return; } requestAnimationFrame(() => { if (formRef.current) { requestSubmit(formRef.current); } }); }, [canSubmit]); const abort = useCallback(() => { if (!canAbort || !abortableMessageId) { return; } client[kInternal].ai.abort(abortableMessageId); }, [canAbort, abortableMessageId, client]); useImperativeHandle( forwardedRef, () => formRef.current, [] ); return /* @__PURE__ */ jsx( AiComposerEditorContext.Provider, { value: { editor, onEditorValueChange: handleEditorValueChange, abortableMessageId, setFocused }, children: /* @__PURE__ */ jsx( AiComposerContext.Provider, { value: { isDisabled, isEmpty: isEditorEmpty, isFocused, canSubmit, canAbort, submit, abort, clear, focus, blur, select }, children: /* @__PURE__ */ jsx(Component, { onSubmit: handleSubmit, ...props, ref: formRef }) } ) } ); } ); function AiComposerEditorPlaceholder({ attributes, children }) { const { opacity: _opacity, ...style } = attributes.style; return /* @__PURE__ */ jsx("span", { ...attributes, style, "data-placeholder": "", children }); } const AiComposerEditor = forwardRef( ({ defaultValue = "", onKeyDown, onFocus, onBlur, disabled, autoFocus, dir, ...props }, forwardedRef) => { const { editor, onEditorValueChange, setFocused } = useAiComposerEditorContext(); const { submit, isDisabled: isComposerDisabled, isFocused, focus, blur, select } = useAiComposer(); const isDisabled = disabled || isComposerDisabled; const handleKeyDown = useCallback( (event) => { onKeyDown?.(event); if (event.isDefaultPrevented()) return; if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); submit(); } else if (event.key === "Enter" && event.shiftKey) { event.preventDefault(); editor.insertBreak(); } else if (event.key === "Escape") { blur(); } }, [editor, onKeyDown, submit, blur] ); const handleFocus = useCallback( (event) => { onFocus?.(event); if (!event.isDefaultPrevented()) { setFocused(true); } }, [onFocus, setFocused] ); const handleBlur = useCallback( (event) => { onBlur?.(event); if (!event.isDefaultPrevented()) { setFocused(false); } }, [onBlur, setFocused] ); useImperativeHandle( forwardedRef, () => ReactEditor.toDOMNode(editor, editor), [editor] ); useLayoutEffect(() => { if (autoFocus) { focus(); } }, [autoFocus, editor, focus]); useLayoutEffect(() => { if (isFocused && editor.selection === null) { select(); } }, [editor, select, isFocused]); const initialValue = useMemo(() => { return defaultValue.split("\n").map((text) => ({ type: "paragraph", children: [{ text }] })); }, [defaultValue]); return /* @__PURE__ */ jsx( Slate, { editor, initialValue, onValueChange: onEditorValueChange, children: /* @__PURE__ */ jsx( Editable, { dir, enterKeyHint: "send", autoCapitalize: "sentences", "aria-label": "Composer editor", onKeyDown: handleKeyDown, onFocus: handleFocus, onBlur: handleBlur, "data-focused": isFocused || void 0, "data-disabled": isDisabled || void 0, ...props, readOnly: isDisabled, disabled: isDisabled, renderPlaceholder: AiComposerEditorPlaceholder } ) } ); } ); const AiComposerSubmit = forwardRef(({ disabled, asChild, ...props }, forwardedRef) => { const Component = asChild ? Slot : "button"; const { isDisabled: isComposerDisabled, canSubmit } = useAiComposer(); const isDisabled = isComposerDisabled || disabled || !canSubmit; return /* @__PURE__ */ jsx( Component, { type: "submit", ...props, ref: forwardedRef, disabled: isDisabled } ); }); const AiComposerAbort = forwardRef(({ disabled, onClick, asChild, ...props }, forwardedRef) => { const Component = asChild ? Slot : "button"; const { isDisabled: isComposerDisabled, canAbort, abort } = useAiComposer(); const isDisabled = isComposerDisabled || disabled || !canAbort; const handleClick = useCallback( (event) => { onClick?.(event); if (event.isDefaultPrevented()) { return; } abort(); }, [abort, onClick] ); return /* @__PURE__ */ jsx( Component, { type: "button", ...props, ref: forwardedRef, disabled: isDisabled, onClick: handleClick } ); }); if (process.env.NODE_ENV !== "production") { AiComposerEditor.displayName = AI_COMPOSER_EDITOR_NAME; AiComposerForm.displayName = AI_COMPOSER_FORM_NAME; AiComposerSubmit.displayName = AI_COMPOSER_SUBMIT_NAME; AiComposerAbort.displayName = AI_COMPOSER_ABORT_NAME; } export { AiComposerAbort as Abort, AiComposerAbort, AiComposerForm, AiComposerSubmit, AiComposerEditor as Editor, AiComposerForm as Form, AiComposerSubmit as Submit }; //# sourceMappingURL=index.js.map