feat: Unified icon picker (#7038)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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={
|
||||
<>
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user