feat: Unified icon picker (#7038)
This commit is contained in:
@@ -18,29 +18,32 @@ import {
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import { Emoji, EmojiButton } from "~/components/EmojiPicker/components";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import { PopoverButton } from "~/components/IconPicker/components/PopoverButton";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
const EmojiPicker = React.lazy(() => import("~/components/EmojiPicker"));
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
|
||||
type Props = {
|
||||
/** ID of the associated document */
|
||||
documentId: string;
|
||||
/** Title to display */
|
||||
title: string;
|
||||
/** Emoji to display */
|
||||
emoji?: string | null;
|
||||
/** Icon to display */
|
||||
icon?: string | null;
|
||||
/** Icon color */
|
||||
color: string;
|
||||
/** Placeholder to display when the document has no title */
|
||||
placeholder?: string;
|
||||
/** Should the title be editable, policies will also be considered separately */
|
||||
readOnly?: boolean;
|
||||
/** Callback called on any edits to text */
|
||||
onChangeTitle?: (text: string) => void;
|
||||
/** Callback called when the user selects an emoji */
|
||||
onChangeEmoji?: (emoji: string | null) => void;
|
||||
/** Callback called when the user selects an icon */
|
||||
onChangeIcon?: (icon: string | null, color: string | null) => void;
|
||||
/** Callback called when the user expects to move to the "next" input */
|
||||
onGoToNextInput?: (insertParagraph?: boolean) => void;
|
||||
/** Callback called when the user expects to save (CMD+S) */
|
||||
@@ -56,10 +59,11 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
||||
{
|
||||
documentId,
|
||||
title,
|
||||
emoji,
|
||||
icon,
|
||||
color,
|
||||
readOnly,
|
||||
onChangeTitle,
|
||||
onChangeEmoji,
|
||||
onChangeIcon,
|
||||
onSave,
|
||||
onGoToNextInput,
|
||||
onBlur,
|
||||
@@ -68,7 +72,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
||||
externalRef: React.RefObject<RefHandle>
|
||||
) {
|
||||
const ref = React.useRef<RefHandle>(null);
|
||||
const [emojiPickerIsOpen, handleOpen, handleClose] = useBoolean();
|
||||
const [iconPickerIsOpen, handleOpen, handleClose] = useBoolean();
|
||||
const { editor } = useDocumentContext();
|
||||
const can = usePolicy(documentId);
|
||||
|
||||
@@ -212,19 +216,26 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
||||
[editor]
|
||||
);
|
||||
|
||||
const handleEmojiChange = React.useCallback(
|
||||
async (value: string | null) => {
|
||||
// Restore focus on title
|
||||
restoreFocus();
|
||||
if (emoji !== value) {
|
||||
onChangeEmoji?.(value);
|
||||
const handleIconChange = React.useCallback(
|
||||
(chosenIcon: string | null, iconColor: string | null) => {
|
||||
if (icon !== chosenIcon || color !== iconColor) {
|
||||
onChangeIcon?.(chosenIcon, iconColor);
|
||||
}
|
||||
},
|
||||
[emoji, onChangeEmoji, restoreFocus]
|
||||
[icon, color, onChangeIcon]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!iconPickerIsOpen) {
|
||||
restoreFocus();
|
||||
}
|
||||
}, [iconPickerIsOpen, restoreFocus]);
|
||||
|
||||
const dir = ref.current?.getComputedDirection();
|
||||
const emojiIcon = <Emoji size={32}>{emoji}</Emoji>;
|
||||
|
||||
const fallbackIcon = icon ? (
|
||||
<Icon value={icon} color={color} size={40} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Title
|
||||
@@ -235,8 +246,8 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
value={title}
|
||||
$emojiPickerIsOpen={emojiPickerIsOpen}
|
||||
$containsEmoji={!!emoji}
|
||||
$iconPickerIsOpen={iconPickerIsOpen}
|
||||
$containsIcon={!!icon}
|
||||
autoFocus={!title}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
readOnly={readOnly}
|
||||
@@ -244,47 +255,33 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
||||
ref={mergeRefs([ref, externalRef])}
|
||||
>
|
||||
{can.update && !readOnly ? (
|
||||
<EmojiWrapper align="center" justify="center" dir={dir}>
|
||||
<React.Suspense fallback={emojiIcon}>
|
||||
<StyledEmojiPicker
|
||||
value={emoji}
|
||||
onChange={handleEmojiChange}
|
||||
<IconWrapper align="center" justify="center" dir={dir}>
|
||||
<React.Suspense fallback={fallbackIcon}>
|
||||
<StyledIconPicker
|
||||
icon={icon ?? null}
|
||||
color={color}
|
||||
size={40}
|
||||
popoverPosition="bottom-start"
|
||||
allowDelete={true}
|
||||
borderOnHover={true}
|
||||
onChange={handleIconChange}
|
||||
onOpen={handleOpen}
|
||||
onClose={handleClose}
|
||||
onClickOutside={restoreFocus}
|
||||
autoFocus
|
||||
/>
|
||||
</React.Suspense>
|
||||
</EmojiWrapper>
|
||||
) : emoji ? (
|
||||
<EmojiWrapper align="center" justify="center" dir={dir}>
|
||||
{emojiIcon}
|
||||
</EmojiWrapper>
|
||||
</IconWrapper>
|
||||
) : icon ? (
|
||||
<IconWrapper align="center" justify="center" dir={dir}>
|
||||
{fallbackIcon}
|
||||
</IconWrapper>
|
||||
) : null}
|
||||
</Title>
|
||||
);
|
||||
});
|
||||
|
||||
const StyledEmojiPicker = styled(EmojiPicker)`
|
||||
${extraArea(8)}
|
||||
`;
|
||||
|
||||
const EmojiWrapper = styled(Flex)<{ dir?: string }>`
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
|
||||
// Always move above TOC
|
||||
z-index: 1;
|
||||
|
||||
${(props: { dir?: string }) =>
|
||||
props.dir === "rtl" ? "right: -40px" : "left: -40px"};
|
||||
`;
|
||||
|
||||
type TitleProps = {
|
||||
$containsEmoji: boolean;
|
||||
$emojiPickerIsOpen: boolean;
|
||||
$containsIcon: boolean;
|
||||
$iconPickerIsOpen: boolean;
|
||||
};
|
||||
|
||||
const Title = styled(ContentEditable)<TitleProps>`
|
||||
@@ -293,7 +290,7 @@ const Title = styled(ContentEditable)<TitleProps>`
|
||||
margin-top: 6vh;
|
||||
margin-bottom: 0.5em;
|
||||
margin-left: ${(props) =>
|
||||
props.$containsEmoji || props.$emojiPickerIsOpen ? "40px" : "0px"};
|
||||
props.$containsIcon || props.$iconPickerIsOpen ? "40px" : "0px"};
|
||||
font-size: ${fontSize};
|
||||
font-weight: 600;
|
||||
border: 0;
|
||||
@@ -314,14 +311,14 @@ const Title = styled(ContentEditable)<TitleProps>`
|
||||
&:focus {
|
||||
margin-left: 40px;
|
||||
|
||||
${EmojiButton} {
|
||||
${PopoverButton} {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
${EmojiButton} {
|
||||
${PopoverButton} {
|
||||
opacity: ${(props: TitleProps) =>
|
||||
props.$containsEmoji ? "1 !important" : 0};
|
||||
props.$containsIcon ? "1 !important" : 0};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
@@ -333,7 +330,7 @@ const Title = styled(ContentEditable)<TitleProps>`
|
||||
}
|
||||
|
||||
&:hover {
|
||||
${EmojiButton} {
|
||||
${PopoverButton} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
@@ -349,4 +346,21 @@ const Title = styled(ContentEditable)<TitleProps>`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledIconPicker = styled(IconPicker)`
|
||||
${extraArea(8)}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled(Flex)<{ dir?: string }>`
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
|
||||
// Always move above TOC
|
||||
z-index: 1;
|
||||
|
||||
${(props: { dir?: string }) =>
|
||||
props.dir === "rtl" ? "right: -48px" : "left: -48px"};
|
||||
`;
|
||||
|
||||
export default observer(DocumentTitle);
|
||||
|
||||
Reference in New Issue
Block a user