feat: Unified icon picker (#7038)

This commit is contained in:
Hemachandar
2024-06-23 19:01:18 +05:30
committed by GitHub
parent 56d90e6bc3
commit 6fd3a0fa8a
83 changed files with 2302 additions and 852 deletions

View File

@@ -306,8 +306,9 @@ const HeadingWithIcon = styled(Heading)`
`;
const HeadingIcon = styled(CollectionIcon)`
align-self: flex-start;
flex-shrink: 0;
margin-left: -8px;
margin-right: 8px;
`;
export default observer(CollectionScene);

View File

@@ -19,9 +19,15 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { s } from "@shared/styles";
import { NavigationNode, TOCPosition, TeamPreference } from "@shared/types";
import {
IconType,
NavigationNode,
TOCPosition,
TeamPreference,
} from "@shared/types";
import { ProsemirrorHelper, Heading } from "@shared/utils/ProsemirrorHelper";
import { parseDomain } from "@shared/utils/domains";
import { determineIconType } from "@shared/utils/icon";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
@@ -169,8 +175,11 @@ class DocumentScene extends React.Component<Props> {
this.title = title;
this.props.document.title = title;
}
if (template.emoji) {
this.props.document.emoji = template.emoji;
if (template.icon) {
this.props.document.icon = template.icon;
}
if (template.color) {
this.props.document.color = template.color;
}
this.props.document.data = cloneDeep(template.data);
@@ -383,8 +392,9 @@ class DocumentScene extends React.Component<Props> {
void this.autosave();
});
handleChangeEmoji = action((value: string) => {
this.props.document.emoji = value;
handleChangeIcon = action((icon: string | null, color: string | null) => {
this.props.document.icon = icon;
this.props.document.color = color;
void this.onSave();
});
@@ -425,6 +435,12 @@ class DocumentScene extends React.Component<Props> {
? this.props.match.url
: updateDocumentPath(this.props.match.url, document);
const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji;
const title = hasEmojiInTitle
? document.titleWithDefault.replace(document.icon!, "")
: document.titleWithDefault;
const favicon = hasEmojiInTitle ? emojiToUrl(document.icon!) : undefined;
return (
<ErrorBoundary showTitle>
{this.props.location.pathname !== canonicalUrl && (
@@ -459,10 +475,7 @@ class DocumentScene extends React.Component<Props> {
column
auto
>
<PageTitle
title={document.titleWithDefault.replace(document.emoji || "", "")}
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
/>
<PageTitle title={title} favicon={favicon} />
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
<Container column>
{!readOnly && (
@@ -542,7 +555,7 @@ class DocumentScene extends React.Component<Props> {
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.handleChangeTitle}
onChangeEmoji={this.handleChangeEmoji}
onChangeIcon={this.handleChangeIcon}
onChange={this.handleChange}
onHeadingsChange={this.onHeadingsChange}
onSave={this.onSave}

View File

@@ -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);

View File

@@ -4,7 +4,9 @@ import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { useHistory, useRouteMatch } from "react-router-dom";
import { richExtensions, withComments } from "@shared/editor/nodes";
import { randomElement } from "@shared/random";
import { TeamPreference } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import { RefHandle } from "~/components/ContentEditable";
@@ -52,7 +54,7 @@ const extensions = [
type Props = Omit<EditorProps, "editorStyle"> & {
onChangeTitle: (title: string) => void;
onChangeEmoji: (emoji: string | null) => void;
onChangeIcon: (icon: string | null, color: string | null) => void;
id: string;
document: Document;
isDraft: boolean;
@@ -81,7 +83,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
const {
document,
onChangeTitle,
onChangeEmoji,
onChangeIcon,
isDraft,
shareId,
readOnly,
@@ -91,6 +93,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
} = props;
const can = usePolicy(document);
const iconColor = React.useMemo(
() => document.color ?? randomElement(colorPalette),
[document.color]
);
const childRef = React.useRef<HTMLDivElement>(null);
const focusAtStart = React.useCallback(() => {
if (ref.current) {
@@ -186,9 +192,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
? document.titleWithDefault
: document.title
}
emoji={document.emoji}
icon={document.icon}
color={iconColor}
onChangeTitle={onChangeTitle}
onChangeEmoji={onChangeEmoji}
onChangeIcon={onChangeIcon}
onGoToNextInput={handleGoToNextInput}
onBlur={handleBlur}
placeholder={t("Untitled")}

View File

@@ -24,8 +24,9 @@ import {
useDocumentContext,
useEditingFocus,
} from "~/components/DocumentContext";
import Flex from "~/components/Flex";
import Header from "~/components/Header";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import Icon from "~/components/Icon";
import Star from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import { publishDocument } from "~/actions/definitions/documents";
@@ -189,7 +190,14 @@ function DocumentHeader({
return (
<StyledHeader
$hidden={isEditingFocus}
title={document.title}
title={
<Flex gap={4}>
{document.icon && (
<Icon value={document.icon} color={document.color ?? undefined} />
)}
{document.title}
</Flex>
}
hasSidebar={sharedTree && sharedTree.children?.length > 0}
left={
isMobile ? (
@@ -229,17 +237,15 @@ function DocumentHeader({
)
}
title={
<>
{document.emoji && (
<>
<EmojiIcon size={24} emoji={document.emoji} />{" "}
</>
<Flex gap={4}>
{document.icon && (
<Icon value={document.icon} color={document.color ?? undefined} />
)}
{document.title}{" "}
{document.title}
{document.isArchived && (
<ArchivedBadge>{t("Archived")}</ArchivedBadge>
)}
</>
</Flex>
}
actions={
<>

View File

@@ -1,7 +1,7 @@
import * as React from "react";
import { NavigationNode } from "@shared/types";
import Breadcrumb from "~/components/Breadcrumb";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import Icon from "~/components/Icon";
import { MenuInternalLink } from "~/types";
import { sharedDocumentPath } from "~/utils/routeHelpers";
@@ -53,13 +53,10 @@ const PublicBreadcrumb: React.FC<Props> = ({
.slice(0, -1)
.map((item) => ({
...item,
title: item.emoji ? (
<>
<EmojiIcon emoji={item.emoji} /> {item.title}
</>
) : (
item.title
),
icon: item.icon ? (
<Icon value={item.icon} color={item.color} />
) : undefined,
title: item.title,
type: "route",
to: sharedDocumentPath(shareId, item.url),
})),

View File

@@ -4,10 +4,11 @@ import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import { IconType, NavigationNode } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document";
import Flex from "~/components/Flex";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import Icon from "~/components/Icon";
import { hover } from "~/styles";
import { sharedDocumentPath } from "~/utils/routeHelpers";
@@ -58,7 +59,8 @@ function ReferenceListItem({
shareId,
...rest
}: Props) {
const { emoji } = document;
const { icon, color } = document;
const isEmoji = determineIconType(icon) === IconType.Emoji;
return (
<DocumentLink
@@ -74,9 +76,13 @@ function ReferenceListItem({
{...rest}
>
<Content gap={4} dir="auto">
{emoji ? <EmojiIcon emoji={emoji} /> : <DocumentIcon />}
{icon ? (
<Icon value={icon} color={color ?? undefined} />
) : (
<DocumentIcon />
)}
<Title>
{emoji ? document.title.replace(emoji, "") : document.title}
{isEmoji ? document.title.replace(icon!, "") : document.title}
</Title>
</Content>
</DocumentLink>

View File

@@ -1,6 +1,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import EditorContainer from "@shared/editor/components/Styles";
import { colorPalette } from "@shared/utils/collections";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import { Props as EditorProps } from "~/components/Editor";
@@ -30,7 +31,8 @@ function RevisionViewer(props: Props) {
<DocumentTitle
documentId={revision.documentId}
title={revision.title}
emoji={revision.emoji}
icon={revision.icon}
color={revision.color ?? colorPalette[0]}
readOnly
/>
<DocumentMeta