@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.
575 lines (571 loc) • 19.7 kB
JavaScript
"use client";
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
import { MENTION_CHARACTER, assertNever, 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 '../icons/index.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 { cn } from '../utils/cn.js';
import { useControllableState } from '../utils/use-controllable-state.js';
import { useIsGroupMentionMember } from '../utils/use-group-mention.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 { Group } from './internal/Group.js';
import { GroupDescription } from './internal/GroupDescription.js';
import { Tooltip, ShortcutTooltip } from './internal/Tooltip.js';
import { User } from './internal/User.js';
import { PopoverTrigger } from '@radix-ui/react-popover';
import { UsersIcon } from '../icons/Users.js';
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: cn("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: cn("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: cn("lb-composer-editor-action", className),
onPointerDown: preventDefault,
onClick: stopPropagation,
"aria-label": label,
icon: /* @__PURE__ */ jsx(AttachmentIcon, {}),
...props
}
) }) });
}
function ComposerUserMention({ mention }) {
return /* @__PURE__ */ jsxs(ComposerMention$1, { className: "lb-mention lb-composer-mention", children: [
/* @__PURE__ */ jsx("span", { className: "lb-mention-symbol", children: MENTION_CHARACTER }),
/* @__PURE__ */ jsx(User, { userId: mention.id })
] });
}
function ComposerGroupMention({ mention }) {
const isMember = useIsGroupMentionMember(mention);
return /* @__PURE__ */ jsxs(
ComposerMention$1,
{
className: "lb-mention lb-composer-mention",
"data-self": isMember ? "" : void 0,
children: [
/* @__PURE__ */ jsx("span", { className: "lb-mention-symbol", children: MENTION_CHARACTER }),
/* @__PURE__ */ jsx(Group, { groupId: mention.id })
]
}
);
}
function ComposerMention({ mention, ...props }) {
switch (mention.kind) {
case "user":
return /* @__PURE__ */ jsx(ComposerUserMention, { mention, ...props });
case "group":
return /* @__PURE__ */ jsx(ComposerGroupMention, { mention, ...props });
default:
return assertNever(mention, "Unhandled mention kind");
}
}
function ComposerMentionSuggestions({
mentions
}) {
return mentions.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: mentions.map((mention) => {
return /* @__PURE__ */ jsx(
ComposerSuggestionsListItem,
{
className: "lb-composer-suggestions-list-item lb-composer-mention-suggestion",
value: mention.id,
children: mention.kind === "user" ? /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(
Avatar,
{
userId: mention.id,
className: "lb-composer-mention-suggestion-avatar"
}
),
/* @__PURE__ */ jsx(
User,
{
userId: mention.id,
className: "lb-composer-mention-suggestion-user"
}
)
] }) : mention.kind === "group" ? /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(
Avatar,
{
groupId: mention.id,
className: "lb-composer-mention-suggestion-avatar",
icon: /* @__PURE__ */ jsx(UsersIcon, {})
}
),
/* @__PURE__ */ jsx(
Group,
{
groupId: mention.id,
className: "lb-composer-mention-suggestion-group",
children: /* @__PURE__ */ jsx(
GroupDescription,
{
groupId: mention.id,
className: "lb-composer-mention-suggestion-group-description"
}
)
}
)
] }) : assertNever(mention, "Unhandled mention kind")
},
mention.id
);
}) }) }) : 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: cn("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: cn("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 || !showAttachments || 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,
blurOnSubmit = true,
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(
defaultCollapsed ?? false,
controlledCollapsed,
controlledOnCollapsedChange
);
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: cn("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,
blurOnSubmit,
roomId,
children: /* @__PURE__ */ jsx(
ComposerEditorContainer,
{
defaultValue,
actions,
overrides,
isCollapsed,
showAttachments,
showAttribution,
showFormattingControls,
hasResolveMentionSuggestions,
onEmptyChange: setEmptyRef,
onEmojiPickerOpenChange: setEmojiPickerOpenRef,
onEditorClick: handleEditorClick,
autoFocus,
disabled
}
)
}
) });
}
);
export { Composer, ComposerMention, ComposerRoomIdContext };
//# sourceMappingURL=Composer.js.map