@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
JavaScript
"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