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.

568 lines (564 loc) 18.1 kB
"use client"; import { jsx, jsxs, Fragment } from 'react/jsx-runtime'; import { Permission } from '@liveblocks/core'; import { useRoom } from '@liveblocks/react'; import { useLayoutEffect, useCreateRoomThread, useCreateRoomComment, useEditRoomComment, useResolveMentionSuggestions, useRoomPermissions } from '@liveblocks/react/_private'; import { useCallback, useMemo, createContext, forwardRef, useRef, useSyncExternalStore } from 'react'; import { useLiveblocksUIConfig } from '../config.js'; import { FLOATING_ELEMENT_SIDE_OFFSET } from '../constants.js'; import { AttachmentIcon } from '../icons/Attachment.js'; import { BoldIcon } from '../icons/Bold.js'; import { CodeIcon } from '../icons/Code.js'; import { EmojiIcon } from '../icons/Emoji.js'; import { ItalicIcon } from '../icons/Italic.js'; import { MentionIcon } from '../icons/Mention.js'; import { SendIcon } from '../icons/Send.js'; import { StrikethroughIcon } from '../icons/Strikethrough.js'; import { useOverrides } from '../overrides.js'; import { AttachFiles as ComposerAttachFiles, Mention as ComposerMention$1, Suggestions as ComposerSuggestions, SuggestionsList as ComposerSuggestionsList, SuggestionsListItem as ComposerSuggestionsListItem, MarkToggle as ComposerMarkToggle, FloatingToolbar as ComposerFloatingToolbar$1, Link as ComposerLink$1, Editor as ComposerEditor, Submit as ComposerSubmit, Form as ComposerForm } from '../primitives/Composer/index.js'; import { useComposer, useComposerEditorContext, useComposerAttachmentsContext } from '../primitives/Composer/contexts.js'; import { useComposerAttachmentsDropArea } from '../primitives/Composer/utils.js'; import { MENTION_CHARACTER } from '../slate/plugins/mentions.js'; import { classNames } from '../utils/class-names.js'; import { useControllableState } from '../utils/use-controllable-state.js'; import { FileAttachment } from './internal/Attachment.js'; import { Attribution } from './internal/Attribution.js'; import { Avatar } from './internal/Avatar.js'; import { Button } from './internal/Button.js'; import { EmojiPicker } from './internal/EmojiPicker.js'; import { Tooltip, ShortcutTooltip } from './internal/Tooltip.js'; import { User } from './internal/User.js'; import { PopoverTrigger } from '@radix-ui/react-popover'; import { TooltipProvider } from '@radix-ui/react-tooltip'; function ComposerInsertMentionEditorAction({ label, tooltipLabel, className, onClick, ...props }) { const { createMention } = useComposer(); const preventDefault = useCallback((event) => { event.preventDefault(); }, []); const handleClick = useCallback( (event) => { onClick?.(event); if (!event.isDefaultPrevented()) { event.stopPropagation(); createMention(); } }, [createMention, onClick] ); return /* @__PURE__ */ jsx(Tooltip, { content: tooltipLabel ?? label, children: /* @__PURE__ */ jsx(Button, { className: classNames("lb-composer-editor-action", className), onPointerDown: preventDefault, onClick: handleClick, "aria-label": label, icon: /* @__PURE__ */ jsx(MentionIcon, {}), ...props }) }); } function ComposerInsertEmojiEditorAction({ label, tooltipLabel, onPickerOpenChange, className, ...props }) { const { insertText } = useComposer(); const preventDefault = useCallback((event) => { event.preventDefault(); }, []); const stopPropagation = useCallback((event) => { event.stopPropagation(); }, []); return /* @__PURE__ */ jsx(EmojiPicker, { onEmojiSelect: insertText, onOpenChange: onPickerOpenChange, children: /* @__PURE__ */ jsx(Tooltip, { content: tooltipLabel ?? label, children: /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx(Button, { className: classNames("lb-composer-editor-action", className), onPointerDown: preventDefault, onClick: stopPropagation, "aria-label": label, icon: /* @__PURE__ */ jsx(EmojiIcon, {}), ...props }) }) }) }); } function ComposerAttachFilesEditorAction({ label, tooltipLabel, className, ...props }) { const preventDefault = useCallback((event) => { event.preventDefault(); }, []); const stopPropagation = useCallback((event) => { event.stopPropagation(); }, []); return /* @__PURE__ */ jsx(Tooltip, { content: tooltipLabel ?? label, children: /* @__PURE__ */ jsx(ComposerAttachFiles, { asChild: true, children: /* @__PURE__ */ jsx(Button, { className: classNames("lb-composer-editor-action", className), onPointerDown: preventDefault, onClick: stopPropagation, "aria-label": label, icon: /* @__PURE__ */ jsx(AttachmentIcon, {}), ...props }) }) }); } function ComposerMention({ userId }) { return /* @__PURE__ */ jsxs(ComposerMention$1, { className: "lb-composer-mention", children: [ MENTION_CHARACTER, /* @__PURE__ */ jsx(User, { userId }) ] }); } function ComposerMentionSuggestions({ userIds }) { return userIds.length > 0 ? /* @__PURE__ */ jsx(ComposerSuggestions, { className: "lb-root lb-portal lb-elevation lb-composer-suggestions lb-composer-mention-suggestions", children: /* @__PURE__ */ jsx(ComposerSuggestionsList, { className: "lb-composer-suggestions-list lb-composer-mention-suggestions-list", children: userIds.map((userId) => /* @__PURE__ */ jsxs(ComposerSuggestionsListItem, { className: "lb-composer-suggestions-list-item lb-composer-mention-suggestion", value: userId, children: [ /* @__PURE__ */ jsx(Avatar, { userId, className: "lb-composer-mention-suggestion-avatar" }), /* @__PURE__ */ jsx(User, { userId, className: "lb-composer-mention-suggestion-user" }) ] }, userId)) }) }) : null; } function MarkToggle({ mark, icon, shortcut, children, ...props }) { const $ = useOverrides(); const label = useMemo(() => { return $.COMPOSER_TOGGLE_MARK(mark); }, [$, mark]); return /* @__PURE__ */ jsx(ShortcutTooltip, { content: label, shortcut, sideOffset: FLOATING_ELEMENT_SIDE_OFFSET + 2, children: /* @__PURE__ */ jsx(ComposerMarkToggle, { mark, asChild: true, ...props, children: /* @__PURE__ */ jsx(Button, { "aria-label": label, variant: "toolbar", icon, children }) }) }); } const markToggles = { bold: () => /* @__PURE__ */ jsx(MarkToggle, { mark: "bold", shortcut: "Mod-B", icon: /* @__PURE__ */ jsx(BoldIcon, {}) }), italic: () => /* @__PURE__ */ jsx(MarkToggle, { mark: "italic", shortcut: "Mod-I", icon: /* @__PURE__ */ jsx(ItalicIcon, {}) }), strikethrough: () => /* @__PURE__ */ jsx(MarkToggle, { mark: "strikethrough", shortcut: "Mod-Shift-S", icon: /* @__PURE__ */ jsx(StrikethroughIcon, {}) }), code: () => /* @__PURE__ */ jsx(MarkToggle, { mark: "code", shortcut: "Mod-E", icon: /* @__PURE__ */ jsx(CodeIcon, {}) }) }; const markTogglesList = Object.entries(markToggles).map(([mark, Toggle]) => /* @__PURE__ */ jsx(Toggle, {}, mark)); function ComposerFloatingToolbar() { return /* @__PURE__ */ jsx(ComposerFloatingToolbar$1, { className: "lb-root lb-portal lb-elevation lb-composer-floating-toolbar", children: markTogglesList }); } function ComposerLink({ href, children }) { return /* @__PURE__ */ jsx(ComposerLink$1, { href, className: "lb-composer-link", children }); } function ComposerFileAttachment({ attachment, className, overrides, ...props }) { const { removeAttachment } = useComposer(); const { roomId } = useComposerEditorContext(); const handleDeleteClick = useCallback(() => { removeAttachment(attachment.id); }, [attachment.id, removeAttachment]); return /* @__PURE__ */ jsx(FileAttachment, { className: classNames("lb-composer-attachment", className), ...props, attachment, onDeleteClick: handleDeleteClick, preventFocusOnDelete: true, overrides, roomId }); } function ComposerAttachments({ overrides, className, ...props }) { const { attachments } = useComposer(); if (attachments.length === 0) { return null; } return /* @__PURE__ */ jsx("div", { className: classNames("lb-composer-attachments", className), ...props, children: /* @__PURE__ */ jsx("div", { className: "lb-attachments", children: attachments.map((attachment) => { return /* @__PURE__ */ jsx(ComposerFileAttachment, { attachment, overrides }, attachment.id); }) }) }); } const editorRequiredComponents = { Mention: ComposerMention, MentionSuggestions: ComposerMentionSuggestions, Link: ComposerLink }; function ComposerEditorContainer({ showAttachments = true, showFormattingControls = true, showAttribution, defaultValue, isCollapsed, overrides, actions, autoFocus, disabled, hasResolveMentionSuggestions, onEmojiPickerOpenChange, onEmptyChange, onEditorClick }) { const { isEmpty } = useComposer(); const { hasMaxAttachments } = useComposerAttachmentsContext(); const $ = useOverrides(overrides); const components = useMemo(() => { return { ...editorRequiredComponents, FloatingToolbar: showFormattingControls ? ComposerFloatingToolbar : void 0 }; }, [showFormattingControls]); const [isDraggingOver, dropAreaProps] = useComposerAttachmentsDropArea({ disabled: disabled || hasMaxAttachments }); useLayoutEffect(() => { onEmptyChange(isEmpty); }, [isEmpty, onEmptyChange]); const preventDefault = useCallback((event) => { event.preventDefault(); }, []); const stopPropagation = useCallback((event) => { event.stopPropagation(); }, []); return /* @__PURE__ */ jsxs("div", { className: "lb-composer-editor-container", ...dropAreaProps, children: [ /* @__PURE__ */ jsx(ComposerEditor, { className: "lb-composer-editor", onClick: onEditorClick, placeholder: $.COMPOSER_PLACEHOLDER, defaultValue, autoFocus, components, disabled, dir: $.dir }), showAttachments && /* @__PURE__ */ jsx(ComposerAttachments, { overrides }), (!isCollapsed || isDraggingOver) && /* @__PURE__ */ jsxs("div", { className: "lb-composer-footer", children: [ /* @__PURE__ */ jsxs("div", { className: "lb-composer-editor-actions", children: [ hasResolveMentionSuggestions && /* @__PURE__ */ jsx(ComposerInsertMentionEditorAction, { label: $.COMPOSER_INSERT_MENTION, disabled }), /* @__PURE__ */ jsx(ComposerInsertEmojiEditorAction, { label: $.COMPOSER_INSERT_EMOJI, onPickerOpenChange: onEmojiPickerOpenChange, disabled }), showAttachments && /* @__PURE__ */ jsx(ComposerAttachFilesEditorAction, { label: $.COMPOSER_ATTACH_FILES, disabled }) ] }), showAttribution && /* @__PURE__ */ jsx(Attribution, {}), /* @__PURE__ */ jsx("div", { className: "lb-composer-actions", children: actions ?? /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsx(ShortcutTooltip, { content: $.COMPOSER_SEND, shortcut: "Enter", children: /* @__PURE__ */ jsx(ComposerSubmit, { asChild: true, children: /* @__PURE__ */ jsx(Button, { onPointerDown: preventDefault, onClick: stopPropagation, className: "lb-composer-action", variant: "primary", "aria-label": $.COMPOSER_SEND, icon: /* @__PURE__ */ jsx(SendIcon, {}) }) }) }) }) }) ] }), showAttachments && isDraggingOver && /* @__PURE__ */ jsx("div", { className: "lb-composer-attachments-drop-area", children: /* @__PURE__ */ jsxs("div", { className: "lb-composer-attachments-drop-area-label", children: [ /* @__PURE__ */ jsx(AttachmentIcon, {}), $.COMPOSER_ATTACH_FILES ] }) }) ] }); } const ComposerRoomIdContext = createContext(null); const Composer = forwardRef( ({ threadId, commentId, metadata, defaultValue, defaultAttachments, onComposerSubmit, collapsed: controlledCollapsed, defaultCollapsed, onCollapsedChange: controlledOnCollapsedChange, overrides, actions, onBlur, className, onFocus, autoFocus, disabled, showAttachments = true, showFormattingControls = true, showAttribution, roomId: _roomId, ...props }, forwardedRef) => { const room = useRoom({ allowOutsideRoom: true }); const roomId = _roomId !== void 0 ? _roomId : room?.id; if (roomId === void 0) { throw new Error( "Composer must be a descendant of RoomProvider component" ); } const createThread = useCreateRoomThread(roomId); const createComment = useCreateRoomComment(roomId); const editComment = useEditRoomComment(roomId); const { preventUnsavedComposerChanges } = useLiveblocksUIConfig(); const hasResolveMentionSuggestions = useResolveMentionSuggestions() !== void 0; const isEmptyRef = useRef(true); const isEmojiPickerOpenRef = useRef(false); const $ = useOverrides(overrides); const [isCollapsed, onCollapsedChange] = useControllableState( controlledCollapsed === void 0 && defaultCollapsed === void 0 ? false : controlledCollapsed, controlledOnCollapsedChange, defaultCollapsed ); const canCommentFallback = useSyncExternalStore( useCallback( (callback) => { if (room === null) return () => { }; return room.events.self.subscribeOnce(callback); }, [room] ), useCallback(() => { return room?.getSelf()?.canComment ?? true; }, [room]), useCallback(() => true, []) ); const permissions = useRoomPermissions(roomId); const canComment = permissions.size > 0 ? permissions.has(Permission.CommentsWrite) || permissions.has(Permission.Write) : canCommentFallback; const setEmptyRef = useCallback((isEmpty) => { isEmptyRef.current = isEmpty; }, []); const setEmojiPickerOpenRef = useCallback((isEmojiPickerOpen) => { isEmojiPickerOpenRef.current = isEmojiPickerOpen; }, []); const handleFocus = useCallback( (event) => { onFocus?.(event); if (event.isDefaultPrevented()) { return; } if (isEmptyRef.current && canComment) { onCollapsedChange?.(false); } }, [onCollapsedChange, onFocus, canComment] ); const handleBlur = useCallback( (event) => { onBlur?.(event); if (event.isDefaultPrevented()) { return; } const isOutside = !event.currentTarget.contains( event.relatedTarget ?? document.activeElement ); if (isOutside && isEmptyRef.current && !isEmojiPickerOpenRef.current) { onCollapsedChange?.(true); } }, [onBlur, onCollapsedChange] ); const handleEditorClick = useCallback( (event) => { event.stopPropagation(); if (isEmptyRef.current && canComment) { onCollapsedChange?.(false); } }, [onCollapsedChange, canComment] ); const handleComposerSubmit = useCallback( (comment, event) => { onComposerSubmit?.(comment, event); if (event.isDefaultPrevented()) { return; } event.stopPropagation(); if (commentId && threadId) { editComment({ commentId, threadId, body: comment.body, attachments: comment.attachments }); } else if (threadId) { createComment({ threadId, body: comment.body, attachments: comment.attachments }); } else { createThread({ body: comment.body, metadata: metadata ?? {}, attachments: comment.attachments }); } }, [ commentId, createComment, createThread, editComment, metadata, onComposerSubmit, threadId ] ); return /* @__PURE__ */ jsx(TooltipProvider, { children: /* @__PURE__ */ jsx(ComposerForm, { onComposerSubmit: handleComposerSubmit, className: classNames( "lb-root lb-composer lb-composer-form", className ), dir: $.dir, ...props, ref: forwardedRef, "data-collapsed": isCollapsed ? "" : void 0, onFocus: handleFocus, onBlur: handleBlur, disabled: disabled || !canComment, defaultAttachments, pasteFilesAsAttachments: showAttachments, preventUnsavedChanges: preventUnsavedComposerChanges, roomId, children: /* @__PURE__ */ jsx(ComposerEditorContainer, { defaultValue, actions, overrides, isCollapsed, showAttachments, showAttribution, showFormattingControls, hasResolveMentionSuggestions, onEmptyChange: setEmptyRef, onEmojiPickerOpenChange: setEmojiPickerOpenRef, onEditorClick: handleEditorClick, autoFocus, disabled }) }) }); } ); export { Composer, ComposerRoomIdContext }; //# sourceMappingURL=Composer.js.map