feat: Unified icon picker (#7038)
This commit is contained in:
@@ -11,7 +11,7 @@ import { CollectionValidation } from "@shared/validations";
|
|||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import IconPicker from "~/components/IconPicker";
|
import Icon from "~/components/Icon";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||||
import Switch from "~/components/Switch";
|
import Switch from "~/components/Switch";
|
||||||
@@ -20,10 +20,12 @@ import useBoolean from "~/hooks/useBoolean";
|
|||||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||||
|
|
||||||
|
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||||
|
|
||||||
export interface FormData {
|
export interface FormData {
|
||||||
name: string;
|
name: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string | null;
|
||||||
sharing: boolean;
|
sharing: boolean;
|
||||||
permission: CollectionPermission | undefined;
|
permission: CollectionPermission | undefined;
|
||||||
}
|
}
|
||||||
@@ -37,7 +39,16 @@ export const CollectionForm = observer(function CollectionForm_({
|
|||||||
}) {
|
}) {
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
||||||
|
|
||||||
|
const iconColor = React.useMemo(
|
||||||
|
() => collection?.color ?? randomElement(colorPalette),
|
||||||
|
[collection?.color]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fallbackIcon = <Icon value="collection" color={iconColor} />;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit: formHandleSubmit,
|
handleSubmit: formHandleSubmit,
|
||||||
@@ -53,7 +64,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
|||||||
icon: collection?.icon,
|
icon: collection?.icon,
|
||||||
sharing: collection?.sharing ?? true,
|
sharing: collection?.sharing ?? true,
|
||||||
permission: collection?.permission,
|
permission: collection?.permission,
|
||||||
color: collection?.color ?? randomElement(colorPalette),
|
color: iconColor,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,20 +81,20 @@ export const CollectionForm = observer(function CollectionForm_({
|
|||||||
"collection"
|
"collection"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [values.name, collection]);
|
}, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
|
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
|
||||||
}, [setFocus]);
|
}, [setFocus]);
|
||||||
|
|
||||||
const handleIconPickerChange = React.useCallback(
|
const handleIconChange = React.useCallback(
|
||||||
(color: string, icon: string) => {
|
(icon: string, color: string | null) => {
|
||||||
if (icon !== values.icon) {
|
if (icon !== values.icon) {
|
||||||
setFocus("name");
|
setFocus("name");
|
||||||
}
|
}
|
||||||
|
|
||||||
setValue("color", color);
|
|
||||||
setValue("icon", icon);
|
setValue("icon", icon);
|
||||||
|
setValue("color", color);
|
||||||
},
|
},
|
||||||
[setFocus, setValue, values.icon]
|
[setFocus, setValue, values.icon]
|
||||||
);
|
);
|
||||||
@@ -105,13 +116,16 @@ export const CollectionForm = observer(function CollectionForm_({
|
|||||||
maxLength: CollectionValidation.maxNameLength,
|
maxLength: CollectionValidation.maxNameLength,
|
||||||
})}
|
})}
|
||||||
prefix={
|
prefix={
|
||||||
<StyledIconPicker
|
<React.Suspense fallback={fallbackIcon}>
|
||||||
onOpen={setHasOpenedIconPicker}
|
<StyledIconPicker
|
||||||
onChange={handleIconPickerChange}
|
icon={values.icon}
|
||||||
initial={values.name[0]}
|
color={values.color ?? iconColor}
|
||||||
color={values.color}
|
initial={values.name[0]}
|
||||||
icon={values.icon}
|
popoverPosition="right"
|
||||||
/>
|
onOpen={setHasOpenedIconPicker}
|
||||||
|
onChange={handleIconChange}
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
}
|
}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import styled from "styled-components";
|
|||||||
import type { NavigationNode } from "@shared/types";
|
import type { NavigationNode } from "@shared/types";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Breadcrumb from "~/components/Breadcrumb";
|
import Breadcrumb from "~/components/Breadcrumb";
|
||||||
|
import Icon from "~/components/Icon";
|
||||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { MenuInternalLink } from "~/types";
|
import { MenuInternalLink } from "~/types";
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
settingsPath,
|
settingsPath,
|
||||||
trashPath,
|
trashPath,
|
||||||
} from "~/utils/routeHelpers";
|
} from "~/utils/routeHelpers";
|
||||||
import EmojiIcon from "./Icons/EmojiIcon";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -106,9 +106,9 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
|||||||
path.slice(0, -1).forEach((node: NavigationNode) => {
|
path.slice(0, -1).forEach((node: NavigationNode) => {
|
||||||
output.push({
|
output.push({
|
||||||
type: "route",
|
type: "route",
|
||||||
title: node.emoji ? (
|
title: node.icon ? (
|
||||||
<>
|
<>
|
||||||
<EmojiIcon emoji={node.emoji} /> {node.title}
|
<StyledIcon value={node.icon} color={node.color} /> {node.title}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
node.title
|
node.title
|
||||||
@@ -144,6 +144,10 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const StyledIcon = styled(Icon)`
|
||||||
|
margin-right: 2px;
|
||||||
|
`;
|
||||||
|
|
||||||
const SmallSlash = styled(GoToIcon)`
|
const SmallSlash = styled(GoToIcon)`
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
|
|||||||
@@ -9,15 +9,17 @@ import { Link } from "react-router-dom";
|
|||||||
import styled, { useTheme } from "styled-components";
|
import styled, { useTheme } from "styled-components";
|
||||||
import Squircle from "@shared/components/Squircle";
|
import Squircle from "@shared/components/Squircle";
|
||||||
import { s, ellipsis } from "@shared/styles";
|
import { s, ellipsis } from "@shared/styles";
|
||||||
|
import { IconType } from "@shared/types";
|
||||||
|
import { determineIconType } from "@shared/utils/icon";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Pin from "~/models/Pin";
|
import Pin from "~/models/Pin";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
|
import Icon from "~/components/Icon";
|
||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
import Time from "~/components/Time";
|
import Time from "~/components/Time";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { hover } from "~/styles";
|
import { hover } from "~/styles";
|
||||||
import CollectionIcon from "./Icons/CollectionIcon";
|
import CollectionIcon from "./Icons/CollectionIcon";
|
||||||
import EmojiIcon from "./Icons/EmojiIcon";
|
|
||||||
import Text from "./Text";
|
import Text from "./Text";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
|
||||||
@@ -52,6 +54,8 @@ function DocumentCard(props: Props) {
|
|||||||
disabled: !isDraggable || !canUpdatePin,
|
disabled: !isDraggable || !canUpdatePin,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji;
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
@@ -109,12 +113,18 @@ function DocumentCard(props: Props) {
|
|||||||
<path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" />
|
<path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" />
|
||||||
</Fold>
|
</Fold>
|
||||||
|
|
||||||
{document.emoji ? (
|
{document.icon ? (
|
||||||
<Squircle color={theme.slateLight}>
|
<DocumentSquircle
|
||||||
<EmojiIcon emoji={document.emoji} size={24} />
|
icon={document.icon}
|
||||||
</Squircle>
|
color={document.color ?? undefined}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Squircle color={collection?.color}>
|
<Squircle
|
||||||
|
color={
|
||||||
|
collection?.color ??
|
||||||
|
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
|
||||||
|
}
|
||||||
|
>
|
||||||
{collection?.icon &&
|
{collection?.icon &&
|
||||||
collection?.icon !== "letter" &&
|
collection?.icon !== "letter" &&
|
||||||
collection?.icon !== "collection" &&
|
collection?.icon !== "collection" &&
|
||||||
@@ -127,8 +137,8 @@ function DocumentCard(props: Props) {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<Heading dir={document.dir}>
|
<Heading dir={document.dir}>
|
||||||
{document.emoji
|
{hasEmojiInTitle
|
||||||
? document.titleWithDefault.replace(document.emoji, "")
|
? document.titleWithDefault.replace(document.icon!, "")
|
||||||
: document.titleWithDefault}
|
: document.titleWithDefault}
|
||||||
</Heading>
|
</Heading>
|
||||||
<DocumentMeta size="xsmall">
|
<DocumentMeta size="xsmall">
|
||||||
@@ -159,6 +169,25 @@ function DocumentCard(props: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DocumentSquircle = ({
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
icon: string;
|
||||||
|
color?: string;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const iconType = determineIconType(icon)!;
|
||||||
|
const squircleColor =
|
||||||
|
iconType === IconType.Outline ? color : theme.slateLight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Squircle color={squircleColor}>
|
||||||
|
<Icon value={icon} color={theme.white} />
|
||||||
|
</Squircle>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Clock = styled(ClockIcon)`
|
const Clock = styled(ClockIcon)`
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import { NavigationNode } from "@shared/types";
|
|||||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
|
import Icon from "~/components/Icon";
|
||||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
|
||||||
import { Outline } from "~/components/Input";
|
import { Outline } from "~/components/Input";
|
||||||
import InputSearch from "~/components/InputSearch";
|
import InputSearch from "~/components/InputSearch";
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
@@ -216,25 +216,30 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
|||||||
}) => {
|
}) => {
|
||||||
const node = data[index];
|
const node = data[index];
|
||||||
const isCollection = node.type === "collection";
|
const isCollection = node.type === "collection";
|
||||||
let icon, title: string, emoji: string | undefined, path;
|
let renderedIcon,
|
||||||
|
title: string,
|
||||||
|
icon: string | undefined,
|
||||||
|
color: string | undefined,
|
||||||
|
path;
|
||||||
|
|
||||||
if (isCollection) {
|
if (isCollection) {
|
||||||
const col = collections.get(node.collectionId as string);
|
const col = collections.get(node.collectionId as string);
|
||||||
icon = col && (
|
renderedIcon = col && (
|
||||||
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
||||||
);
|
);
|
||||||
title = node.title;
|
title = node.title;
|
||||||
} else {
|
} else {
|
||||||
const doc = documents.get(node.id);
|
const doc = documents.get(node.id);
|
||||||
emoji = doc?.emoji ?? node.emoji;
|
icon = doc?.icon ?? node.icon;
|
||||||
|
color = doc?.color ?? node.color;
|
||||||
title = doc?.title ?? node.title;
|
title = doc?.title ?? node.title;
|
||||||
|
|
||||||
if (emoji) {
|
if (icon) {
|
||||||
icon = <EmojiIcon emoji={emoji} />;
|
renderedIcon = <Icon value={icon} color={color} />;
|
||||||
} else if (doc?.isStarred) {
|
} else if (doc?.isStarred) {
|
||||||
icon = <StarredIcon color={theme.yellow} />;
|
renderedIcon = <StarredIcon color={theme.yellow} />;
|
||||||
} else {
|
} else {
|
||||||
icon = <DocumentIcon color={theme.textSecondary} />;
|
renderedIcon = <DocumentIcon color={theme.textSecondary} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
path = ancestors(node)
|
path = ancestors(node)
|
||||||
@@ -254,7 +259,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
|||||||
}}
|
}}
|
||||||
onPointerMove={() => setActiveNode(index)}
|
onPointerMove={() => setActiveNode(index)}
|
||||||
onClick={() => toggleSelect(index)}
|
onClick={() => toggleSelect(index)}
|
||||||
icon={icon}
|
icon={renderedIcon}
|
||||||
title={title}
|
title={title}
|
||||||
path={path}
|
path={path}
|
||||||
/>
|
/>
|
||||||
@@ -275,7 +280,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
|||||||
selected={isSelected(index)}
|
selected={isSelected(index)}
|
||||||
active={activeNode === index}
|
active={activeNode === index}
|
||||||
expanded={isExpanded(index)}
|
expanded={isExpanded(index)}
|
||||||
icon={icon}
|
icon={renderedIcon}
|
||||||
title={title}
|
title={title}
|
||||||
depth={node.depth as number}
|
depth={node.depth as number}
|
||||||
hasChildren={hasChildren(index)}
|
hasChildren={hasChildren(index)}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import Badge from "~/components/Badge";
|
|||||||
import DocumentMeta from "~/components/DocumentMeta";
|
import DocumentMeta from "~/components/DocumentMeta";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import Highlight from "~/components/Highlight";
|
import Highlight from "~/components/Highlight";
|
||||||
|
import Icon from "~/components/Icon";
|
||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
import StarButton, { AnimatedStar } from "~/components/Star";
|
import StarButton, { AnimatedStar } from "~/components/Star";
|
||||||
import Tooltip from "~/components/Tooltip";
|
import Tooltip from "~/components/Tooltip";
|
||||||
@@ -23,7 +24,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
|||||||
import DocumentMenu from "~/menus/DocumentMenu";
|
import DocumentMenu from "~/menus/DocumentMenu";
|
||||||
import { hover } from "~/styles";
|
import { hover } from "~/styles";
|
||||||
import { documentPath } from "~/utils/routeHelpers";
|
import { documentPath } from "~/utils/routeHelpers";
|
||||||
import EmojiIcon from "./Icons/EmojiIcon";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
document: Document;
|
document: Document;
|
||||||
@@ -97,9 +97,9 @@ function DocumentListItem(
|
|||||||
>
|
>
|
||||||
<Content>
|
<Content>
|
||||||
<Heading dir={document.dir}>
|
<Heading dir={document.dir}>
|
||||||
{document.emoji && (
|
{document.icon && (
|
||||||
<>
|
<>
|
||||||
<EmojiIcon emoji={document.emoji} size={24} />
|
<Icon value={document.icon} color={document.color ?? undefined} />
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import styled from "styled-components";
|
|
||||||
import Button from "~/components/Button";
|
|
||||||
import { hover } from "~/styles";
|
|
||||||
import Flex from "../Flex";
|
|
||||||
|
|
||||||
export const EmojiButton = styled(Button)`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
|
|
||||||
&: ${hover},
|
|
||||||
&:active,
|
|
||||||
&[aria-expanded= "true"] {
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Emoji = styled(Flex)<{ size?: number }>`
|
|
||||||
line-height: 1.6;
|
|
||||||
${(props) => (props.size ? `font-size: ${props.size}px` : "")}
|
|
||||||
`;
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import data from "@emoji-mart/data";
|
|
||||||
import Picker from "@emoji-mart/react";
|
|
||||||
import { SmileyIcon } from "outline-icons";
|
|
||||||
import * as React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
|
||||||
import styled, { useTheme } from "styled-components";
|
|
||||||
import { depths, s } from "@shared/styles";
|
|
||||||
import { toRGB } from "@shared/utils/color";
|
|
||||||
import Button from "~/components/Button";
|
|
||||||
import Popover from "~/components/Popover";
|
|
||||||
import useStores from "~/hooks/useStores";
|
|
||||||
import useUserLocale from "~/hooks/useUserLocale";
|
|
||||||
import { Emoji, EmojiButton } from "./components";
|
|
||||||
|
|
||||||
/* Locales supported by emoji-mart */
|
|
||||||
const supportedLocales = [
|
|
||||||
"en",
|
|
||||||
"ar",
|
|
||||||
"be",
|
|
||||||
"cs",
|
|
||||||
"de",
|
|
||||||
"es",
|
|
||||||
"fa",
|
|
||||||
"fi",
|
|
||||||
"fr",
|
|
||||||
"hi",
|
|
||||||
"it",
|
|
||||||
"ja",
|
|
||||||
"ko",
|
|
||||||
"nl",
|
|
||||||
"pl",
|
|
||||||
"pt",
|
|
||||||
"ru",
|
|
||||||
"sa",
|
|
||||||
"tr",
|
|
||||||
"uk",
|
|
||||||
"vi",
|
|
||||||
"zh",
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React hook to derive emoji picker's theme from UI theme
|
|
||||||
*
|
|
||||||
* @returns {string} Theme to use for emoji picker
|
|
||||||
*/
|
|
||||||
function usePickerTheme(): string {
|
|
||||||
const { ui } = useStores();
|
|
||||||
const { theme } = ui;
|
|
||||||
|
|
||||||
if (theme === "system") {
|
|
||||||
return "auto";
|
|
||||||
}
|
|
||||||
|
|
||||||
return theme;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
/** The selected emoji, if any */
|
|
||||||
value?: string | null;
|
|
||||||
/** Callback when an emoji is selected */
|
|
||||||
onChange: (emoji: string | null) => void | Promise<void>;
|
|
||||||
/** Callback when the picker is opened */
|
|
||||||
onOpen?: () => void;
|
|
||||||
/** Callback when the picker is closed */
|
|
||||||
onClose?: () => void;
|
|
||||||
/** Callback when the picker is clicked outside of */
|
|
||||||
onClickOutside: () => void;
|
|
||||||
/** Whether to auto focus the search input on open */
|
|
||||||
autoFocus?: boolean;
|
|
||||||
/** Class name to apply to the trigger button */
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function EmojiPicker({
|
|
||||||
value,
|
|
||||||
onOpen,
|
|
||||||
onClose,
|
|
||||||
onChange,
|
|
||||||
onClickOutside,
|
|
||||||
autoFocus,
|
|
||||||
className,
|
|
||||||
}: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const pickerTheme = usePickerTheme();
|
|
||||||
const theme = useTheme();
|
|
||||||
const locale = useUserLocale(true) ?? "en";
|
|
||||||
|
|
||||||
const popover = usePopoverState({
|
|
||||||
placement: "bottom-start",
|
|
||||||
modal: true,
|
|
||||||
unstable_offset: [0, 0],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [emojisPerLine, setEmojisPerLine] = React.useState(9);
|
|
||||||
|
|
||||||
const pickerRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (popover.visible) {
|
|
||||||
onOpen?.();
|
|
||||||
} else {
|
|
||||||
onClose?.();
|
|
||||||
}
|
|
||||||
}, [popover.visible, onOpen, onClose]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (popover.visible && pickerRef.current) {
|
|
||||||
// 28 is picker's observed width when perLine is set to 0
|
|
||||||
// and 36 is the default emojiButtonSize
|
|
||||||
// Ref: https://github.com/missive/emoji-mart#options--props
|
|
||||||
setEmojisPerLine(Math.floor((pickerRef.current.clientWidth - 28) / 36));
|
|
||||||
}
|
|
||||||
}, [popover.visible]);
|
|
||||||
|
|
||||||
const handleEmojiChange = React.useCallback(
|
|
||||||
async (emoji) => {
|
|
||||||
popover.hide();
|
|
||||||
await onChange(emoji ? emoji.native : null);
|
|
||||||
},
|
|
||||||
[popover, onChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClick = React.useCallback(
|
|
||||||
(ev: React.MouseEvent) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
if (popover.visible) {
|
|
||||||
popover.hide();
|
|
||||||
} else {
|
|
||||||
popover.show();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[popover]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClickOutside = React.useCallback(() => {
|
|
||||||
// It was observed that onClickOutside got triggered
|
|
||||||
// even when the picker wasn't open or opened at all.
|
|
||||||
// Hence, this guard here...
|
|
||||||
if (popover.visible) {
|
|
||||||
onClickOutside();
|
|
||||||
}
|
|
||||||
}, [popover.visible, onClickOutside]);
|
|
||||||
|
|
||||||
// Auto focus search input when picker is opened
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
if (autoFocus && popover.visible) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const searchInput = pickerRef.current
|
|
||||||
?.querySelector("em-emoji-picker")
|
|
||||||
?.shadowRoot?.querySelector(
|
|
||||||
"input[type=search]"
|
|
||||||
) as HTMLInputElement | null;
|
|
||||||
searchInput?.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [autoFocus, popover.visible]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PopoverDisclosure {...popover}>
|
|
||||||
{(props) => (
|
|
||||||
<EmojiButton
|
|
||||||
{...props}
|
|
||||||
className={className}
|
|
||||||
onClick={handleClick}
|
|
||||||
icon={
|
|
||||||
value ? (
|
|
||||||
<Emoji size={32} align="center" justify="center">
|
|
||||||
{value}
|
|
||||||
</Emoji>
|
|
||||||
) : (
|
|
||||||
<StyledSmileyIcon size={32} color={theme.textTertiary} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
neutral
|
|
||||||
borderOnHover
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</PopoverDisclosure>
|
|
||||||
<PickerPopover
|
|
||||||
{...popover}
|
|
||||||
tabIndex={0}
|
|
||||||
// This prevents picker from closing when any of its
|
|
||||||
// children are focused, e.g, clicking on search bar or
|
|
||||||
// a click on skin tone button
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
width={352}
|
|
||||||
aria-label={t("Emoji Picker")}
|
|
||||||
>
|
|
||||||
{popover.visible && (
|
|
||||||
<>
|
|
||||||
{value && (
|
|
||||||
<RemoveButton neutral onClick={() => handleEmojiChange(null)}>
|
|
||||||
{t("Remove")}
|
|
||||||
</RemoveButton>
|
|
||||||
)}
|
|
||||||
<PickerStyles ref={pickerRef}>
|
|
||||||
<Picker
|
|
||||||
locale={supportedLocales.includes(locale) ? locale : "en"}
|
|
||||||
data={data}
|
|
||||||
onEmojiSelect={handleEmojiChange}
|
|
||||||
theme={pickerTheme}
|
|
||||||
previewPosition="none"
|
|
||||||
perLine={emojisPerLine}
|
|
||||||
onClickOutside={handleClickOutside}
|
|
||||||
/>
|
|
||||||
</PickerStyles>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</PickerPopover>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const StyledSmileyIcon = styled(SmileyIcon)`
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const RemoveButton = styled(Button)`
|
|
||||||
margin-left: -12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
height: 24px;
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
> :first-child {
|
|
||||||
min-height: unset;
|
|
||||||
line-height: unset;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PickerPopover = styled(Popover)`
|
|
||||||
z-index: ${depths.popover};
|
|
||||||
> :first-child {
|
|
||||||
padding-top: 8px;
|
|
||||||
padding-bottom: 0;
|
|
||||||
max-height: 488px;
|
|
||||||
overflow: unset;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PickerStyles = styled.div`
|
|
||||||
margin-left: -24px;
|
|
||||||
margin-right: -24px;
|
|
||||||
em-emoji-picker {
|
|
||||||
--shadow: none;
|
|
||||||
--font-family: ${s("fontFamily")};
|
|
||||||
--rgb-background: ${(props) => toRGB(props.theme.menuBackground)};
|
|
||||||
--rgb-accent: ${(props) => toRGB(props.theme.accent)};
|
|
||||||
--border-radius: 6px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
min-height: 443px;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default EmojiPicker;
|
|
||||||
93
app/components/Icon.tsx
Normal file
93
app/components/Icon.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { getLuminance } from "polished";
|
||||||
|
import * as React from "react";
|
||||||
|
import { randomElement } from "@shared/random";
|
||||||
|
import { IconType } from "@shared/types";
|
||||||
|
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||||
|
import { colorPalette } from "@shared/utils/collections";
|
||||||
|
import { determineIconType } from "@shared/utils/icon";
|
||||||
|
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
import Logger from "~/utils/Logger";
|
||||||
|
|
||||||
|
type IconProps = {
|
||||||
|
value: string;
|
||||||
|
color?: string;
|
||||||
|
size?: number;
|
||||||
|
initial?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = ({
|
||||||
|
value: icon,
|
||||||
|
color,
|
||||||
|
size = 24,
|
||||||
|
initial,
|
||||||
|
className,
|
||||||
|
}: IconProps) => {
|
||||||
|
const iconType = determineIconType(icon);
|
||||||
|
|
||||||
|
if (!iconType) {
|
||||||
|
Logger.warn("Failed to determine icon type", {
|
||||||
|
icon,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (iconType === IconType.Outline) {
|
||||||
|
return (
|
||||||
|
<OutlineIcon
|
||||||
|
value={icon}
|
||||||
|
color={color}
|
||||||
|
size={size}
|
||||||
|
initial={initial}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <EmojiIcon emoji={icon} size={size} className={className} />;
|
||||||
|
} catch (err) {
|
||||||
|
Logger.warn("Failed to render icon", {
|
||||||
|
icon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OutlineIconProps = {
|
||||||
|
value: string;
|
||||||
|
color?: string;
|
||||||
|
size?: number;
|
||||||
|
initial?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OutlineIcon = ({
|
||||||
|
value: icon,
|
||||||
|
color: inputColor,
|
||||||
|
initial,
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
}: OutlineIconProps) => {
|
||||||
|
const { ui } = useStores();
|
||||||
|
|
||||||
|
let color = inputColor ?? randomElement(colorPalette);
|
||||||
|
|
||||||
|
// If the chosen icon color is very dark then we invert it in dark mode
|
||||||
|
// otherwise it will be impossible to see against the dark background.
|
||||||
|
if (!inputColor && ui.resolvedTheme === "dark" && color !== "currentColor") {
|
||||||
|
color = getLuminance(color) > 0.09 ? color : "currentColor";
|
||||||
|
}
|
||||||
|
|
||||||
|
const Component = IconLibrary.getComponent(icon);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component color={color} size={size} className={className}>
|
||||||
|
{initial}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Icon;
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { PopoverDisclosure, usePopoverState } from "reakit";
|
|
||||||
import { MenuItem } from "reakit/Menu";
|
|
||||||
import styled, { useTheme } from "styled-components";
|
|
||||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
|
||||||
import { colorPalette } from "@shared/utils/collections";
|
|
||||||
import Flex from "~/components/Flex";
|
|
||||||
import NudeButton from "~/components/NudeButton";
|
|
||||||
import Text from "~/components/Text";
|
|
||||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
|
||||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
|
||||||
import DelayedMount from "./DelayedMount";
|
|
||||||
import InputSearch from "./InputSearch";
|
|
||||||
import Popover from "./Popover";
|
|
||||||
|
|
||||||
const icons = IconLibrary.mapping;
|
|
||||||
|
|
||||||
const TwitterPicker = lazyWithRetry(
|
|
||||||
() => import("react-color/lib/components/twitter/Twitter")
|
|
||||||
);
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onOpen?: () => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
onChange: (color: string, icon: string) => void;
|
|
||||||
initial: string;
|
|
||||||
icon: string;
|
|
||||||
color: string;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function IconPicker({
|
|
||||||
onOpen,
|
|
||||||
onClose,
|
|
||||||
icon,
|
|
||||||
initial,
|
|
||||||
color,
|
|
||||||
onChange,
|
|
||||||
className,
|
|
||||||
}: Props) {
|
|
||||||
const [query, setQuery] = React.useState("");
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const theme = useTheme();
|
|
||||||
const popover = usePopoverState({
|
|
||||||
gutter: 0,
|
|
||||||
placement: "right",
|
|
||||||
modal: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (popover.visible) {
|
|
||||||
onOpen?.();
|
|
||||||
} else {
|
|
||||||
onClose?.();
|
|
||||||
setQuery("");
|
|
||||||
}
|
|
||||||
}, [onOpen, onClose, popover.visible]);
|
|
||||||
|
|
||||||
const filteredIcons = IconLibrary.findIcons(query);
|
|
||||||
const handleFilter = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setQuery(event.target.value.toLowerCase());
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = React.useMemo(
|
|
||||||
() => ({
|
|
||||||
default: {
|
|
||||||
body: {
|
|
||||||
padding: 0,
|
|
||||||
marginRight: -8,
|
|
||||||
},
|
|
||||||
hash: {
|
|
||||||
color: theme.text,
|
|
||||||
background: theme.inputBorder,
|
|
||||||
},
|
|
||||||
swatch: {
|
|
||||||
cursor: "var(--cursor-pointer)",
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
color: theme.text,
|
|
||||||
boxShadow: `inset 0 0 0 1px ${theme.inputBorder}`,
|
|
||||||
background: "transparent",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[theme]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
|
|
||||||
// prevent event bubbling.
|
|
||||||
useOnClickOutside(
|
|
||||||
popover.unstable_popoverRef,
|
|
||||||
(event) => {
|
|
||||||
if (popover.visible) {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
popover.hide();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ capture: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const iconNames = Object.keys(icons);
|
|
||||||
const delayPerIcon = 250 / iconNames.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PopoverDisclosure {...popover}>
|
|
||||||
{(props) => (
|
|
||||||
<NudeButton
|
|
||||||
aria-label={t("Show menu")}
|
|
||||||
className={className}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
as={IconLibrary.getComponent(icon || "collection")}
|
|
||||||
color={color}
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</Icon>
|
|
||||||
</NudeButton>
|
|
||||||
)}
|
|
||||||
</PopoverDisclosure>
|
|
||||||
<Popover
|
|
||||||
{...popover}
|
|
||||||
width={552}
|
|
||||||
aria-label={t("Choose an icon")}
|
|
||||||
hideOnClickOutside={false}
|
|
||||||
>
|
|
||||||
<Flex column gap={12}>
|
|
||||||
<Text size="large" weight="xbold">
|
|
||||||
{t("Choose an icon")}
|
|
||||||
</Text>
|
|
||||||
<InputSearch
|
|
||||||
value={query}
|
|
||||||
placeholder={`${t("Filter")}…`}
|
|
||||||
onChange={handleFilter}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
{iconNames.map((name, index) => (
|
|
||||||
<MenuItem key={name} onClick={() => onChange(color, name)}>
|
|
||||||
{(props) => (
|
|
||||||
<IconButton
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
opacity: query
|
|
||||||
? filteredIcons.includes(name)
|
|
||||||
? 1
|
|
||||||
: 0.3
|
|
||||||
: undefined,
|
|
||||||
"--delay": `${Math.round(index * delayPerIcon)}ms`,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
as={IconLibrary.getComponent(name)}
|
|
||||||
color={color}
|
|
||||||
size={30}
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</Icon>
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Flex>
|
|
||||||
<React.Suspense
|
|
||||||
fallback={
|
|
||||||
<DelayedMount>
|
|
||||||
<Text>{t("Loading")}…</Text>
|
|
||||||
</DelayedMount>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ColorPicker
|
|
||||||
color={color}
|
|
||||||
onChange={(color) => onChange(color.hex, icon)}
|
|
||||||
colors={colorPalette}
|
|
||||||
triangle="hide"
|
|
||||||
styles={styles}
|
|
||||||
/>
|
|
||||||
</React.Suspense>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Popover>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Icon = styled.svg`
|
|
||||||
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
|
|
||||||
transition-delay: var(--delay);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const IconButton = styled(NudeButton)`
|
|
||||||
vertical-align: top;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 0px 6px 6px 0px;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ColorPicker = styled(TwitterPicker)`
|
|
||||||
box-shadow: none !important;
|
|
||||||
background: transparent !important;
|
|
||||||
width: 100% !important;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default IconPicker;
|
|
||||||
218
app/components/IconPicker/components/ColorPicker.tsx
Normal file
218
app/components/IconPicker/components/ColorPicker.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { BackIcon } from "outline-icons";
|
||||||
|
import React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { breakpoints, s } from "@shared/styles";
|
||||||
|
import { colorPalette } from "@shared/utils/collections";
|
||||||
|
import { validateColorHex } from "@shared/utils/color";
|
||||||
|
import Flex from "~/components/Flex";
|
||||||
|
import NudeButton from "~/components/NudeButton";
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import { hover } from "~/styles";
|
||||||
|
|
||||||
|
enum Panel {
|
||||||
|
Builtin,
|
||||||
|
Hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
width: number;
|
||||||
|
activeColor: string;
|
||||||
|
onSelect: (color: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ColorPicker = ({ width, activeColor, onSelect }: Props) => {
|
||||||
|
const [localValue, setLocalValue] = React.useState(activeColor);
|
||||||
|
|
||||||
|
const [panel, setPanel] = React.useState(
|
||||||
|
colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSwitcherClick = React.useCallback(() => {
|
||||||
|
setPanel(panel === Panel.Builtin ? Panel.Hex : Panel.Builtin);
|
||||||
|
}, [panel, setPanel]);
|
||||||
|
|
||||||
|
const isLargeMobile = width > breakpoints.mobileLarge + 12; // 12px for the Container padding
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setLocalValue(activeColor);
|
||||||
|
setPanel(colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex);
|
||||||
|
}, [activeColor]);
|
||||||
|
|
||||||
|
return isLargeMobile ? (
|
||||||
|
<Container justify="space-between">
|
||||||
|
<LargeMobileBuiltinColors activeColor={activeColor} onClick={onSelect} />
|
||||||
|
<LargeMobileCustomColor
|
||||||
|
value={localValue}
|
||||||
|
setLocalValue={setLocalValue}
|
||||||
|
onValidHex={onSelect}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
) : (
|
||||||
|
<Container gap={12}>
|
||||||
|
<PanelSwitcher align="center">
|
||||||
|
<SwitcherButton panel={panel} onClick={handleSwitcherClick}>
|
||||||
|
{panel === Panel.Builtin ? "#" : <BackIcon />}
|
||||||
|
</SwitcherButton>
|
||||||
|
</PanelSwitcher>
|
||||||
|
{panel === Panel.Builtin ? (
|
||||||
|
<BuiltinColors activeColor={activeColor} onClick={onSelect} />
|
||||||
|
) : (
|
||||||
|
<CustomColor
|
||||||
|
value={localValue}
|
||||||
|
setLocalValue={setLocalValue}
|
||||||
|
onValidHex={onSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BuiltinColors = ({
|
||||||
|
activeColor,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
activeColor: string;
|
||||||
|
onClick: (color: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<Flex className={className} justify="space-between" align="center" auto>
|
||||||
|
{colorPalette.map((color) => (
|
||||||
|
<ColorButton
|
||||||
|
key={color}
|
||||||
|
color={color}
|
||||||
|
active={color === activeColor}
|
||||||
|
onClick={() => onClick(color)}
|
||||||
|
>
|
||||||
|
<Selected />
|
||||||
|
</ColorButton>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CustomColor = ({
|
||||||
|
value,
|
||||||
|
setLocalValue,
|
||||||
|
onValidHex,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
setLocalValue: (value: string) => void;
|
||||||
|
onValidHex: (color: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const hasHexChars = React.useCallback(
|
||||||
|
(color: string) => /(^#[0-9A-F]{1,6}$)/i.test(color),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputChange = React.useCallback(
|
||||||
|
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = ev.target.value;
|
||||||
|
|
||||||
|
if (val === "" || val === "#") {
|
||||||
|
setLocalValue("#");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uppercasedVal = val.toUpperCase();
|
||||||
|
|
||||||
|
if (hasHexChars(uppercasedVal)) {
|
||||||
|
setLocalValue(uppercasedVal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validateColorHex(uppercasedVal)) {
|
||||||
|
onValidHex(uppercasedVal);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setLocalValue, hasHexChars, onValidHex]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex className={className} align="center" gap={8}>
|
||||||
|
<Text type="tertiary" size="small">
|
||||||
|
HEX
|
||||||
|
</Text>
|
||||||
|
<CustomColorInput
|
||||||
|
maxLength={7}
|
||||||
|
value={value}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Container = styled(Flex)`
|
||||||
|
height: 48px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid ${s("inputBorder")};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Selected = styled.span`
|
||||||
|
width: 8px;
|
||||||
|
height: 4px;
|
||||||
|
border-left: 1px solid white;
|
||||||
|
border-bottom: 1px solid white;
|
||||||
|
transform: translateY(-25%) rotate(-45deg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ColorButton = styled(NudeButton)<{ color: string; active: boolean }>`
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: ${({ color }) => color};
|
||||||
|
|
||||||
|
&: ${hover} {
|
||||||
|
outline: 2px solid ${s("menuBackground")} !important;
|
||||||
|
box-shadow: ${({ color }) => `0px 0px 3px 3px ${color}`};
|
||||||
|
}
|
||||||
|
|
||||||
|
& ${Selected} {
|
||||||
|
display: ${({ active }) => (active ? "block" : "none")};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PanelSwitcher = styled(Flex)`
|
||||||
|
width: 40px;
|
||||||
|
border-right: 1px solid ${s("inputBorder")};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SwitcherButton = styled(NudeButton)<{ panel: Panel }>`
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid ${s("inputBorder")};
|
||||||
|
transition: all 100ms ease-in-out;
|
||||||
|
|
||||||
|
&: ${hover} {
|
||||||
|
border-color: ${s("inputBorderFocused")};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LargeMobileBuiltinColors = styled(BuiltinColors)`
|
||||||
|
max-width: 380px;
|
||||||
|
padding-right: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LargeMobileCustomColor = styled(CustomColor)`
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid ${s("inputBorder")};
|
||||||
|
width: 120px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CustomColorInput = styled.input.attrs(() => ({
|
||||||
|
type: "text",
|
||||||
|
autocomplete: "off",
|
||||||
|
}))`
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${s("textSecondary")};
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default ColorPicker;
|
||||||
8
app/components/IconPicker/components/Emoji.tsx
Normal file
8
app/components/IconPicker/components/Emoji.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
import { s } from "@shared/styles";
|
||||||
|
|
||||||
|
export const Emoji = styled.span`
|
||||||
|
font-family: ${s("fontFamilyEmoji")};
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
`;
|
||||||
245
app/components/IconPicker/components/EmojiPanel.tsx
Normal file
245
app/components/IconPicker/components/EmojiPanel.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import concat from "lodash/concat";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
|
||||||
|
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
|
||||||
|
import Flex from "~/components/Flex";
|
||||||
|
import InputSearch from "~/components/InputSearch";
|
||||||
|
import usePersistedState from "~/hooks/usePersistedState";
|
||||||
|
import {
|
||||||
|
FREQUENTLY_USED_COUNT,
|
||||||
|
DisplayCategory,
|
||||||
|
emojiSkinToneKey,
|
||||||
|
emojisFreqKey,
|
||||||
|
lastEmojiKey,
|
||||||
|
sortFrequencies,
|
||||||
|
} from "../utils";
|
||||||
|
import GridTemplate, { DataNode } from "./GridTemplate";
|
||||||
|
import SkinTonePicker from "./SkinTonePicker";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is needed as a constant for react-window.
|
||||||
|
* Calculated from the heights of TabPanel and InputSearch.
|
||||||
|
*/
|
||||||
|
const GRID_HEIGHT = 362;
|
||||||
|
|
||||||
|
const useEmojiState = () => {
|
||||||
|
const [emojiSkinTone, setEmojiSkinTone] = usePersistedState<EmojiSkinTone>(
|
||||||
|
emojiSkinToneKey,
|
||||||
|
EmojiSkinTone.Default
|
||||||
|
);
|
||||||
|
const [emojisFreq, setEmojisFreq] = usePersistedState<Record<string, number>>(
|
||||||
|
emojisFreqKey,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const [lastEmoji, setLastEmoji] = usePersistedState<string | undefined>(
|
||||||
|
lastEmojiKey,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const incrementEmojiCount = React.useCallback(
|
||||||
|
(emoji: string) => {
|
||||||
|
emojisFreq[emoji] = (emojisFreq[emoji] ?? 0) + 1;
|
||||||
|
setEmojisFreq({ ...emojisFreq });
|
||||||
|
setLastEmoji(emoji);
|
||||||
|
},
|
||||||
|
[emojisFreq, setEmojisFreq, setLastEmoji]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getFreqEmojis = React.useCallback(() => {
|
||||||
|
const freqs = Object.entries(emojisFreq);
|
||||||
|
|
||||||
|
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
|
||||||
|
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
|
||||||
|
setEmojisFreq(Object.fromEntries(freqs));
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis = sortFrequencies(freqs)
|
||||||
|
.slice(0, FREQUENTLY_USED_COUNT.Get)
|
||||||
|
.map(([emoji, _]) => emoji);
|
||||||
|
|
||||||
|
const isLastPresent = emojis.includes(lastEmoji ?? "");
|
||||||
|
if (lastEmoji && !isLastPresent) {
|
||||||
|
emojis.pop();
|
||||||
|
emojis.push(lastEmoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
return emojis;
|
||||||
|
}, [emojisFreq, setEmojisFreq, lastEmoji]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
emojiSkinTone,
|
||||||
|
setEmojiSkinTone,
|
||||||
|
incrementEmojiCount,
|
||||||
|
getFreqEmojis,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
panelWidth: number;
|
||||||
|
query: string;
|
||||||
|
panelActive: boolean;
|
||||||
|
onEmojiChange: (emoji: string) => void;
|
||||||
|
onQueryChange: (query: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmojiPanel = ({
|
||||||
|
panelWidth,
|
||||||
|
query,
|
||||||
|
panelActive,
|
||||||
|
onEmojiChange,
|
||||||
|
onQueryChange,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const searchRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
emojiSkinTone: skinTone,
|
||||||
|
setEmojiSkinTone,
|
||||||
|
incrementEmojiCount,
|
||||||
|
getFreqEmojis,
|
||||||
|
} = useEmojiState();
|
||||||
|
|
||||||
|
const freqEmojis = React.useMemo(() => getFreqEmojis(), [getFreqEmojis]);
|
||||||
|
|
||||||
|
const handleFilter = React.useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onQueryChange(event.target.value);
|
||||||
|
},
|
||||||
|
[onQueryChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSkinChange = React.useCallback(
|
||||||
|
(emojiSkinTone: EmojiSkinTone) => {
|
||||||
|
setEmojiSkinTone(emojiSkinTone);
|
||||||
|
},
|
||||||
|
[setEmojiSkinTone]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEmojiSelection = React.useCallback(
|
||||||
|
({ id, value }: { id: string; value: string }) => {
|
||||||
|
onEmojiChange(value);
|
||||||
|
incrementEmojiCount(id);
|
||||||
|
},
|
||||||
|
[onEmojiChange, incrementEmojiCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSearch = query !== "";
|
||||||
|
const templateData: DataNode[] = isSearch
|
||||||
|
? getSearchResults({
|
||||||
|
query,
|
||||||
|
skinTone,
|
||||||
|
})
|
||||||
|
: getAllEmojis({
|
||||||
|
skinTone,
|
||||||
|
freqEmojis,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (scrollableRef.current) {
|
||||||
|
scrollableRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
searchRef.current?.focus();
|
||||||
|
}, [panelActive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex column>
|
||||||
|
<UserInputContainer align="center" gap={12}>
|
||||||
|
<StyledInputSearch
|
||||||
|
ref={searchRef}
|
||||||
|
value={query}
|
||||||
|
placeholder={`${t("Search emoji")}…`}
|
||||||
|
onChange={handleFilter}
|
||||||
|
/>
|
||||||
|
<SkinTonePicker skinTone={skinTone} onChange={handleSkinChange} />
|
||||||
|
</UserInputContainer>
|
||||||
|
<GridTemplate
|
||||||
|
ref={scrollableRef}
|
||||||
|
width={panelWidth}
|
||||||
|
height={GRID_HEIGHT}
|
||||||
|
data={templateData}
|
||||||
|
onIconSelect={handleEmojiSelection}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSearchResults = ({
|
||||||
|
query,
|
||||||
|
skinTone,
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
skinTone: EmojiSkinTone;
|
||||||
|
}): DataNode[] => {
|
||||||
|
const emojis = search({ query, skinTone });
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
category: DisplayCategory.Search,
|
||||||
|
icons: emojis.map((emoji) => ({
|
||||||
|
type: IconType.Emoji,
|
||||||
|
id: emoji.id,
|
||||||
|
value: emoji.value,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllEmojis = ({
|
||||||
|
skinTone,
|
||||||
|
freqEmojis,
|
||||||
|
}: {
|
||||||
|
skinTone: EmojiSkinTone;
|
||||||
|
freqEmojis: string[];
|
||||||
|
}): DataNode[] => {
|
||||||
|
const emojisWithCategory = getEmojisWithCategory({ skinTone });
|
||||||
|
|
||||||
|
const getFrequentEmojis = (): DataNode => {
|
||||||
|
const emojis = getEmojis({ ids: freqEmojis, skinTone });
|
||||||
|
return {
|
||||||
|
category: DisplayCategory.Frequent,
|
||||||
|
icons: emojis.map((emoji) => ({
|
||||||
|
type: IconType.Emoji,
|
||||||
|
id: emoji.id,
|
||||||
|
value: emoji.value,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryData = (emojiCategory: EmojiCategory): DataNode => {
|
||||||
|
const emojis = emojisWithCategory[emojiCategory] ?? [];
|
||||||
|
return {
|
||||||
|
category: emojiCategory,
|
||||||
|
icons: emojis.map((emoji) => ({
|
||||||
|
type: IconType.Emoji,
|
||||||
|
id: emoji.id,
|
||||||
|
value: emoji.value,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return concat(
|
||||||
|
getFrequentEmojis(),
|
||||||
|
getCategoryData(EmojiCategory.People),
|
||||||
|
getCategoryData(EmojiCategory.Nature),
|
||||||
|
getCategoryData(EmojiCategory.Foods),
|
||||||
|
getCategoryData(EmojiCategory.Activity),
|
||||||
|
getCategoryData(EmojiCategory.Places),
|
||||||
|
getCategoryData(EmojiCategory.Objects),
|
||||||
|
getCategoryData(EmojiCategory.Symbols),
|
||||||
|
getCategoryData(EmojiCategory.Flags)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserInputContainer = styled(Flex)`
|
||||||
|
height: 48px;
|
||||||
|
padding: 6px 12px 0px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledInputSearch = styled(InputSearch)`
|
||||||
|
flex-grow: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default EmojiPanel;
|
||||||
61
app/components/IconPicker/components/Grid.tsx
Normal file
61
app/components/IconPicker/components/Grid.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FixedSizeList, ListChildComponentProps } from "react-window";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
data: React.ReactNode[][];
|
||||||
|
columns: number;
|
||||||
|
itemWidth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Grid = (
|
||||||
|
{ width, height, data, columns, itemWidth }: Props,
|
||||||
|
ref: React.Ref<HTMLDivElement>
|
||||||
|
) => (
|
||||||
|
<Container
|
||||||
|
outerRef={ref}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
itemCount={data.length}
|
||||||
|
itemSize={itemWidth}
|
||||||
|
itemData={{ data, columns }}
|
||||||
|
>
|
||||||
|
{Row}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
|
||||||
|
type RowProps = {
|
||||||
|
data: React.ReactNode[][];
|
||||||
|
columns: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Row = ({ index, style, data }: ListChildComponentProps<RowProps>) => {
|
||||||
|
const { data: rows, columns } = data;
|
||||||
|
const row = rows[index];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RowContainer style={style} columns={columns}>
|
||||||
|
{row}
|
||||||
|
</RowContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Container = styled(FixedSizeList<RowProps>)`
|
||||||
|
padding: 0px 12px;
|
||||||
|
|
||||||
|
// Needed for the absolutely positioned children
|
||||||
|
// to respect the VirtualList's padding
|
||||||
|
& > div {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const RowContainer = styled.div<{ columns: number }>`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: ${({ columns }) => `repeat(${columns}, 1fr)`};
|
||||||
|
align-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default React.forwardRef(Grid);
|
||||||
120
app/components/IconPicker/components/GridTemplate.tsx
Normal file
120
app/components/IconPicker/components/GridTemplate.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import chunk from "lodash/chunk";
|
||||||
|
import compact from "lodash/compact";
|
||||||
|
import React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { IconType } from "@shared/types";
|
||||||
|
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import { TRANSLATED_CATEGORIES } from "../utils";
|
||||||
|
import { Emoji } from "./Emoji";
|
||||||
|
import Grid from "./Grid";
|
||||||
|
import { IconButton } from "./IconButton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* icon/emoji size is 24px; and we add 4px padding on all sides,
|
||||||
|
*/
|
||||||
|
const BUTTON_SIZE = 32;
|
||||||
|
|
||||||
|
type OutlineNode = {
|
||||||
|
type: IconType.Outline;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
initial: string;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmojiNode = {
|
||||||
|
type: IconType.Emoji;
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataNode = {
|
||||||
|
category: keyof typeof TRANSLATED_CATEGORIES;
|
||||||
|
icons: (OutlineNode | EmojiNode)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
data: DataNode[];
|
||||||
|
onIconSelect: ({ id, value }: { id: string; value: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GridTemplate = (
|
||||||
|
{ width, height, data, onIconSelect }: Props,
|
||||||
|
ref: React.Ref<HTMLDivElement>
|
||||||
|
) => {
|
||||||
|
// 24px padding for the Grid Container
|
||||||
|
const itemsPerRow = Math.floor((width - 24) / BUTTON_SIZE);
|
||||||
|
|
||||||
|
const gridItems = compact(
|
||||||
|
data.flatMap((node) => {
|
||||||
|
if (node.icons.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = (
|
||||||
|
<CategoryName
|
||||||
|
key={node.category}
|
||||||
|
type="tertiary"
|
||||||
|
size="xsmall"
|
||||||
|
weight="bold"
|
||||||
|
>
|
||||||
|
{TRANSLATED_CATEGORIES[node.category]}
|
||||||
|
</CategoryName>
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = node.icons.map((item) => {
|
||||||
|
if (item.type === IconType.Outline) {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
key={item.name}
|
||||||
|
onClick={() => onIconSelect({ id: item.name, value: item.name })}
|
||||||
|
delay={item.delay}
|
||||||
|
>
|
||||||
|
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
|
||||||
|
{item.initial}
|
||||||
|
</Icon>
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onIconSelect({ id: item.id, value: item.value })}
|
||||||
|
>
|
||||||
|
<Emoji>{item.value}</Emoji>
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunks = chunk(items, itemsPerRow);
|
||||||
|
return [[category], ...chunks];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
ref={ref}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
data={gridItems}
|
||||||
|
columns={itemsPerRow}
|
||||||
|
itemWidth={BUTTON_SIZE}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CategoryName = styled(Text)`
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding-left: 6px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Icon = styled.svg`
|
||||||
|
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
|
||||||
|
transition-delay: var(--delay);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default React.forwardRef(GridTemplate);
|
||||||
15
app/components/IconPicker/components/IconButton.tsx
Normal file
15
app/components/IconPicker/components/IconButton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
import { s } from "@shared/styles";
|
||||||
|
import NudeButton from "~/components/NudeButton";
|
||||||
|
import { hover } from "~/styles";
|
||||||
|
|
||||||
|
export const IconButton = styled(NudeButton)<{ delay?: number }>`
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 4px;
|
||||||
|
--delay: ${({ delay }) => delay && `${delay}ms`};
|
||||||
|
|
||||||
|
&: ${hover} {
|
||||||
|
background: ${s("listItemHoverBackground")};
|
||||||
|
}
|
||||||
|
`;
|
||||||
200
app/components/IconPicker/components/IconPanel.tsx
Normal file
200
app/components/IconPicker/components/IconPanel.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { IconType } from "@shared/types";
|
||||||
|
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||||
|
import Flex from "~/components/Flex";
|
||||||
|
import InputSearch from "~/components/InputSearch";
|
||||||
|
import usePersistedState from "~/hooks/usePersistedState";
|
||||||
|
import {
|
||||||
|
FREQUENTLY_USED_COUNT,
|
||||||
|
DisplayCategory,
|
||||||
|
iconsFreqKey,
|
||||||
|
lastIconKey,
|
||||||
|
sortFrequencies,
|
||||||
|
} from "../utils";
|
||||||
|
import ColorPicker from "./ColorPicker";
|
||||||
|
import GridTemplate, { DataNode } from "./GridTemplate";
|
||||||
|
|
||||||
|
const IconNames = Object.keys(IconLibrary.mapping);
|
||||||
|
const TotalIcons = IconNames.length;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is needed as a constant for react-window.
|
||||||
|
* Calculated from the heights of TabPanel, ColorPicker and InputSearch.
|
||||||
|
*/
|
||||||
|
const GRID_HEIGHT = 314;
|
||||||
|
|
||||||
|
const useIconState = () => {
|
||||||
|
const [iconsFreq, setIconsFreq] = usePersistedState<Record<string, number>>(
|
||||||
|
iconsFreqKey,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const [lastIcon, setLastIcon] = usePersistedState<string | undefined>(
|
||||||
|
lastIconKey,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const incrementIconCount = React.useCallback(
|
||||||
|
(icon: string) => {
|
||||||
|
iconsFreq[icon] = (iconsFreq[icon] ?? 0) + 1;
|
||||||
|
setIconsFreq({ ...iconsFreq });
|
||||||
|
setLastIcon(icon);
|
||||||
|
},
|
||||||
|
[iconsFreq, setIconsFreq, setLastIcon]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getFreqIcons = React.useCallback(() => {
|
||||||
|
const freqs = Object.entries(iconsFreq);
|
||||||
|
|
||||||
|
if (freqs.length > FREQUENTLY_USED_COUNT.Track) {
|
||||||
|
sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track);
|
||||||
|
setIconsFreq(Object.fromEntries(freqs));
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = sortFrequencies(freqs)
|
||||||
|
.slice(0, FREQUENTLY_USED_COUNT.Get)
|
||||||
|
.map(([icon, _]) => icon);
|
||||||
|
|
||||||
|
const isLastPresent = icons.includes(lastIcon ?? "");
|
||||||
|
if (lastIcon && !isLastPresent) {
|
||||||
|
icons.pop();
|
||||||
|
icons.push(lastIcon);
|
||||||
|
}
|
||||||
|
|
||||||
|
return icons;
|
||||||
|
}, [iconsFreq, setIconsFreq, lastIcon]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
incrementIconCount,
|
||||||
|
getFreqIcons,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
panelWidth: number;
|
||||||
|
initial: string;
|
||||||
|
color: string;
|
||||||
|
query: string;
|
||||||
|
panelActive: boolean;
|
||||||
|
onIconChange: (icon: string) => void;
|
||||||
|
onColorChange: (icon: string) => void;
|
||||||
|
onQueryChange: (query: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const IconPanel = ({
|
||||||
|
panelWidth,
|
||||||
|
initial,
|
||||||
|
color,
|
||||||
|
query,
|
||||||
|
panelActive,
|
||||||
|
onIconChange,
|
||||||
|
onColorChange,
|
||||||
|
onQueryChange,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const searchRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const { incrementIconCount, getFreqIcons } = useIconState();
|
||||||
|
|
||||||
|
const freqIcons = React.useMemo(() => getFreqIcons(), [getFreqIcons]);
|
||||||
|
const totalFreqIcons = freqIcons.length;
|
||||||
|
|
||||||
|
const filteredIcons = React.useMemo(
|
||||||
|
() => IconLibrary.findIcons(query),
|
||||||
|
[query]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSearch = query !== "";
|
||||||
|
const category = isSearch ? DisplayCategory.Search : DisplayCategory.All;
|
||||||
|
const delayPerIcon = 250 / (TotalIcons + totalFreqIcons);
|
||||||
|
|
||||||
|
const handleFilter = React.useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onQueryChange(event.target.value);
|
||||||
|
},
|
||||||
|
[onQueryChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleIconSelection = React.useCallback(
|
||||||
|
({ id, value }: { id: string; value: string }) => {
|
||||||
|
onIconChange(value);
|
||||||
|
incrementIconCount(id);
|
||||||
|
},
|
||||||
|
[onIconChange, incrementIconCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseIcons: DataNode = {
|
||||||
|
category,
|
||||||
|
icons: filteredIcons.map((name, index) => ({
|
||||||
|
type: IconType.Outline,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
initial,
|
||||||
|
delay: Math.round((index + totalFreqIcons) * delayPerIcon),
|
||||||
|
onClick: handleIconSelection,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const templateData: DataNode[] = isSearch
|
||||||
|
? [baseIcons]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
category: DisplayCategory.Frequent,
|
||||||
|
icons: freqIcons.map((name, index) => ({
|
||||||
|
type: IconType.Outline,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
initial,
|
||||||
|
delay: Math.round((index + totalFreqIcons) * delayPerIcon),
|
||||||
|
onClick: handleIconSelection,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
baseIcons,
|
||||||
|
];
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (scrollableRef.current) {
|
||||||
|
scrollableRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
searchRef.current?.focus();
|
||||||
|
}, [panelActive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex column>
|
||||||
|
<InputSearchContainer align="center">
|
||||||
|
<StyledInputSearch
|
||||||
|
ref={searchRef}
|
||||||
|
value={query}
|
||||||
|
placeholder={`${t("Search icons")}…`}
|
||||||
|
onChange={handleFilter}
|
||||||
|
/>
|
||||||
|
</InputSearchContainer>
|
||||||
|
<ColorPicker
|
||||||
|
width={panelWidth}
|
||||||
|
activeColor={color}
|
||||||
|
onSelect={onColorChange}
|
||||||
|
/>
|
||||||
|
<GridTemplate
|
||||||
|
ref={scrollableRef}
|
||||||
|
width={panelWidth}
|
||||||
|
height={GRID_HEIGHT}
|
||||||
|
data={templateData}
|
||||||
|
onIconSelect={handleIconSelection}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InputSearchContainer = styled(Flex)`
|
||||||
|
height: 48px;
|
||||||
|
padding: 6px 12px 0px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledInputSearch = styled(InputSearch)`
|
||||||
|
flex-grow: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default IconPanel;
|
||||||
20
app/components/IconPicker/components/PopoverButton.tsx
Normal file
20
app/components/IconPicker/components/PopoverButton.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import styled, { css } from "styled-components";
|
||||||
|
import { s } from "@shared/styles";
|
||||||
|
import NudeButton from "~/components/NudeButton";
|
||||||
|
import { hover } from "~/styles";
|
||||||
|
|
||||||
|
export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
|
||||||
|
&: ${hover},
|
||||||
|
&:active,
|
||||||
|
&[aria-expanded= "true"] {
|
||||||
|
opacity: 1 !important;
|
||||||
|
|
||||||
|
${({ $borderOnHover }) =>
|
||||||
|
$borderOnHover &&
|
||||||
|
css`
|
||||||
|
background: ${s("buttonNeutralBackground")};
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px,
|
||||||
|
${s("buttonNeutralBorder")} 0 0 0 1px inset;
|
||||||
|
`};
|
||||||
|
}
|
||||||
|
`;
|
||||||
92
app/components/IconPicker/components/SkinTonePicker.tsx
Normal file
92
app/components/IconPicker/components/SkinTonePicker.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { depths, s } from "@shared/styles";
|
||||||
|
import { EmojiSkinTone } from "@shared/types";
|
||||||
|
import { getEmojiVariants } from "@shared/utils/emoji";
|
||||||
|
import Flex from "~/components/Flex";
|
||||||
|
import NudeButton from "~/components/NudeButton";
|
||||||
|
import { hover } from "~/styles";
|
||||||
|
import { Emoji } from "./Emoji";
|
||||||
|
import { IconButton } from "./IconButton";
|
||||||
|
|
||||||
|
const SkinTonePicker = ({
|
||||||
|
skinTone,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
skinTone: EmojiSkinTone;
|
||||||
|
onChange: (skin: EmojiSkinTone) => void;
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handEmojiVariants = React.useMemo(
|
||||||
|
() => getEmojiVariants({ id: "hand" }),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const menu = useMenuState({
|
||||||
|
placement: "bottom",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSkinClick = React.useCallback(
|
||||||
|
(emojiSkin) => {
|
||||||
|
menu.hide();
|
||||||
|
onChange(emojiSkin);
|
||||||
|
},
|
||||||
|
[menu, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const menuItems = React.useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(handEmojiVariants).map(([eskin, emoji]) => (
|
||||||
|
<MenuItem {...menu} key={emoji.value}>
|
||||||
|
{(menuprops) => (
|
||||||
|
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
|
||||||
|
<Emoji>{emoji.value}</Emoji>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
)),
|
||||||
|
[menu, handEmojiVariants, handleSkinClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuButton {...menu}>
|
||||||
|
{(props) => (
|
||||||
|
<StyledMenuButton
|
||||||
|
{...props}
|
||||||
|
aria-label={t("Choose default skin tone")}
|
||||||
|
>
|
||||||
|
{handEmojiVariants[skinTone]!.value}
|
||||||
|
</StyledMenuButton>
|
||||||
|
)}
|
||||||
|
</MenuButton>
|
||||||
|
<Menu {...menu} aria-label={t("Choose default skin tone")}>
|
||||||
|
{(props) => <MenuContainer {...props}>{menuItems}</MenuContainer>}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MenuContainer = styled(Flex)`
|
||||||
|
z-index: ${depths.menu};
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: ${s("menuBackground")};
|
||||||
|
box-shadow: ${s("menuShadow")};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledMenuButton = styled(NudeButton)`
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid ${s("inputBorder")};
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
&: ${hover} {
|
||||||
|
border: 1px solid ${s("inputBorderFocused")};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default SkinTonePicker;
|
||||||
315
app/components/IconPicker/index.tsx
Normal file
315
app/components/IconPicker/index.tsx
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import { SmileyIcon } from "outline-icons";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
PopoverDisclosure,
|
||||||
|
Tab,
|
||||||
|
TabList,
|
||||||
|
TabPanel,
|
||||||
|
usePopoverState,
|
||||||
|
useTabState,
|
||||||
|
} from "reakit";
|
||||||
|
import styled, { css } from "styled-components";
|
||||||
|
import { s } from "@shared/styles";
|
||||||
|
import theme from "@shared/styles/theme";
|
||||||
|
import { IconType } from "@shared/types";
|
||||||
|
import { determineIconType } from "@shared/utils/icon";
|
||||||
|
import Flex from "~/components/Flex";
|
||||||
|
import Icon from "~/components/Icon";
|
||||||
|
import NudeButton from "~/components/NudeButton";
|
||||||
|
import Popover from "~/components/Popover";
|
||||||
|
import useMobile from "~/hooks/useMobile";
|
||||||
|
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||||
|
import useWindowSize from "~/hooks/useWindowSize";
|
||||||
|
import { hover } from "~/styles";
|
||||||
|
import EmojiPanel from "./components/EmojiPanel";
|
||||||
|
import IconPanel from "./components/IconPanel";
|
||||||
|
import { PopoverButton } from "./components/PopoverButton";
|
||||||
|
|
||||||
|
const TAB_NAMES = {
|
||||||
|
Icon: "icon",
|
||||||
|
Emoji: "emoji",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const POPOVER_WIDTH = 408;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
icon: string | null;
|
||||||
|
color: string;
|
||||||
|
size?: number;
|
||||||
|
initial?: string;
|
||||||
|
className?: string;
|
||||||
|
popoverPosition: "bottom-start" | "right";
|
||||||
|
allowDelete?: boolean;
|
||||||
|
borderOnHover?: boolean;
|
||||||
|
onChange: (icon: string | null, color: string | null) => void;
|
||||||
|
onOpen?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const IconPicker = ({
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
size = 24,
|
||||||
|
initial,
|
||||||
|
className,
|
||||||
|
popoverPosition,
|
||||||
|
allowDelete,
|
||||||
|
onChange,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
borderOnHover,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { width: windowWidth } = useWindowSize();
|
||||||
|
const isMobile = useMobile();
|
||||||
|
|
||||||
|
const [query, setQuery] = React.useState("");
|
||||||
|
const [chosenColor, setChosenColor] = React.useState(color);
|
||||||
|
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const iconType = determineIconType(icon);
|
||||||
|
const defaultTab = React.useMemo(
|
||||||
|
() =>
|
||||||
|
iconType === IconType.Emoji ? TAB_NAMES["Emoji"] : TAB_NAMES["Icon"],
|
||||||
|
[iconType]
|
||||||
|
);
|
||||||
|
|
||||||
|
const popover = usePopoverState({
|
||||||
|
placement: popoverPosition,
|
||||||
|
modal: true,
|
||||||
|
unstable_offset: [0, 0],
|
||||||
|
});
|
||||||
|
const tab = useTabState({ selectedId: defaultTab });
|
||||||
|
|
||||||
|
const popoverWidth = isMobile ? windowWidth : POPOVER_WIDTH;
|
||||||
|
// In mobile, popover is absolutely positioned to leave 8px on both sides.
|
||||||
|
const panelWidth = isMobile ? windowWidth - 16 : popoverWidth;
|
||||||
|
|
||||||
|
const resetDefaultTab = React.useCallback(() => {
|
||||||
|
tab.select(defaultTab);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [defaultTab]);
|
||||||
|
|
||||||
|
const handleIconChange = React.useCallback(
|
||||||
|
(ic: string) => {
|
||||||
|
popover.hide();
|
||||||
|
const icType = determineIconType(ic);
|
||||||
|
const finalColor = icType === IconType.Outline ? chosenColor : null;
|
||||||
|
onChange(ic, finalColor);
|
||||||
|
},
|
||||||
|
[popover, onChange, chosenColor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleIconColorChange = React.useCallback(
|
||||||
|
(c: string) => {
|
||||||
|
setChosenColor(c);
|
||||||
|
|
||||||
|
const icType = determineIconType(icon);
|
||||||
|
// Outline icon set; propagate color change
|
||||||
|
if (icType === IconType.Outline) {
|
||||||
|
onChange(icon, c);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[icon, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleIconRemove = React.useCallback(() => {
|
||||||
|
popover.hide();
|
||||||
|
onChange(null, null);
|
||||||
|
}, [popover, onChange]);
|
||||||
|
|
||||||
|
const handleQueryChange = React.useCallback(
|
||||||
|
(q: string) => setQuery(q),
|
||||||
|
[setQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePopoverButtonClick = React.useCallback(
|
||||||
|
(ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (popover.visible) {
|
||||||
|
popover.hide();
|
||||||
|
} else {
|
||||||
|
popover.show();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[popover]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Popover open effect
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (popover.visible) {
|
||||||
|
onOpen?.();
|
||||||
|
} else {
|
||||||
|
onClose?.();
|
||||||
|
setQuery("");
|
||||||
|
resetDefaultTab();
|
||||||
|
}
|
||||||
|
}, [popover.visible, onOpen, onClose, setQuery, resetDefaultTab]);
|
||||||
|
|
||||||
|
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
|
||||||
|
// prevent event bubbling.
|
||||||
|
useOnClickOutside(
|
||||||
|
contentRef,
|
||||||
|
(event) => {
|
||||||
|
if (
|
||||||
|
popover.visible &&
|
||||||
|
!popover.unstable_disclosureRef.current?.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
popover.hide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ capture: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PopoverDisclosure {...popover}>
|
||||||
|
{(props) => (
|
||||||
|
<PopoverButton
|
||||||
|
{...props}
|
||||||
|
aria-label={t("Show menu")}
|
||||||
|
className={className}
|
||||||
|
size={size}
|
||||||
|
onClick={handlePopoverButtonClick}
|
||||||
|
$borderOnHover={borderOnHover}
|
||||||
|
>
|
||||||
|
{iconType && icon ? (
|
||||||
|
<Icon value={icon} color={color} size={size} initial={initial} />
|
||||||
|
) : (
|
||||||
|
<StyledSmileyIcon color={theme.textTertiary} size={size} />
|
||||||
|
)}
|
||||||
|
</PopoverButton>
|
||||||
|
)}
|
||||||
|
</PopoverDisclosure>
|
||||||
|
<Popover
|
||||||
|
{...popover}
|
||||||
|
ref={contentRef}
|
||||||
|
width={popoverWidth}
|
||||||
|
shrink
|
||||||
|
aria-label={t("Icon Picker")}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
hideOnClickOutside={false}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<TabActionsWrapper justify="space-between" align="center">
|
||||||
|
<TabList {...tab}>
|
||||||
|
<StyledTab
|
||||||
|
{...tab}
|
||||||
|
id={TAB_NAMES["Icon"]}
|
||||||
|
aria-label={t("Icons")}
|
||||||
|
active={tab.selectedId === TAB_NAMES["Icon"]}
|
||||||
|
>
|
||||||
|
{t("Icons")}
|
||||||
|
</StyledTab>
|
||||||
|
<StyledTab
|
||||||
|
{...tab}
|
||||||
|
id={TAB_NAMES["Emoji"]}
|
||||||
|
aria-label={t("Emojis")}
|
||||||
|
active={tab.selectedId === TAB_NAMES["Emoji"]}
|
||||||
|
>
|
||||||
|
{t("Emojis")}
|
||||||
|
</StyledTab>
|
||||||
|
</TabList>
|
||||||
|
{allowDelete && icon && (
|
||||||
|
<RemoveButton onClick={handleIconRemove}>
|
||||||
|
{t("Remove")}
|
||||||
|
</RemoveButton>
|
||||||
|
)}
|
||||||
|
</TabActionsWrapper>
|
||||||
|
<StyledTabPanel {...tab}>
|
||||||
|
<IconPanel
|
||||||
|
panelWidth={panelWidth}
|
||||||
|
initial={initial ?? "?"}
|
||||||
|
color={chosenColor}
|
||||||
|
query={query}
|
||||||
|
panelActive={
|
||||||
|
popover.visible && tab.selectedId === TAB_NAMES["Icon"]
|
||||||
|
}
|
||||||
|
onIconChange={handleIconChange}
|
||||||
|
onColorChange={handleIconColorChange}
|
||||||
|
onQueryChange={handleQueryChange}
|
||||||
|
/>
|
||||||
|
</StyledTabPanel>
|
||||||
|
<StyledTabPanel {...tab}>
|
||||||
|
<EmojiPanel
|
||||||
|
panelWidth={panelWidth}
|
||||||
|
query={query}
|
||||||
|
panelActive={
|
||||||
|
popover.visible && tab.selectedId === TAB_NAMES["Emoji"]
|
||||||
|
}
|
||||||
|
onEmojiChange={handleIconChange}
|
||||||
|
onQueryChange={handleQueryChange}
|
||||||
|
/>
|
||||||
|
</StyledTabPanel>
|
||||||
|
</>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StyledSmileyIcon = styled(SmileyIcon)`
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const RemoveButton = styled(NudeButton)`
|
||||||
|
width: auto;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${s("textTertiary")};
|
||||||
|
padding: 8px 12px;
|
||||||
|
transition: color 100ms ease-in-out;
|
||||||
|
&: ${hover} {
|
||||||
|
color: ${s("textSecondary")};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TabActionsWrapper = styled(Flex)`
|
||||||
|
padding-left: 12px;
|
||||||
|
border-bottom: 1px solid ${s("inputBorder")};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledTab = styled(Tab)<{ active: boolean }>`
|
||||||
|
position: relative;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: var(--pointer);
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
user-select: none;
|
||||||
|
color: ${({ active }) => (active ? s("textSecondary") : s("textTertiary"))};
|
||||||
|
transition: color 100ms ease-in-out;
|
||||||
|
|
||||||
|
&: ${hover} {
|
||||||
|
color: ${s("textSecondary")};
|
||||||
|
}
|
||||||
|
|
||||||
|
${({ active }) =>
|
||||||
|
active &&
|
||||||
|
css`
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: ${s("textSecondary")};
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledTabPanel = styled(TabPanel)`
|
||||||
|
height: 410px;
|
||||||
|
overflow-y: auto;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default IconPicker;
|
||||||
50
app/components/IconPicker/utils.ts
Normal file
50
app/components/IconPicker/utils.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import i18next from "i18next";
|
||||||
|
|
||||||
|
export enum DisplayCategory {
|
||||||
|
All = "All",
|
||||||
|
Frequent = "Frequent",
|
||||||
|
Search = "Search",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRANSLATED_CATEGORIES = {
|
||||||
|
All: i18next.t("All"),
|
||||||
|
Frequent: i18next.t("Frequently Used"),
|
||||||
|
Search: i18next.t("Search Results"),
|
||||||
|
People: i18next.t("Smileys & People"),
|
||||||
|
Nature: i18next.t("Animals & Nature"),
|
||||||
|
Foods: i18next.t("Food & Drink"),
|
||||||
|
Activity: i18next.t("Activity"),
|
||||||
|
Places: i18next.t("Travel & Places"),
|
||||||
|
Objects: i18next.t("Objects"),
|
||||||
|
Symbols: i18next.t("Symbols"),
|
||||||
|
Flags: i18next.t("Flags"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FREQUENTLY_USED_COUNT = {
|
||||||
|
Get: 24,
|
||||||
|
Track: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
Base: "icon-state",
|
||||||
|
EmojiSkinTone: "emoji-skintone",
|
||||||
|
IconsFrequency: "icons-freq",
|
||||||
|
EmojisFrequency: "emojis-freq",
|
||||||
|
LastIcon: "last-icon",
|
||||||
|
LastEmoji: "last-emoji",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStorageKey = (key: string) => `${STORAGE_KEYS.Base}.${key}`;
|
||||||
|
|
||||||
|
export const emojiSkinToneKey = getStorageKey(STORAGE_KEYS.EmojiSkinTone);
|
||||||
|
|
||||||
|
export const iconsFreqKey = getStorageKey(STORAGE_KEYS.IconsFrequency);
|
||||||
|
|
||||||
|
export const emojisFreqKey = getStorageKey(STORAGE_KEYS.EmojisFrequency);
|
||||||
|
|
||||||
|
export const lastIconKey = getStorageKey(STORAGE_KEYS.LastIcon);
|
||||||
|
|
||||||
|
export const lastEmojiKey = getStorageKey(STORAGE_KEYS.LastEmoji);
|
||||||
|
|
||||||
|
export const sortFrequencies = (freqs: [string, number][]) =>
|
||||||
|
freqs.sort((a, b) => (a[1] >= b[1] ? -1 : 1));
|
||||||
@@ -2,10 +2,11 @@ import { observer } from "mobx-react";
|
|||||||
import { CollectionIcon } from "outline-icons";
|
import { CollectionIcon } from "outline-icons";
|
||||||
import { getLuminance } from "polished";
|
import { getLuminance } from "polished";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
import { randomElement } from "@shared/random";
|
||||||
|
import { colorPalette } from "@shared/utils/collections";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
|
import Icon from "~/components/Icon";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import Logger from "~/utils/Logger";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The collection to show an icon for */
|
/** The collection to show an icon for */
|
||||||
@@ -16,6 +17,7 @@ type Props = {
|
|||||||
size?: number;
|
size?: number;
|
||||||
/** The color of the icon, defaults to the collection color */
|
/** The color of the icon, defaults to the collection color */
|
||||||
color?: string;
|
color?: string;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ResolvedCollectionIcon({
|
function ResolvedCollectionIcon({
|
||||||
@@ -23,35 +25,41 @@ function ResolvedCollectionIcon({
|
|||||||
color: inputColor,
|
color: inputColor,
|
||||||
expanded,
|
expanded,
|
||||||
size,
|
size,
|
||||||
|
className,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { ui } = useStores();
|
const { ui } = useStores();
|
||||||
|
|
||||||
// If the chosen icon color is very dark then we invert it in dark mode
|
if (!collection.icon || collection.icon === "collection") {
|
||||||
// otherwise it will be impossible to see against the dark background.
|
// If the chosen icon color is very dark then we invert it in dark mode
|
||||||
const color =
|
// otherwise it will be impossible to see against the dark background.
|
||||||
inputColor ||
|
const collectionColor = collection.color ?? randomElement(colorPalette);
|
||||||
(ui.resolvedTheme === "dark" && collection.color !== "currentColor"
|
const color =
|
||||||
? getLuminance(collection.color) > 0.09
|
inputColor ||
|
||||||
? collection.color
|
(ui.resolvedTheme === "dark" && collectionColor !== "currentColor"
|
||||||
: "currentColor"
|
? getLuminance(collectionColor) > 0.09
|
||||||
: collection.color);
|
? collectionColor
|
||||||
|
: "currentColor"
|
||||||
|
: collectionColor);
|
||||||
|
|
||||||
if (collection.icon && collection.icon !== "collection") {
|
return (
|
||||||
try {
|
<CollectionIcon
|
||||||
const Component = IconLibrary.getComponent(collection.icon);
|
color={color}
|
||||||
return (
|
expanded={expanded}
|
||||||
<Component color={color} size={size}>
|
size={size}
|
||||||
{collection.initial}
|
className={className}
|
||||||
</Component>
|
/>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
|
||||||
Logger.warn("Failed to render custom icon", {
|
|
||||||
icon: collection.icon,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CollectionIcon color={color} expanded={expanded} size={size} />;
|
return (
|
||||||
|
<Icon
|
||||||
|
value={collection.icon}
|
||||||
|
color={inputColor ?? collection.color ?? undefined}
|
||||||
|
size={size}
|
||||||
|
initial={collection.initial}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default observer(ResolvedCollectionIcon);
|
export default observer(ResolvedCollectionIcon);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { s } from "@shared/styles";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The emoji to render */
|
/** The emoji to render */
|
||||||
emoji: string;
|
emoji: string;
|
||||||
/** The size of the emoji, 24px is default to match standard icons */
|
/** The size of the emoji, 24px is default to match standard icons */
|
||||||
size?: number;
|
size?: number;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,19 +17,28 @@ type Props = {
|
|||||||
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
|
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
|
||||||
return (
|
return (
|
||||||
<Span $size={size} {...rest}>
|
<Span $size={size} {...rest}>
|
||||||
{emoji}
|
<SVG size={size} emoji={emoji} />
|
||||||
</Span>
|
</Span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Span = styled.span<{ $size: number }>`
|
const Span = styled.span<{ $size: number }>`
|
||||||
display: inline-flex;
|
font-family: ${s("fontFamilyEmoji")};
|
||||||
align-items: center;
|
display: inline-block;
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: ${(props) => props.$size}px;
|
width: ${(props) => props.$size}px;
|
||||||
height: ${(props) => props.$size}px;
|
height: ${(props) => props.$size}px;
|
||||||
text-indent: -0.15em;
|
|
||||||
font-size: ${(props) => props.$size - 10}px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const SVG = ({ size, emoji }: { size: number; emoji: string }) => (
|
||||||
|
<svg width={size} height={size} xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<text
|
||||||
|
x="50%"
|
||||||
|
y={"55%"}
|
||||||
|
dominantBaseline="middle"
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={size * 0.7}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|||||||
@@ -20,15 +20,18 @@ type Props = PopoverProps & {
|
|||||||
hide: () => void;
|
hide: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Popover: React.FC<Props> = ({
|
const Popover = (
|
||||||
children,
|
{
|
||||||
shrink,
|
children,
|
||||||
width = 380,
|
shrink,
|
||||||
scrollable = true,
|
width = 380,
|
||||||
flex,
|
scrollable = true,
|
||||||
mobilePosition,
|
flex,
|
||||||
...rest
|
mobilePosition,
|
||||||
}: Props) => {
|
...rest
|
||||||
|
}: Props,
|
||||||
|
ref: React.Ref<HTMLDivElement>
|
||||||
|
) => {
|
||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
|
|
||||||
// Custom Escape handler rather than using hideOnEsc from reakit so we can
|
// Custom Escape handler rather than using hideOnEsc from reakit so we can
|
||||||
@@ -50,6 +53,7 @@ const Popover: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog {...rest} modal>
|
<Dialog {...rest} modal>
|
||||||
<Contents
|
<Contents
|
||||||
|
ref={ref}
|
||||||
$shrink={shrink}
|
$shrink={shrink}
|
||||||
$scrollable={scrollable}
|
$scrollable={scrollable}
|
||||||
$flex={flex}
|
$flex={flex}
|
||||||
@@ -64,6 +68,7 @@ const Popover: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<StyledPopover {...rest} hideOnEsc={false} hideOnClickOutside>
|
<StyledPopover {...rest} hideOnEsc={false} hideOnClickOutside>
|
||||||
<Contents
|
<Contents
|
||||||
|
ref={ref}
|
||||||
$shrink={shrink}
|
$shrink={shrink}
|
||||||
$width={width}
|
$width={width}
|
||||||
$scrollable={scrollable}
|
$scrollable={scrollable}
|
||||||
@@ -123,4 +128,4 @@ const Contents = styled.div<ContentsProps>`
|
|||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default Popover;
|
export default React.forwardRef(Popover);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import * as React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "styled-components";
|
import { useTheme } from "styled-components";
|
||||||
import Squircle from "@shared/components/Squircle";
|
import Squircle from "@shared/components/Squircle";
|
||||||
import { CollectionPermission } from "@shared/types";
|
import { CollectionPermission, IconType } from "@shared/types";
|
||||||
|
import { determineIconType } from "@shared/utils/icon";
|
||||||
import type Collection from "~/models/Collection";
|
import type Collection from "~/models/Collection";
|
||||||
import type Document from "~/models/Document";
|
import type Document from "~/models/Document";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
@@ -54,15 +55,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
|
|||||||
/>
|
/>
|
||||||
) : usersInCollection ? (
|
) : usersInCollection ? (
|
||||||
<ListItem
|
<ListItem
|
||||||
image={
|
image={<CollectionSquircle collection={collection} />}
|
||||||
<Squircle color={collection.color} size={AvatarSize.Medium}>
|
|
||||||
<CollectionIcon
|
|
||||||
collection={collection}
|
|
||||||
color={theme.white}
|
|
||||||
size={16}
|
|
||||||
/>
|
|
||||||
</Squircle>
|
|
||||||
}
|
|
||||||
title={collection.name}
|
title={collection.name}
|
||||||
subtitle={t("Everyone in the collection")}
|
subtitle={t("Everyone in the collection")}
|
||||||
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
|
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
|
||||||
@@ -136,6 +129,24 @@ const AccessTooltip = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CollectionSquircle = ({ collection }: { collection: Collection }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const iconType = determineIconType(collection.icon)!;
|
||||||
|
const squircleColor =
|
||||||
|
iconType === IconType.Outline ? collection.color! : theme.slateLight;
|
||||||
|
const iconSize = iconType === IconType.Outline ? 16 : 22;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Squircle color={squircleColor} size={AvatarSize.Medium}>
|
||||||
|
<CollectionIcon
|
||||||
|
collection={collection}
|
||||||
|
color={theme.white}
|
||||||
|
size={iconSize}
|
||||||
|
/>
|
||||||
|
</Squircle>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
function useUsersInCollection(collection?: Collection) {
|
function useUsersInCollection(collection?: Collection) {
|
||||||
const { users, memberships } = useStores();
|
const { users, memberships } = useStores();
|
||||||
const { request } = useRequest(() =>
|
const { request } = useRequest(() =>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { DocumentValidation } from "@shared/validations";
|
|||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Fade from "~/components/Fade";
|
import Fade from "~/components/Fade";
|
||||||
|
import Icon from "~/components/Icon";
|
||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
import Tooltip from "~/components/Tooltip";
|
import Tooltip from "~/components/Tooltip";
|
||||||
import useBoolean from "~/hooks/useBoolean";
|
import useBoolean from "~/hooks/useBoolean";
|
||||||
@@ -282,6 +283,8 @@ function InnerDocumentLink(
|
|||||||
const title =
|
const title =
|
||||||
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
|
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
|
||||||
t("Untitled");
|
t("Untitled");
|
||||||
|
const icon = document?.icon || node.icon;
|
||||||
|
const color = document?.color || node.color;
|
||||||
|
|
||||||
const isExpanded = expanded && !isDragging;
|
const isExpanded = expanded && !isDragging;
|
||||||
const hasChildren = nodeChildren.length > 0;
|
const hasChildren = nodeChildren.length > 0;
|
||||||
@@ -324,7 +327,7 @@ function InnerDocumentLink(
|
|||||||
starred: inStarredSection,
|
starred: inStarredSection,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
emoji={document?.emoji || node.emoji}
|
icon={icon && <Icon value={icon} color={color} />}
|
||||||
label={
|
label={
|
||||||
<EditableTitle
|
<EditableTitle
|
||||||
title={title}
|
title={title}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { NavigationNode } from "@shared/types";
|
import { NavigationNode } from "@shared/types";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
|
import Icon from "~/components/Icon";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||||
import { descendants } from "~/utils/tree";
|
import { descendants } from "~/utils/tree";
|
||||||
@@ -111,7 +112,7 @@ function DocumentLink(
|
|||||||
}}
|
}}
|
||||||
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
|
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
|
||||||
onDisclosureClick={handleDisclosureClick}
|
onDisclosureClick={handleDisclosureClick}
|
||||||
emoji={node.emoji}
|
icon={node.icon && <Icon value={node.icon} color={node.color} />}
|
||||||
label={title}
|
label={title}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
exact={false}
|
exact={false}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import fractionalIndex from "fractional-index";
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { NotificationEventType } from "@shared/types";
|
import { IconType, NotificationEventType } from "@shared/types";
|
||||||
|
import { determineIconType } from "@shared/utils/icon";
|
||||||
import UserMembership from "~/models/UserMembership";
|
import UserMembership from "~/models/UserMembership";
|
||||||
import Fade from "~/components/Fade";
|
import Fade from "~/components/Fade";
|
||||||
import useBoolean from "~/hooks/useBoolean";
|
import useBoolean from "~/hooks/useBoolean";
|
||||||
@@ -78,10 +79,11 @@ function SharedWithMeLink({ userMembership }: Props) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { emoji } = document;
|
const { icon: docIcon } = document;
|
||||||
const label = emoji
|
const label =
|
||||||
? document.title.replace(emoji, "")
|
determineIconType(docIcon) === IconType.Emoji
|
||||||
: document.titleWithDefault;
|
? document.title.replace(docIcon!, "")
|
||||||
|
: document.titleWithDefault;
|
||||||
const collection = document.collectionId
|
const collection = document.collectionId
|
||||||
? collections.get(document.collectionId)
|
? collections.get(document.collectionId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import breakpoint from "styled-components-breakpoint";
|
|||||||
import EventBoundary from "@shared/components/EventBoundary";
|
import EventBoundary from "@shared/components/EventBoundary";
|
||||||
import { s } from "@shared/styles";
|
import { s } from "@shared/styles";
|
||||||
import { NavigationNode } from "@shared/types";
|
import { NavigationNode } from "@shared/types";
|
||||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
|
||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
import { UnreadBadge } from "~/components/UnreadBadge";
|
import { UnreadBadge } from "~/components/UnreadBadge";
|
||||||
import useUnmount from "~/hooks/useUnmount";
|
import useUnmount from "~/hooks/useUnmount";
|
||||||
@@ -27,7 +26,6 @@ type Props = Omit<NavLinkProps, "to"> & {
|
|||||||
onClickIntent?: () => void;
|
onClickIntent?: () => void;
|
||||||
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
|
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
emoji?: string | null;
|
|
||||||
label?: React.ReactNode;
|
label?: React.ReactNode;
|
||||||
menu?: React.ReactNode;
|
menu?: React.ReactNode;
|
||||||
unreadBadge?: boolean;
|
unreadBadge?: boolean;
|
||||||
@@ -52,7 +50,6 @@ function SidebarLink(
|
|||||||
onClick,
|
onClick,
|
||||||
onClickIntent,
|
onClickIntent,
|
||||||
to,
|
to,
|
||||||
emoji,
|
|
||||||
label,
|
label,
|
||||||
active,
|
active,
|
||||||
isActiveDrop,
|
isActiveDrop,
|
||||||
@@ -142,7 +139,6 @@ function SidebarLink(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||||
{emoji && <EmojiIcon emoji={emoji} />}
|
|
||||||
<Label>{label}</Label>
|
<Label>{label}</Label>
|
||||||
{unreadBadge && <UnreadBadge />}
|
{unreadBadge && <UnreadBadge />}
|
||||||
</Content>
|
</Content>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DocumentIcon } from "outline-icons";
|
import { DocumentIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import Icon from "~/components/Icon";
|
||||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
|
||||||
interface SidebarItem {
|
interface SidebarItem {
|
||||||
@@ -21,7 +21,11 @@ export function useSidebarLabelAndIcon(
|
|||||||
if (document) {
|
if (document) {
|
||||||
return {
|
return {
|
||||||
label: document.titleWithDefault,
|
label: document.titleWithDefault,
|
||||||
icon: document.emoji ? <EmojiIcon emoji={document.emoji} /> : icon,
|
icon: document.icon ? (
|
||||||
|
<Icon value={document.icon} color={document.color ?? undefined} />
|
||||||
|
) : (
|
||||||
|
icon
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import data, { type Emoji as TEmoji } from "@emoji-mart/data";
|
|
||||||
import { init, Data } from "emoji-mart";
|
|
||||||
import FuzzySearch from "fuzzy-search";
|
|
||||||
import capitalize from "lodash/capitalize";
|
import capitalize from "lodash/capitalize";
|
||||||
import sortBy from "lodash/sortBy";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
|
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
|
||||||
import { isMac } from "@shared/utils/browser";
|
import { search as emojiSearch } from "@shared/utils/emoji";
|
||||||
import EmojiMenuItem from "./EmojiMenuItem";
|
import EmojiMenuItem from "./EmojiMenuItem";
|
||||||
import SuggestionsMenu, {
|
import SuggestionsMenu, {
|
||||||
Props as SuggestionsMenuProps,
|
Props as SuggestionsMenuProps,
|
||||||
@@ -19,13 +15,6 @@ type Emoji = {
|
|||||||
attrs: { markup: string; "data-name": string };
|
attrs: { markup: string; "data-name": string };
|
||||||
};
|
};
|
||||||
|
|
||||||
init({
|
|
||||||
data,
|
|
||||||
noCountryFlags: isMac() ? false : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
let searcher: FuzzySearch<TEmoji>;
|
|
||||||
|
|
||||||
type Props = Omit<
|
type Props = Omit<
|
||||||
SuggestionsMenuProps<Emoji>,
|
SuggestionsMenuProps<Emoji>,
|
||||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||||
@@ -34,36 +23,26 @@ type Props = Omit<
|
|||||||
const EmojiMenu = (props: Props) => {
|
const EmojiMenu = (props: Props) => {
|
||||||
const { search = "" } = props;
|
const { search = "" } = props;
|
||||||
|
|
||||||
if (!searcher) {
|
const items = React.useMemo(
|
||||||
searcher = new FuzzySearch(Object.values(Data.emojis), ["search"], {
|
() =>
|
||||||
caseSensitive: false,
|
emojiSearch({ query: search })
|
||||||
sort: true,
|
.map((item) => {
|
||||||
});
|
// We snake_case the shortcode for backwards compatability with gemoji to
|
||||||
}
|
// avoid multiple formats being written into documents.
|
||||||
|
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
|
||||||
|
const emoji = item.value;
|
||||||
|
|
||||||
const items = React.useMemo(() => {
|
return {
|
||||||
const n = search.toLowerCase();
|
name: "emoji",
|
||||||
|
title: emoji,
|
||||||
return sortBy(searcher.search(n), (item) => {
|
description: capitalize(item.name.toLowerCase()),
|
||||||
const nlc = item.name.toLowerCase();
|
emoji,
|
||||||
return nlc === n ? -1 : nlc.startsWith(n) ? 0 : 1;
|
attrs: { markup: shortcode, "data-name": shortcode },
|
||||||
})
|
};
|
||||||
.map((item) => {
|
})
|
||||||
// We snake_case the shortcode for backwards compatability with gemoji to
|
.slice(0, 15),
|
||||||
// avoid multiple formats being written into documents.
|
[search]
|
||||||
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
|
);
|
||||||
const emoji = item.skins[0].native;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: "emoji",
|
|
||||||
title: emoji,
|
|
||||||
description: capitalize(item.name.toLowerCase()),
|
|
||||||
emoji,
|
|
||||||
attrs: { markup: shortcode, "data-name": shortcode },
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.slice(0, 15);
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SuggestionsMenu
|
<SuggestionsMenu
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import isMarkdown from "@shared/editor/lib/isMarkdown";
|
|||||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||||
import { isInList } from "@shared/editor/queries/isInList";
|
import { isInList } from "@shared/editor/queries/isInList";
|
||||||
|
import { IconType } from "@shared/types";
|
||||||
|
import { determineIconType } from "@shared/utils/icon";
|
||||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||||
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
|
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||||
import stores from "~/stores";
|
import stores from "~/stores";
|
||||||
@@ -179,9 +181,12 @@ export default class PasteHandler extends Extension {
|
|||||||
if (document) {
|
if (document) {
|
||||||
const { hash } = new URL(text);
|
const { hash } = new URL(text);
|
||||||
|
|
||||||
const title = `${
|
const hasEmoji =
|
||||||
document.emoji ? document.emoji + " " : ""
|
determineIconType(document.icon) === IconType.Emoji;
|
||||||
}${document.titleWithDefault}`;
|
|
||||||
|
const title = `${hasEmoji ? document.icon + " " : ""}${
|
||||||
|
document.titleWithDefault
|
||||||
|
}`;
|
||||||
insertLink(`${document.path}${hash}`, title);
|
insertLink(`${document.path}${hash}`, title);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
import Icon from "~/components/Icon";
|
||||||
import { createAction } from "~/actions";
|
import { createAction } from "~/actions";
|
||||||
import { DocumentSection } from "~/actions/sections";
|
import { DocumentSection } from "~/actions/sections";
|
||||||
import history from "~/utils/history";
|
import history from "~/utils/history";
|
||||||
@@ -21,8 +21,8 @@ const useTemplatesActions = () => {
|
|||||||
name: item.titleWithDefault,
|
name: item.titleWithDefault,
|
||||||
analyticsName: "New document",
|
analyticsName: "New document",
|
||||||
section: DocumentSection,
|
section: DocumentSection,
|
||||||
icon: item.emoji ? (
|
icon: item.icon ? (
|
||||||
<EmojiIcon emoji={item.emoji} />
|
<Icon value={item.icon} color={item.color ?? undefined} />
|
||||||
) : (
|
) : (
|
||||||
<NewDocumentIcon />
|
<NewDocumentIcon />
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Button from "~/components/Button";
|
|||||||
import ContextMenu from "~/components/ContextMenu";
|
import ContextMenu from "~/components/ContextMenu";
|
||||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||||
import Separator from "~/components/ContextMenu/Separator";
|
import Separator from "~/components/ContextMenu/Separator";
|
||||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
import Icon from "~/components/Icon";
|
||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { replaceTitleVariables } from "~/utils/date";
|
import { replaceTitleVariables } from "~/utils/date";
|
||||||
@@ -43,7 +43,11 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
|
|||||||
key={template.id}
|
key={template.id}
|
||||||
onClick={() => onSelectTemplate(template)}
|
onClick={() => onSelectTemplate(template)}
|
||||||
icon={
|
icon={
|
||||||
template.emoji ? <EmojiIcon emoji={template.emoji} /> : <DocumentIcon />
|
template.icon ? (
|
||||||
|
<Icon value={template.icon} color={template.color ?? undefined} />
|
||||||
|
) : (
|
||||||
|
<DocumentIcon />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
{...menu}
|
{...menu}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -40,18 +40,18 @@ export default class Collection extends ParanoidModel {
|
|||||||
data: ProsemirrorData;
|
data: ProsemirrorData;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An emoji to use as the collection icon.
|
* An icon (or) emoji to use as the collection icon.
|
||||||
*/
|
*/
|
||||||
@Field
|
@Field
|
||||||
@observable
|
@observable
|
||||||
icon: string;
|
icon: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A color to use for the collection icon and other highlights.
|
* The color to use for the collection icon and other highlights.
|
||||||
*/
|
*/
|
||||||
@Field
|
@Field
|
||||||
@observable
|
@observable
|
||||||
color: string;
|
color?: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default permission for workspace users.
|
* The default permission for workspace users.
|
||||||
|
|||||||
@@ -129,11 +129,18 @@ export default class Document extends ParanoidModel {
|
|||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An emoji to use as the document icon.
|
* An icon (or) emoji to use as the document icon.
|
||||||
*/
|
*/
|
||||||
@Field
|
@Field
|
||||||
@observable
|
@observable
|
||||||
emoji: string | undefined | null;
|
icon?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The color to use for the document icon.
|
||||||
|
*/
|
||||||
|
@Field
|
||||||
|
@observable
|
||||||
|
color?: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this is a template.
|
* Whether this is a template.
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ class Revision extends Model {
|
|||||||
/** Prosemirror data of the content when revision was created */
|
/** Prosemirror data of the content when revision was created */
|
||||||
data: ProsemirrorData;
|
data: ProsemirrorData;
|
||||||
|
|
||||||
/** The emoji of the document when the revision was created */
|
/** The icon (or) emoji of the document when the revision was created */
|
||||||
emoji: string | null;
|
icon: string | null;
|
||||||
|
|
||||||
|
/** The color of the document icon when the revision was created */
|
||||||
|
color: string | null;
|
||||||
|
|
||||||
/** HTML string representing the revision as a diff from the previous version */
|
/** HTML string representing the revision as a diff from the previous version */
|
||||||
html: string;
|
html: string;
|
||||||
|
|||||||
@@ -306,8 +306,9 @@ const HeadingWithIcon = styled(Heading)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const HeadingIcon = styled(CollectionIcon)`
|
const HeadingIcon = styled(CollectionIcon)`
|
||||||
align-self: flex-start;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-left: -8px;
|
||||||
|
margin-right: 8px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default observer(CollectionScene);
|
export default observer(CollectionScene);
|
||||||
|
|||||||
@@ -19,9 +19,15 @@ import styled from "styled-components";
|
|||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||||
import { s } from "@shared/styles";
|
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 { ProsemirrorHelper, Heading } from "@shared/utils/ProsemirrorHelper";
|
||||||
import { parseDomain } from "@shared/utils/domains";
|
import { parseDomain } from "@shared/utils/domains";
|
||||||
|
import { determineIconType } from "@shared/utils/icon";
|
||||||
import RootStore from "~/stores/RootStore";
|
import RootStore from "~/stores/RootStore";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Revision from "~/models/Revision";
|
import Revision from "~/models/Revision";
|
||||||
@@ -169,8 +175,11 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
this.title = title;
|
this.title = title;
|
||||||
this.props.document.title = title;
|
this.props.document.title = title;
|
||||||
}
|
}
|
||||||
if (template.emoji) {
|
if (template.icon) {
|
||||||
this.props.document.emoji = template.emoji;
|
this.props.document.icon = template.icon;
|
||||||
|
}
|
||||||
|
if (template.color) {
|
||||||
|
this.props.document.color = template.color;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.props.document.data = cloneDeep(template.data);
|
this.props.document.data = cloneDeep(template.data);
|
||||||
@@ -383,8 +392,9 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
void this.autosave();
|
void this.autosave();
|
||||||
});
|
});
|
||||||
|
|
||||||
handleChangeEmoji = action((value: string) => {
|
handleChangeIcon = action((icon: string | null, color: string | null) => {
|
||||||
this.props.document.emoji = value;
|
this.props.document.icon = icon;
|
||||||
|
this.props.document.color = color;
|
||||||
void this.onSave();
|
void this.onSave();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -425,6 +435,12 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
? this.props.match.url
|
? this.props.match.url
|
||||||
: updateDocumentPath(this.props.match.url, document);
|
: 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 (
|
return (
|
||||||
<ErrorBoundary showTitle>
|
<ErrorBoundary showTitle>
|
||||||
{this.props.location.pathname !== canonicalUrl && (
|
{this.props.location.pathname !== canonicalUrl && (
|
||||||
@@ -459,10 +475,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
column
|
column
|
||||||
auto
|
auto
|
||||||
>
|
>
|
||||||
<PageTitle
|
<PageTitle title={title} favicon={favicon} />
|
||||||
title={document.titleWithDefault.replace(document.emoji || "", "")}
|
|
||||||
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
|
|
||||||
/>
|
|
||||||
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
|
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
|
||||||
<Container column>
|
<Container column>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
@@ -542,7 +555,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
onSearchLink={this.props.onSearchLink}
|
onSearchLink={this.props.onSearchLink}
|
||||||
onCreateLink={this.props.onCreateLink}
|
onCreateLink={this.props.onCreateLink}
|
||||||
onChangeTitle={this.handleChangeTitle}
|
onChangeTitle={this.handleChangeTitle}
|
||||||
onChangeEmoji={this.handleChangeEmoji}
|
onChangeIcon={this.handleChangeIcon}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
onHeadingsChange={this.onHeadingsChange}
|
onHeadingsChange={this.onHeadingsChange}
|
||||||
onSave={this.onSave}
|
onSave={this.onSave}
|
||||||
|
|||||||
@@ -18,29 +18,32 @@ import {
|
|||||||
import { DocumentValidation } from "@shared/validations";
|
import { DocumentValidation } from "@shared/validations";
|
||||||
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
|
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
|
||||||
import { useDocumentContext } from "~/components/DocumentContext";
|
import { useDocumentContext } from "~/components/DocumentContext";
|
||||||
import { Emoji, EmojiButton } from "~/components/EmojiPicker/components";
|
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
|
import Icon from "~/components/Icon";
|
||||||
|
import { PopoverButton } from "~/components/IconPicker/components/PopoverButton";
|
||||||
import useBoolean from "~/hooks/useBoolean";
|
import useBoolean from "~/hooks/useBoolean";
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import { isModKey } from "~/utils/keyboard";
|
import { isModKey } from "~/utils/keyboard";
|
||||||
|
|
||||||
const EmojiPicker = React.lazy(() => import("~/components/EmojiPicker"));
|
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** ID of the associated document */
|
/** ID of the associated document */
|
||||||
documentId: string;
|
documentId: string;
|
||||||
/** Title to display */
|
/** Title to display */
|
||||||
title: string;
|
title: string;
|
||||||
/** Emoji to display */
|
/** Icon to display */
|
||||||
emoji?: string | null;
|
icon?: string | null;
|
||||||
|
/** Icon color */
|
||||||
|
color: string;
|
||||||
/** Placeholder to display when the document has no title */
|
/** Placeholder to display when the document has no title */
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/** Should the title be editable, policies will also be considered separately */
|
/** Should the title be editable, policies will also be considered separately */
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
/** Callback called on any edits to text */
|
/** Callback called on any edits to text */
|
||||||
onChangeTitle?: (text: string) => void;
|
onChangeTitle?: (text: string) => void;
|
||||||
/** Callback called when the user selects an emoji */
|
/** Callback called when the user selects an icon */
|
||||||
onChangeEmoji?: (emoji: string | null) => void;
|
onChangeIcon?: (icon: string | null, color: string | null) => void;
|
||||||
/** Callback called when the user expects to move to the "next" input */
|
/** Callback called when the user expects to move to the "next" input */
|
||||||
onGoToNextInput?: (insertParagraph?: boolean) => void;
|
onGoToNextInput?: (insertParagraph?: boolean) => void;
|
||||||
/** Callback called when the user expects to save (CMD+S) */
|
/** Callback called when the user expects to save (CMD+S) */
|
||||||
@@ -56,10 +59,11 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
|||||||
{
|
{
|
||||||
documentId,
|
documentId,
|
||||||
title,
|
title,
|
||||||
emoji,
|
icon,
|
||||||
|
color,
|
||||||
readOnly,
|
readOnly,
|
||||||
onChangeTitle,
|
onChangeTitle,
|
||||||
onChangeEmoji,
|
onChangeIcon,
|
||||||
onSave,
|
onSave,
|
||||||
onGoToNextInput,
|
onGoToNextInput,
|
||||||
onBlur,
|
onBlur,
|
||||||
@@ -68,7 +72,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
|||||||
externalRef: React.RefObject<RefHandle>
|
externalRef: React.RefObject<RefHandle>
|
||||||
) {
|
) {
|
||||||
const ref = React.useRef<RefHandle>(null);
|
const ref = React.useRef<RefHandle>(null);
|
||||||
const [emojiPickerIsOpen, handleOpen, handleClose] = useBoolean();
|
const [iconPickerIsOpen, handleOpen, handleClose] = useBoolean();
|
||||||
const { editor } = useDocumentContext();
|
const { editor } = useDocumentContext();
|
||||||
const can = usePolicy(documentId);
|
const can = usePolicy(documentId);
|
||||||
|
|
||||||
@@ -212,19 +216,26 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
|||||||
[editor]
|
[editor]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEmojiChange = React.useCallback(
|
const handleIconChange = React.useCallback(
|
||||||
async (value: string | null) => {
|
(chosenIcon: string | null, iconColor: string | null) => {
|
||||||
// Restore focus on title
|
if (icon !== chosenIcon || color !== iconColor) {
|
||||||
restoreFocus();
|
onChangeIcon?.(chosenIcon, iconColor);
|
||||||
if (emoji !== value) {
|
|
||||||
onChangeEmoji?.(value);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[emoji, onChangeEmoji, restoreFocus]
|
[icon, color, onChangeIcon]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!iconPickerIsOpen) {
|
||||||
|
restoreFocus();
|
||||||
|
}
|
||||||
|
}, [iconPickerIsOpen, restoreFocus]);
|
||||||
|
|
||||||
const dir = ref.current?.getComputedDirection();
|
const dir = ref.current?.getComputedDirection();
|
||||||
const emojiIcon = <Emoji size={32}>{emoji}</Emoji>;
|
|
||||||
|
const fallbackIcon = icon ? (
|
||||||
|
<Icon value={icon} color={color} size={40} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Title
|
<Title
|
||||||
@@ -235,8 +246,8 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
|||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={title}
|
value={title}
|
||||||
$emojiPickerIsOpen={emojiPickerIsOpen}
|
$iconPickerIsOpen={iconPickerIsOpen}
|
||||||
$containsEmoji={!!emoji}
|
$containsIcon={!!icon}
|
||||||
autoFocus={!title}
|
autoFocus={!title}
|
||||||
maxLength={DocumentValidation.maxTitleLength}
|
maxLength={DocumentValidation.maxTitleLength}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
@@ -244,47 +255,33 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
|||||||
ref={mergeRefs([ref, externalRef])}
|
ref={mergeRefs([ref, externalRef])}
|
||||||
>
|
>
|
||||||
{can.update && !readOnly ? (
|
{can.update && !readOnly ? (
|
||||||
<EmojiWrapper align="center" justify="center" dir={dir}>
|
<IconWrapper align="center" justify="center" dir={dir}>
|
||||||
<React.Suspense fallback={emojiIcon}>
|
<React.Suspense fallback={fallbackIcon}>
|
||||||
<StyledEmojiPicker
|
<StyledIconPicker
|
||||||
value={emoji}
|
icon={icon ?? null}
|
||||||
onChange={handleEmojiChange}
|
color={color}
|
||||||
|
size={40}
|
||||||
|
popoverPosition="bottom-start"
|
||||||
|
allowDelete={true}
|
||||||
|
borderOnHover={true}
|
||||||
|
onChange={handleIconChange}
|
||||||
onOpen={handleOpen}
|
onOpen={handleOpen}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onClickOutside={restoreFocus}
|
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
</EmojiWrapper>
|
</IconWrapper>
|
||||||
) : emoji ? (
|
) : icon ? (
|
||||||
<EmojiWrapper align="center" justify="center" dir={dir}>
|
<IconWrapper align="center" justify="center" dir={dir}>
|
||||||
{emojiIcon}
|
{fallbackIcon}
|
||||||
</EmojiWrapper>
|
</IconWrapper>
|
||||||
) : null}
|
) : null}
|
||||||
</Title>
|
</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 = {
|
type TitleProps = {
|
||||||
$containsEmoji: boolean;
|
$containsIcon: boolean;
|
||||||
$emojiPickerIsOpen: boolean;
|
$iconPickerIsOpen: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Title = styled(ContentEditable)<TitleProps>`
|
const Title = styled(ContentEditable)<TitleProps>`
|
||||||
@@ -293,7 +290,7 @@ const Title = styled(ContentEditable)<TitleProps>`
|
|||||||
margin-top: 6vh;
|
margin-top: 6vh;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
margin-left: ${(props) =>
|
margin-left: ${(props) =>
|
||||||
props.$containsEmoji || props.$emojiPickerIsOpen ? "40px" : "0px"};
|
props.$containsIcon || props.$iconPickerIsOpen ? "40px" : "0px"};
|
||||||
font-size: ${fontSize};
|
font-size: ${fontSize};
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -314,14 +311,14 @@ const Title = styled(ContentEditable)<TitleProps>`
|
|||||||
&:focus {
|
&:focus {
|
||||||
margin-left: 40px;
|
margin-left: 40px;
|
||||||
|
|
||||||
${EmojiButton} {
|
${PopoverButton} {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
${EmojiButton} {
|
${PopoverButton} {
|
||||||
opacity: ${(props: TitleProps) =>
|
opacity: ${(props: TitleProps) =>
|
||||||
props.$containsEmoji ? "1 !important" : 0};
|
props.$containsIcon ? "1 !important" : 0};
|
||||||
}
|
}
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
${breakpoint("tablet")`
|
||||||
@@ -333,7 +330,7 @@ const Title = styled(ContentEditable)<TitleProps>`
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
${EmojiButton} {
|
${PopoverButton} {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|
||||||
&:hover {
|
&: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);
|
export default observer(DocumentTitle);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { mergeRefs } from "react-merge-refs";
|
import { mergeRefs } from "react-merge-refs";
|
||||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||||
import { richExtensions, withComments } from "@shared/editor/nodes";
|
import { richExtensions, withComments } from "@shared/editor/nodes";
|
||||||
|
import { randomElement } from "@shared/random";
|
||||||
import { TeamPreference } from "@shared/types";
|
import { TeamPreference } from "@shared/types";
|
||||||
|
import { colorPalette } from "@shared/utils/collections";
|
||||||
import Comment from "~/models/Comment";
|
import Comment from "~/models/Comment";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import { RefHandle } from "~/components/ContentEditable";
|
import { RefHandle } from "~/components/ContentEditable";
|
||||||
@@ -52,7 +54,7 @@ const extensions = [
|
|||||||
|
|
||||||
type Props = Omit<EditorProps, "editorStyle"> & {
|
type Props = Omit<EditorProps, "editorStyle"> & {
|
||||||
onChangeTitle: (title: string) => void;
|
onChangeTitle: (title: string) => void;
|
||||||
onChangeEmoji: (emoji: string | null) => void;
|
onChangeIcon: (icon: string | null, color: string | null) => void;
|
||||||
id: string;
|
id: string;
|
||||||
document: Document;
|
document: Document;
|
||||||
isDraft: boolean;
|
isDraft: boolean;
|
||||||
@@ -81,7 +83,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
|||||||
const {
|
const {
|
||||||
document,
|
document,
|
||||||
onChangeTitle,
|
onChangeTitle,
|
||||||
onChangeEmoji,
|
onChangeIcon,
|
||||||
isDraft,
|
isDraft,
|
||||||
shareId,
|
shareId,
|
||||||
readOnly,
|
readOnly,
|
||||||
@@ -91,6 +93,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
|||||||
} = props;
|
} = props;
|
||||||
const can = usePolicy(document);
|
const can = usePolicy(document);
|
||||||
|
|
||||||
|
const iconColor = React.useMemo(
|
||||||
|
() => document.color ?? randomElement(colorPalette),
|
||||||
|
[document.color]
|
||||||
|
);
|
||||||
const childRef = React.useRef<HTMLDivElement>(null);
|
const childRef = React.useRef<HTMLDivElement>(null);
|
||||||
const focusAtStart = React.useCallback(() => {
|
const focusAtStart = React.useCallback(() => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
@@ -186,9 +192,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
|||||||
? document.titleWithDefault
|
? document.titleWithDefault
|
||||||
: document.title
|
: document.title
|
||||||
}
|
}
|
||||||
emoji={document.emoji}
|
icon={document.icon}
|
||||||
|
color={iconColor}
|
||||||
onChangeTitle={onChangeTitle}
|
onChangeTitle={onChangeTitle}
|
||||||
onChangeEmoji={onChangeEmoji}
|
onChangeIcon={onChangeIcon}
|
||||||
onGoToNextInput={handleGoToNextInput}
|
onGoToNextInput={handleGoToNextInput}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
placeholder={t("Untitled")}
|
placeholder={t("Untitled")}
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ import {
|
|||||||
useDocumentContext,
|
useDocumentContext,
|
||||||
useEditingFocus,
|
useEditingFocus,
|
||||||
} from "~/components/DocumentContext";
|
} from "~/components/DocumentContext";
|
||||||
|
import Flex from "~/components/Flex";
|
||||||
import Header from "~/components/Header";
|
import Header from "~/components/Header";
|
||||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
import Icon from "~/components/Icon";
|
||||||
import Star from "~/components/Star";
|
import Star from "~/components/Star";
|
||||||
import Tooltip from "~/components/Tooltip";
|
import Tooltip from "~/components/Tooltip";
|
||||||
import { publishDocument } from "~/actions/definitions/documents";
|
import { publishDocument } from "~/actions/definitions/documents";
|
||||||
@@ -189,7 +190,14 @@ function DocumentHeader({
|
|||||||
return (
|
return (
|
||||||
<StyledHeader
|
<StyledHeader
|
||||||
$hidden={isEditingFocus}
|
$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}
|
hasSidebar={sharedTree && sharedTree.children?.length > 0}
|
||||||
left={
|
left={
|
||||||
isMobile ? (
|
isMobile ? (
|
||||||
@@ -229,17 +237,15 @@ function DocumentHeader({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
<>
|
<Flex gap={4}>
|
||||||
{document.emoji && (
|
{document.icon && (
|
||||||
<>
|
<Icon value={document.icon} color={document.color ?? undefined} />
|
||||||
<EmojiIcon size={24} emoji={document.emoji} />{" "}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{document.title}{" "}
|
{document.title}
|
||||||
{document.isArchived && (
|
{document.isArchived && (
|
||||||
<ArchivedBadge>{t("Archived")}</ArchivedBadge>
|
<ArchivedBadge>{t("Archived")}</ArchivedBadge>
|
||||||
)}
|
)}
|
||||||
</>
|
</Flex>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { NavigationNode } from "@shared/types";
|
import { NavigationNode } from "@shared/types";
|
||||||
import Breadcrumb from "~/components/Breadcrumb";
|
import Breadcrumb from "~/components/Breadcrumb";
|
||||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
import Icon from "~/components/Icon";
|
||||||
import { MenuInternalLink } from "~/types";
|
import { MenuInternalLink } from "~/types";
|
||||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||||
|
|
||||||
@@ -53,13 +53,10 @@ const PublicBreadcrumb: React.FC<Props> = ({
|
|||||||
.slice(0, -1)
|
.slice(0, -1)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
title: item.emoji ? (
|
icon: item.icon ? (
|
||||||
<>
|
<Icon value={item.icon} color={item.color} />
|
||||||
<EmojiIcon emoji={item.emoji} /> {item.title}
|
) : undefined,
|
||||||
</>
|
title: item.title,
|
||||||
) : (
|
|
||||||
item.title
|
|
||||||
),
|
|
||||||
type: "route",
|
type: "route",
|
||||||
to: sharedDocumentPath(shareId, item.url),
|
to: sharedDocumentPath(shareId, item.url),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import * as React from "react";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { s, ellipsis } from "@shared/styles";
|
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 Document from "~/models/Document";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
import Icon from "~/components/Icon";
|
||||||
import { hover } from "~/styles";
|
import { hover } from "~/styles";
|
||||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||||
|
|
||||||
@@ -58,7 +59,8 @@ function ReferenceListItem({
|
|||||||
shareId,
|
shareId,
|
||||||
...rest
|
...rest
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { emoji } = document;
|
const { icon, color } = document;
|
||||||
|
const isEmoji = determineIconType(icon) === IconType.Emoji;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentLink
|
<DocumentLink
|
||||||
@@ -74,9 +76,13 @@ function ReferenceListItem({
|
|||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<Content gap={4} dir="auto">
|
<Content gap={4} dir="auto">
|
||||||
{emoji ? <EmojiIcon emoji={emoji} /> : <DocumentIcon />}
|
{icon ? (
|
||||||
|
<Icon value={icon} color={color ?? undefined} />
|
||||||
|
) : (
|
||||||
|
<DocumentIcon />
|
||||||
|
)}
|
||||||
<Title>
|
<Title>
|
||||||
{emoji ? document.title.replace(emoji, "") : document.title}
|
{isEmoji ? document.title.replace(icon!, "") : document.title}
|
||||||
</Title>
|
</Title>
|
||||||
</Content>
|
</Content>
|
||||||
</DocumentLink>
|
</DocumentLink>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import EditorContainer from "@shared/editor/components/Styles";
|
import EditorContainer from "@shared/editor/components/Styles";
|
||||||
|
import { colorPalette } from "@shared/utils/collections";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Revision from "~/models/Revision";
|
import Revision from "~/models/Revision";
|
||||||
import { Props as EditorProps } from "~/components/Editor";
|
import { Props as EditorProps } from "~/components/Editor";
|
||||||
@@ -30,7 +31,8 @@ function RevisionViewer(props: Props) {
|
|||||||
<DocumentTitle
|
<DocumentTitle
|
||||||
documentId={revision.documentId}
|
documentId={revision.documentId}
|
||||||
title={revision.title}
|
title={revision.title}
|
||||||
emoji={revision.emoji}
|
icon={revision.icon}
|
||||||
|
color={revision.color ?? colorPalette[0]}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
<DocumentMeta
|
<DocumentMeta
|
||||||
|
|||||||
2
app/typings/index.d.ts
vendored
2
app/typings/index.d.ts
vendored
@@ -2,8 +2,6 @@ declare module "autotrack/autotrack.js";
|
|||||||
|
|
||||||
declare module "emoji-mart";
|
declare module "emoji-mart";
|
||||||
|
|
||||||
declare module "@emoji-mart/react";
|
|
||||||
|
|
||||||
declare module "string-replace-to-array";
|
declare module "string-replace-to-array";
|
||||||
|
|
||||||
declare module "sequelize-encrypted";
|
declare module "sequelize-encrypted";
|
||||||
|
|||||||
1
app/typings/styled-components.d.ts
vendored
1
app/typings/styled-components.d.ts
vendored
@@ -19,6 +19,7 @@ declare module "styled-components" {
|
|||||||
scrollbarThumb: string;
|
scrollbarThumb: string;
|
||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
fontFamilyMono: string;
|
fontFamilyMono: string;
|
||||||
|
fontFamilyEmoji: string;
|
||||||
fontWeightRegular: number;
|
fontWeightRegular: number;
|
||||||
fontWeightMedium: number;
|
fontWeightMedium: number;
|
||||||
fontWeightBold: number;
|
fontWeightBold: number;
|
||||||
|
|||||||
@@ -66,7 +66,6 @@
|
|||||||
"@dnd-kit/modifiers": "^6.0.1",
|
"@dnd-kit/modifiers": "^6.0.1",
|
||||||
"@dnd-kit/sortable": "^7.0.2",
|
"@dnd-kit/sortable": "^7.0.2",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FetchError } from "node-fetch";
|
import { FetchError } from "node-fetch";
|
||||||
import { Op } from "sequelize";
|
import { Op } from "sequelize";
|
||||||
|
import { colorPalette } from "@shared/utils/collections";
|
||||||
import WebhookDisabledEmail from "@server/emails/templates/WebhookDisabledEmail";
|
import WebhookDisabledEmail from "@server/emails/templates/WebhookDisabledEmail";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import Logger from "@server/logging/Logger";
|
import Logger from "@server/logging/Logger";
|
||||||
@@ -423,12 +424,18 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
|||||||
paranoid: false,
|
paranoid: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const collection = model && (await presentCollection(undefined, model));
|
||||||
|
if (collection) {
|
||||||
|
// For backward compatibility, set a default color.
|
||||||
|
collection.color = collection.color ?? colorPalette[0];
|
||||||
|
}
|
||||||
|
|
||||||
await this.sendWebhook({
|
await this.sendWebhook({
|
||||||
event,
|
event,
|
||||||
subscription,
|
subscription,
|
||||||
payload: {
|
payload: {
|
||||||
id: event.collectionId,
|
id: event.collectionId,
|
||||||
model: model && (await presentCollection(undefined, model)),
|
model: collection,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -448,14 +455,20 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
|||||||
paranoid: false,
|
paranoid: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const collection =
|
||||||
|
model && (await presentCollection(undefined, model.collection!));
|
||||||
|
if (collection) {
|
||||||
|
// For backward compatibility, set a default color.
|
||||||
|
collection.color = collection.color ?? colorPalette[0];
|
||||||
|
}
|
||||||
|
|
||||||
await this.sendWebhook({
|
await this.sendWebhook({
|
||||||
event,
|
event,
|
||||||
subscription,
|
subscription,
|
||||||
payload: {
|
payload: {
|
||||||
id: event.modelId,
|
id: event.modelId,
|
||||||
model: model && presentMembership(model),
|
model: model && presentMembership(model),
|
||||||
collection:
|
collection,
|
||||||
model && (await presentCollection(undefined, model.collection!)),
|
|
||||||
user: model && presentUser(model.user),
|
user: model && presentUser(model.user),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -476,14 +489,20 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
|||||||
paranoid: false,
|
paranoid: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const collection =
|
||||||
|
model && (await presentCollection(undefined, model.collection!));
|
||||||
|
if (collection) {
|
||||||
|
// For backward compatibility, set a default color.
|
||||||
|
collection.color = collection.color ?? colorPalette[0];
|
||||||
|
}
|
||||||
|
|
||||||
await this.sendWebhook({
|
await this.sendWebhook({
|
||||||
event,
|
event,
|
||||||
subscription,
|
subscription,
|
||||||
payload: {
|
payload: {
|
||||||
id: event.modelId,
|
id: event.modelId,
|
||||||
model: model && presentCollectionGroupMembership(model),
|
model: model && presentCollectionGroupMembership(model),
|
||||||
collection:
|
collection,
|
||||||
model && (await presentCollection(undefined, model.collection!)),
|
|
||||||
group: model && presentGroup(model.group),
|
group: model && presentGroup(model.group),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ type Props = Optional<
|
|||||||
| "title"
|
| "title"
|
||||||
| "text"
|
| "text"
|
||||||
| "content"
|
| "content"
|
||||||
| "emoji"
|
| "icon"
|
||||||
|
| "color"
|
||||||
| "collectionId"
|
| "collectionId"
|
||||||
| "parentDocumentId"
|
| "parentDocumentId"
|
||||||
| "importId"
|
| "importId"
|
||||||
@@ -36,7 +37,8 @@ type Props = Optional<
|
|||||||
export default async function documentCreator({
|
export default async function documentCreator({
|
||||||
title = "",
|
title = "",
|
||||||
text = "",
|
text = "",
|
||||||
emoji,
|
icon,
|
||||||
|
color,
|
||||||
state,
|
state,
|
||||||
id,
|
id,
|
||||||
urlId,
|
urlId,
|
||||||
@@ -96,9 +98,9 @@ export default async function documentCreator({
|
|||||||
importId,
|
importId,
|
||||||
sourceMetadata,
|
sourceMetadata,
|
||||||
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
|
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
|
||||||
emoji: templateDocument ? templateDocument.emoji : emoji,
|
emoji: templateDocument ? templateDocument.emoji : icon,
|
||||||
icon: templateDocument ? templateDocument.emoji : emoji,
|
icon: templateDocument ? templateDocument.emoji : icon,
|
||||||
color: templateDocument ? templateDocument.color : null,
|
color: templateDocument ? templateDocument.color : color,
|
||||||
title: TextHelper.replaceTemplateVariables(
|
title: TextHelper.replaceTemplateVariables(
|
||||||
templateDocument ? templateDocument.title : title,
|
templateDocument ? templateDocument.title : title,
|
||||||
user
|
user
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ describe("documentDuplicator", () => {
|
|||||||
expect(response[0].title).toEqual(original.title);
|
expect(response[0].title).toEqual(original.title);
|
||||||
expect(response[0].text).toEqual(original.text);
|
expect(response[0].text).toEqual(original.text);
|
||||||
expect(response[0].emoji).toEqual(original.emoji);
|
expect(response[0].emoji).toEqual(original.emoji);
|
||||||
expect(response[0].icon).toEqual(original.emoji);
|
expect(response[0].icon).toEqual(original.icon);
|
||||||
|
expect(response[0].color).toEqual(original.color);
|
||||||
expect(response[0].publishedAt).toBeInstanceOf(Date);
|
expect(response[0].publishedAt).toBeInstanceOf(Date);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ describe("documentDuplicator", () => {
|
|||||||
const original = await buildDocument({
|
const original = await buildDocument({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
emoji: "👋",
|
icon: "👋",
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await sequelize.transaction((transaction) =>
|
const response = await sequelize.transaction((transaction) =>
|
||||||
@@ -52,8 +53,9 @@ describe("documentDuplicator", () => {
|
|||||||
expect(response).toHaveLength(1);
|
expect(response).toHaveLength(1);
|
||||||
expect(response[0].title).toEqual("New title");
|
expect(response[0].title).toEqual("New title");
|
||||||
expect(response[0].text).toEqual(original.text);
|
expect(response[0].text).toEqual(original.text);
|
||||||
expect(response[0].emoji).toEqual(original.emoji);
|
expect(response[0].emoji).toEqual(original.icon);
|
||||||
expect(response[0].icon).toEqual(original.emoji);
|
expect(response[0].icon).toEqual(original.icon);
|
||||||
|
expect(response[0].color).toEqual(original.color);
|
||||||
expect(response[0].publishedAt).toBeInstanceOf(Date);
|
expect(response[0].publishedAt).toBeInstanceOf(Date);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ describe("documentDuplicator", () => {
|
|||||||
const original = await buildDocument({
|
const original = await buildDocument({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
emoji: "👋",
|
icon: "👋",
|
||||||
});
|
});
|
||||||
|
|
||||||
await buildDocument({
|
await buildDocument({
|
||||||
@@ -108,7 +110,8 @@ describe("documentDuplicator", () => {
|
|||||||
expect(response[0].title).toEqual(original.title);
|
expect(response[0].title).toEqual(original.title);
|
||||||
expect(response[0].text).toEqual(original.text);
|
expect(response[0].text).toEqual(original.text);
|
||||||
expect(response[0].emoji).toEqual(original.emoji);
|
expect(response[0].emoji).toEqual(original.emoji);
|
||||||
expect(response[0].icon).toEqual(original.emoji);
|
expect(response[0].icon).toEqual(original.icon);
|
||||||
|
expect(response[0].color).toEqual(original.color);
|
||||||
expect(response[0].publishedAt).toBeNull();
|
expect(response[0].publishedAt).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export default async function documentDuplicator({
|
|||||||
|
|
||||||
const duplicated = await documentCreator({
|
const duplicated = await documentCreator({
|
||||||
parentDocumentId: parentDocumentId ?? document.parentDocumentId,
|
parentDocumentId: parentDocumentId ?? document.parentDocumentId,
|
||||||
emoji: document.emoji,
|
icon: document.icon ?? document.emoji,
|
||||||
|
color: document.color,
|
||||||
template: document.template,
|
template: document.template,
|
||||||
title: title ?? document.title,
|
title: title ?? document.title,
|
||||||
content: document.content,
|
content: document.content,
|
||||||
@@ -78,7 +79,8 @@ export default async function documentDuplicator({
|
|||||||
for (const childDocument of childDocuments) {
|
for (const childDocument of childDocuments) {
|
||||||
const duplicatedChildDocument = await documentCreator({
|
const duplicatedChildDocument = await documentCreator({
|
||||||
parentDocumentId: duplicated.id,
|
parentDocumentId: duplicated.id,
|
||||||
emoji: childDocument.emoji,
|
icon: childDocument.icon ?? childDocument.emoji,
|
||||||
|
color: childDocument.color,
|
||||||
title: childDocument.title,
|
title: childDocument.title,
|
||||||
text: childDocument.text,
|
text: childDocument.text,
|
||||||
...sharedProperties,
|
...sharedProperties,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async function documentImporter({
|
|||||||
ip,
|
ip,
|
||||||
transaction,
|
transaction,
|
||||||
}: Props): Promise<{
|
}: Props): Promise<{
|
||||||
emoji?: string;
|
icon?: string;
|
||||||
text: string;
|
text: string;
|
||||||
title: string;
|
title: string;
|
||||||
state: Buffer;
|
state: Buffer;
|
||||||
@@ -43,9 +43,9 @@ async function documentImporter({
|
|||||||
// find and extract emoji near the beginning of the document.
|
// find and extract emoji near the beginning of the document.
|
||||||
const regex = emojiRegex();
|
const regex = emojiRegex();
|
||||||
const matches = regex.exec(text.slice(0, 10));
|
const matches = regex.exec(text.slice(0, 10));
|
||||||
const emoji = matches ? matches[0] : undefined;
|
const icon = matches ? matches[0] : undefined;
|
||||||
if (emoji) {
|
if (icon) {
|
||||||
text = text.replace(emoji, "");
|
text = text.replace(icon, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the first line of the imported text looks like a markdown heading
|
// If the first line of the imported text looks like a markdown heading
|
||||||
@@ -96,7 +96,7 @@ async function documentImporter({
|
|||||||
text,
|
text,
|
||||||
state,
|
state,
|
||||||
title,
|
title,
|
||||||
emoji,
|
icon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ type Props = {
|
|||||||
document: Document;
|
document: Document;
|
||||||
/** The new title */
|
/** The new title */
|
||||||
title?: string;
|
title?: string;
|
||||||
/** The document emoji */
|
/** The document icon */
|
||||||
emoji?: string | null;
|
icon?: string | null;
|
||||||
|
/** The document icon's color */
|
||||||
|
color?: string | null;
|
||||||
/** The new text content */
|
/** The new text content */
|
||||||
text?: string;
|
text?: string;
|
||||||
/** Whether the editing session is complete */
|
/** Whether the editing session is complete */
|
||||||
@@ -46,7 +48,8 @@ export default async function documentUpdater({
|
|||||||
user,
|
user,
|
||||||
document,
|
document,
|
||||||
title,
|
title,
|
||||||
emoji,
|
icon,
|
||||||
|
color,
|
||||||
text,
|
text,
|
||||||
editorVersion,
|
editorVersion,
|
||||||
templateId,
|
templateId,
|
||||||
@@ -65,9 +68,12 @@ export default async function documentUpdater({
|
|||||||
if (title !== undefined) {
|
if (title !== undefined) {
|
||||||
document.title = title.trim();
|
document.title = title.trim();
|
||||||
}
|
}
|
||||||
if (emoji !== undefined) {
|
if (icon !== undefined) {
|
||||||
document.emoji = emoji;
|
document.emoji = icon;
|
||||||
document.icon = emoji;
|
document.icon = icon;
|
||||||
|
}
|
||||||
|
if (color !== undefined) {
|
||||||
|
document.color = color;
|
||||||
}
|
}
|
||||||
if (editorVersion) {
|
if (editorVersion) {
|
||||||
document.editorVersion = editorVersion;
|
document.editorVersion = editorVersion;
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ class Collection extends ParanoidModel<
|
|||||||
@Column(DataType.JSONB)
|
@Column(DataType.JSONB)
|
||||||
content: ProsemirrorData | null;
|
content: ProsemirrorData | null;
|
||||||
|
|
||||||
|
/** An icon (or) emoji to use as the collection icon. */
|
||||||
@Length({
|
@Length({
|
||||||
max: 50,
|
max: 50,
|
||||||
msg: `icon must be 50 characters or less`,
|
msg: `icon must be 50 characters or less`,
|
||||||
@@ -190,6 +191,7 @@ class Collection extends ParanoidModel<
|
|||||||
@Column
|
@Column
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
|
|
||||||
|
/** The color of the icon. */
|
||||||
@IsHexColor
|
@IsHexColor
|
||||||
@Column
|
@Column
|
||||||
color: string | null;
|
color: string | null;
|
||||||
@@ -270,10 +272,6 @@ class Collection extends ParanoidModel<
|
|||||||
|
|
||||||
@BeforeSave
|
@BeforeSave
|
||||||
static async onBeforeSave(model: Collection) {
|
static async onBeforeSave(model: Collection) {
|
||||||
if (model.icon === "collection") {
|
|
||||||
model.icon = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!model.content) {
|
if (!model.content) {
|
||||||
model.content = await DocumentHelper.toJSON(model);
|
model.content = await DocumentHelper.toJSON(model);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,14 +255,18 @@ class Document extends ParanoidModel<
|
|||||||
@Column
|
@Column
|
||||||
editorVersion: string;
|
editorVersion: string;
|
||||||
|
|
||||||
/** An emoji to use as the document icon. */
|
/**
|
||||||
|
* An emoji to use as the document icon,
|
||||||
|
* This is used as fallback (for backward compat) when icon is not set.
|
||||||
|
*/
|
||||||
@Length({
|
@Length({
|
||||||
max: 1,
|
max: 50,
|
||||||
msg: `Emoji must be a single character`,
|
msg: `Emoji must be 50 characters or less`,
|
||||||
})
|
})
|
||||||
@Column
|
@Column
|
||||||
emoji: string | null;
|
emoji: string | null;
|
||||||
|
|
||||||
|
/** An icon to use as the document icon. */
|
||||||
@Length({
|
@Length({
|
||||||
max: 50,
|
max: 50,
|
||||||
msg: `icon must be 50 characters or less`,
|
msg: `icon must be 50 characters or less`,
|
||||||
@@ -365,7 +369,11 @@ class Document extends ParanoidModel<
|
|||||||
model.archivedAt ||
|
model.archivedAt ||
|
||||||
model.template ||
|
model.template ||
|
||||||
!model.publishedAt ||
|
!model.publishedAt ||
|
||||||
!(model.changed("title") || model.changed("emoji")) ||
|
!(
|
||||||
|
model.changed("title") ||
|
||||||
|
model.changed("icon") ||
|
||||||
|
model.changed("color")
|
||||||
|
) ||
|
||||||
!model.collectionId
|
!model.collectionId
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -721,6 +729,8 @@ class Document extends ParanoidModel<
|
|||||||
this.text = revision.text;
|
this.text = revision.text;
|
||||||
this.title = revision.title;
|
this.title = revision.title;
|
||||||
this.emoji = revision.emoji;
|
this.emoji = revision.emoji;
|
||||||
|
this.icon = revision.icon;
|
||||||
|
this.color = revision.color;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1083,6 +1093,8 @@ class Document extends ParanoidModel<
|
|||||||
title: this.title,
|
title: this.title,
|
||||||
url: this.url,
|
url: this.url,
|
||||||
emoji: isNil(this.emoji) ? undefined : this.emoji,
|
emoji: isNil(this.emoji) ? undefined : this.emoji,
|
||||||
|
icon: isNil(this.icon) ? undefined : this.icon,
|
||||||
|
color: isNil(this.color) ? undefined : this.color,
|
||||||
children,
|
children,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,13 +71,18 @@ class Revision extends IdModel<
|
|||||||
@Column(DataType.JSONB)
|
@Column(DataType.JSONB)
|
||||||
content: ProsemirrorData;
|
content: ProsemirrorData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An emoji to use as the document icon,
|
||||||
|
* This is used as fallback (for backward compat) when icon is not set.
|
||||||
|
*/
|
||||||
@Length({
|
@Length({
|
||||||
max: 1,
|
max: 50,
|
||||||
msg: `Emoji must be a single character`,
|
msg: `Emoji must be 50 characters or less`,
|
||||||
})
|
})
|
||||||
@Column
|
@Column
|
||||||
emoji: string | null;
|
emoji: string | null;
|
||||||
|
|
||||||
|
/** An icon to use as the document icon. */
|
||||||
@Length({
|
@Length({
|
||||||
max: 50,
|
max: 50,
|
||||||
msg: `icon must be 50 characters or less`,
|
msg: `icon must be 50 characters or less`,
|
||||||
@@ -134,7 +139,7 @@ class Revision extends IdModel<
|
|||||||
title: document.title,
|
title: document.title,
|
||||||
text: document.text,
|
text: document.text,
|
||||||
emoji: document.emoji,
|
emoji: document.emoji,
|
||||||
icon: document.emoji,
|
icon: document.icon,
|
||||||
color: document.color,
|
color: document.color,
|
||||||
content: document.content,
|
content: document.content,
|
||||||
userId: document.lastModifiedById,
|
userId: document.lastModifiedById,
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { Node } from "prosemirror-model";
|
|||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import textBetween from "@shared/editor/lib/textBetween";
|
import textBetween from "@shared/editor/lib/textBetween";
|
||||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||||
import { ProsemirrorData } from "@shared/types";
|
import { IconType, ProsemirrorData } from "@shared/types";
|
||||||
|
import { determineIconType } from "@shared/utils/icon";
|
||||||
import { parser, serializer, schema } from "@server/editor";
|
import { parser, serializer, schema } from "@server/editor";
|
||||||
import { addTags } from "@server/logging/tracer";
|
import { addTags } from "@server/logging/tracer";
|
||||||
import { trace } from "@server/logging/tracing";
|
import { trace } from "@server/logging/tracing";
|
||||||
@@ -148,7 +149,10 @@ export class DocumentHelper {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = `${document.emoji ? document.emoji + " " : ""}${
|
const icon = document.icon ?? document.emoji;
|
||||||
|
const iconType = determineIconType(icon);
|
||||||
|
|
||||||
|
const title = `${iconType === IconType.Emoji ? icon + " " : ""}${
|
||||||
document.title
|
document.title
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { colorPalette } from "@shared/utils/collections";
|
|
||||||
import Collection from "@server/models/Collection";
|
import Collection from "@server/models/Collection";
|
||||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||||
import { APIContext } from "@server/types";
|
import { APIContext } from "@server/types";
|
||||||
@@ -19,7 +18,7 @@ export default async function presentCollection(
|
|||||||
sort: collection.sort,
|
sort: collection.sort,
|
||||||
icon: collection.icon,
|
icon: collection.icon,
|
||||||
index: collection.index,
|
index: collection.index,
|
||||||
color: collection.color || colorPalette[0],
|
color: collection.color,
|
||||||
permission: collection.permission,
|
permission: collection.permission,
|
||||||
sharing: collection.sharing,
|
sharing: collection.sharing,
|
||||||
createdAt: collection.createdAt,
|
createdAt: collection.createdAt,
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ async function presentDocument(
|
|||||||
: undefined,
|
: undefined,
|
||||||
text: !asData || options?.includeText ? text : undefined,
|
text: !asData || options?.includeText ? text : undefined,
|
||||||
emoji: document.emoji,
|
emoji: document.emoji,
|
||||||
|
icon: document.icon,
|
||||||
|
color: document.color,
|
||||||
tasks: document.tasks,
|
tasks: document.tasks,
|
||||||
createdAt: document.createdAt,
|
createdAt: document.createdAt,
|
||||||
createdBy: undefined,
|
createdBy: undefined,
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ async function presentRevision(revision: Revision, diff?: string) {
|
|||||||
documentId: revision.documentId,
|
documentId: revision.documentId,
|
||||||
title: strippedTitle,
|
title: strippedTitle,
|
||||||
data: await DocumentHelper.toJSON(revision),
|
data: await DocumentHelper.toJSON(revision),
|
||||||
emoji: revision.emoji ?? emoji,
|
icon: revision.icon ?? revision.emoji ?? emoji,
|
||||||
|
color: revision.color,
|
||||||
html: diff,
|
html: diff,
|
||||||
createdAt: revision.createdAt,
|
createdAt: revision.createdAt,
|
||||||
createdBy: presentUser(revision.user),
|
createdBy: presentUser(revision.user),
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default class DocumentImportTask extends BaseTask<Props> {
|
|||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { text, state, title, emoji } = await documentImporter({
|
const { text, state, title, icon } = await documentImporter({
|
||||||
user,
|
user,
|
||||||
fileName: sourceMetadata.fileName,
|
fileName: sourceMetadata.fileName,
|
||||||
mimeType: sourceMetadata.mimeType,
|
mimeType: sourceMetadata.mimeType,
|
||||||
@@ -55,7 +55,7 @@ export default class DocumentImportTask extends BaseTask<Props> {
|
|||||||
return documentCreator({
|
return documentCreator({
|
||||||
sourceMetadata,
|
sourceMetadata,
|
||||||
title,
|
title,
|
||||||
emoji,
|
icon,
|
||||||
text,
|
text,
|
||||||
state,
|
state,
|
||||||
publish,
|
publish,
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ export default class ExportJSONTask extends ExportTask {
|
|||||||
id: document.id,
|
id: document.id,
|
||||||
urlId: document.urlId,
|
urlId: document.urlId,
|
||||||
title: document.title,
|
title: document.title,
|
||||||
emoji: document.emoji,
|
icon: document.icon,
|
||||||
|
color: document.color,
|
||||||
data: DocumentHelper.toProsemirror(document),
|
data: DocumentHelper.toProsemirror(document),
|
||||||
createdById: document.createdById,
|
createdById: document.createdById,
|
||||||
createdByName: document.createdBy.name,
|
createdByName: document.createdBy.name,
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ export default class ImportJSONTask extends ImportTask {
|
|||||||
// TODO: This is kind of temporary, we can import the document
|
// TODO: This is kind of temporary, we can import the document
|
||||||
// structure directly in the future.
|
// structure directly in the future.
|
||||||
text: serializer.serialize(Node.fromJSON(schema, node.data)),
|
text: serializer.serialize(Node.fromJSON(schema, node.data)),
|
||||||
emoji: node.emoji,
|
emoji: node.icon ?? node.emoji,
|
||||||
icon: node.emoji,
|
icon: node.icon ?? node.emoji,
|
||||||
color: null,
|
color: node.color,
|
||||||
createdAt: node.createdAt ? new Date(node.createdAt) : undefined,
|
createdAt: node.createdAt ? new Date(node.createdAt) : undefined,
|
||||||
updatedAt: node.updatedAt ? new Date(node.updatedAt) : undefined,
|
updatedAt: node.updatedAt ? new Date(node.updatedAt) : undefined,
|
||||||
publishedAt: node.publishedAt ? new Date(node.publishedAt) : null,
|
publishedAt: node.publishedAt ? new Date(node.publishedAt) : null,
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default class ImportMarkdownZipTask extends ImportTask {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, emoji, text } = await documentImporter({
|
const { title, icon, text } = await documentImporter({
|
||||||
mimeType: "text/markdown",
|
mimeType: "text/markdown",
|
||||||
fileName: child.name,
|
fileName: child.name,
|
||||||
content:
|
content:
|
||||||
@@ -115,8 +115,8 @@ export default class ImportMarkdownZipTask extends ImportTask {
|
|||||||
output.documents.push({
|
output.documents.push({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
emoji,
|
emoji: icon,
|
||||||
icon: emoji,
|
icon,
|
||||||
text,
|
text,
|
||||||
collectionId,
|
collectionId,
|
||||||
parentDocumentId,
|
parentDocumentId,
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default class ImportNotionTask extends ImportTask {
|
|||||||
|
|
||||||
Logger.debug("task", `Processing ${name} as ${mimeType}`);
|
Logger.debug("task", `Processing ${name} as ${mimeType}`);
|
||||||
|
|
||||||
const { title, emoji, text } = await documentImporter({
|
const { title, icon, text } = await documentImporter({
|
||||||
mimeType: mimeType || "text/markdown",
|
mimeType: mimeType || "text/markdown",
|
||||||
fileName: name,
|
fileName: name,
|
||||||
content:
|
content:
|
||||||
@@ -130,8 +130,8 @@ export default class ImportNotionTask extends ImportTask {
|
|||||||
output.documents.push({
|
output.documents.push({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
emoji,
|
emoji: icon,
|
||||||
icon: emoji,
|
icon,
|
||||||
text,
|
text,
|
||||||
collectionId,
|
collectionId,
|
||||||
parentDocumentId,
|
parentDocumentId,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export type StructuredImportData = {
|
|||||||
collections: {
|
collections: {
|
||||||
id: string;
|
id: string;
|
||||||
urlId?: string;
|
urlId?: string;
|
||||||
color?: string;
|
color?: string | null;
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
sort?: CollectionSort;
|
sort?: CollectionSort;
|
||||||
permission?: CollectionPermission | null;
|
permission?: CollectionPermission | null;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { CollectionPermission } from "@shared/types";
|
import { CollectionPermission } from "@shared/types";
|
||||||
import { colorPalette } from "@shared/utils/collections";
|
|
||||||
import { Document, UserMembership, GroupPermission } from "@server/models";
|
import { Document, UserMembership, GroupPermission } from "@server/models";
|
||||||
import {
|
import {
|
||||||
buildUser,
|
buildUser,
|
||||||
@@ -182,6 +181,23 @@ describe("#collections.move", () => {
|
|||||||
expect(body.success).toBe(true);
|
expect(body.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should allow setting an emoji as icon", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const admin = await buildAdmin({ teamId: team.id });
|
||||||
|
const collection = await buildCollection({ teamId: team.id });
|
||||||
|
const res = await server.post("/api/collections.move", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
id: collection.id,
|
||||||
|
index: "P",
|
||||||
|
icon: "😁",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("should return error when icon is not valid", async () => {
|
it("should return error when icon is not valid", async () => {
|
||||||
const team = await buildTeam();
|
const team = await buildTeam();
|
||||||
const admin = await buildAdmin({ teamId: team.id });
|
const admin = await buildAdmin({ teamId: team.id });
|
||||||
@@ -1150,7 +1166,6 @@ describe("#collections.create", () => {
|
|||||||
expect(body.data.name).toBe("Test");
|
expect(body.data.name).toBe("Test");
|
||||||
expect(body.data.sort.field).toBe("index");
|
expect(body.data.sort.field).toBe("index");
|
||||||
expect(body.data.sort.direction).toBe("asc");
|
expect(body.data.sort.direction).toBe("asc");
|
||||||
expect(colorPalette.includes(body.data.color)).toBeTruthy();
|
|
||||||
expect(body.policies.length).toBe(1);
|
expect(body.policies.length).toBe(1);
|
||||||
expect(body.policies[0].abilities.read).toBeTruthy();
|
expect(body.policies[0].abilities.read).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
|
import emojiRegex from "emoji-regex";
|
||||||
import isUndefined from "lodash/isUndefined";
|
import isUndefined from "lodash/isUndefined";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { randomElement } from "@shared/random";
|
|
||||||
import { CollectionPermission, FileOperationFormat } from "@shared/types";
|
import { CollectionPermission, FileOperationFormat } from "@shared/types";
|
||||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||||
import { colorPalette } from "@shared/utils/collections";
|
|
||||||
import { Collection } from "@server/models";
|
import { Collection } from "@server/models";
|
||||||
|
import { zodEnumFromObjectKeys } from "@server/utils/zod";
|
||||||
import { ValidateColor, ValidateIndex } from "@server/validation";
|
import { ValidateColor, ValidateIndex } from "@server/validation";
|
||||||
import { BaseSchema, ProsemirrorSchema } from "../schema";
|
import { BaseSchema, ProsemirrorSchema } from "../schema";
|
||||||
|
|
||||||
function zodEnumFromObjectKeys<
|
|
||||||
TI extends Record<string, any>,
|
|
||||||
R extends string = TI extends Record<infer R, any> ? R : never
|
|
||||||
>(input: TI): z.ZodEnum<[R, ...R[]]> {
|
|
||||||
const [firstKey, ...otherKeys] = Object.keys(input) as [R, ...R[]];
|
|
||||||
return z.enum([firstKey, ...otherKeys]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const BaseIdSchema = z.object({
|
const BaseIdSchema = z.object({
|
||||||
/** Id of the collection to be updated */
|
/** Id of the collection to be updated */
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -27,7 +19,7 @@ export const CollectionsCreateSchema = BaseSchema.extend({
|
|||||||
color: z
|
color: z
|
||||||
.string()
|
.string()
|
||||||
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
||||||
.default(randomElement(colorPalette)),
|
.nullish(),
|
||||||
description: z.string().nullish(),
|
description: z.string().nullish(),
|
||||||
data: ProsemirrorSchema.nullish(),
|
data: ProsemirrorSchema.nullish(),
|
||||||
permission: z
|
permission: z
|
||||||
@@ -35,7 +27,12 @@ export const CollectionsCreateSchema = BaseSchema.extend({
|
|||||||
.nullish()
|
.nullish()
|
||||||
.transform((val) => (isUndefined(val) ? null : val)),
|
.transform((val) => (isUndefined(val) ? null : val)),
|
||||||
sharing: z.boolean().default(true),
|
sharing: z.boolean().default(true),
|
||||||
icon: zodEnumFromObjectKeys(IconLibrary.mapping).optional(),
|
icon: z
|
||||||
|
.union([
|
||||||
|
z.string().regex(emojiRegex()),
|
||||||
|
zodEnumFromObjectKeys(IconLibrary.mapping),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
sort: z
|
sort: z
|
||||||
.object({
|
.object({
|
||||||
field: z.union([z.literal("title"), z.literal("index")]),
|
field: z.union([z.literal("title"), z.literal("index")]),
|
||||||
@@ -174,7 +171,12 @@ export const CollectionsUpdateSchema = BaseSchema.extend({
|
|||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
description: z.string().nullish(),
|
description: z.string().nullish(),
|
||||||
data: ProsemirrorSchema.nullish(),
|
data: ProsemirrorSchema.nullish(),
|
||||||
icon: zodEnumFromObjectKeys(IconLibrary.mapping).nullish(),
|
icon: z
|
||||||
|
.union([
|
||||||
|
z.string().regex(emojiRegex()),
|
||||||
|
zodEnumFromObjectKeys(IconLibrary.mapping),
|
||||||
|
])
|
||||||
|
.nullish(),
|
||||||
permission: z.nativeEnum(CollectionPermission).nullish(),
|
permission: z.nativeEnum(CollectionPermission).nullish(),
|
||||||
color: z
|
color: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -2786,7 +2786,7 @@ describe("#documents.create", () => {
|
|||||||
expect(body.message).toEqual("parentDocumentId: Invalid uuid");
|
expect(body.message).toEqual("parentDocumentId: Invalid uuid");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create as a new document", async () => {
|
it("should create as a new document with emoji", async () => {
|
||||||
const team = await buildTeam();
|
const team = await buildTeam();
|
||||||
const user = await buildUser({ teamId: team.id });
|
const user = await buildUser({ teamId: team.id });
|
||||||
const collection = await buildCollection({
|
const collection = await buildCollection({
|
||||||
@@ -2809,6 +2809,34 @@ describe("#documents.create", () => {
|
|||||||
expect(newDocument!.parentDocumentId).toBe(null);
|
expect(newDocument!.parentDocumentId).toBe(null);
|
||||||
expect(newDocument!.collectionId).toBe(collection.id);
|
expect(newDocument!.collectionId).toBe(collection.id);
|
||||||
expect(newDocument!.emoji).toBe("🚢");
|
expect(newDocument!.emoji).toBe("🚢");
|
||||||
|
expect(newDocument!.icon).toBe("🚢");
|
||||||
|
expect(body.policies[0].abilities.update).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create as a new document with icon", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const user = await buildUser({ teamId: team.id });
|
||||||
|
const collection = await buildCollection({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
const res = await server.post("/api/documents.create", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
collectionId: collection.id,
|
||||||
|
icon: "🚢",
|
||||||
|
title: "new document",
|
||||||
|
text: "hello",
|
||||||
|
publish: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
const newDocument = await Document.findByPk(body.data.id);
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(newDocument!.parentDocumentId).toBe(null);
|
||||||
|
expect(newDocument!.collectionId).toBe(collection.id);
|
||||||
|
expect(newDocument!.emoji).toBe("🚢");
|
||||||
|
expect(newDocument!.icon).toBe("🚢");
|
||||||
expect(body.policies[0].abilities.update).toEqual(true);
|
expect(body.policies[0].abilities.update).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3094,7 +3122,7 @@ describe("#documents.update", () => {
|
|||||||
expect(res.status).toEqual(403);
|
expect(res.status).toEqual(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fail to update an invalid emoji value", async () => {
|
it("should fail to update an invalid icon value", async () => {
|
||||||
const user = await buildUser();
|
const user = await buildUser();
|
||||||
const document = await buildDocument({
|
const document = await buildDocument({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -3105,13 +3133,13 @@ describe("#documents.update", () => {
|
|||||||
body: {
|
body: {
|
||||||
token: user.getJwtToken(),
|
token: user.getJwtToken(),
|
||||||
id: document.id,
|
id: document.id,
|
||||||
emoji: ":)",
|
icon: ":)",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(res.status).toEqual(400);
|
expect(res.status).toEqual(400);
|
||||||
|
|
||||||
expect(body.message).toBe("emoji: Invalid");
|
expect(body.message).toBe("icon: Invalid");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should successfully update the emoji", async () => {
|
it("should successfully update the emoji", async () => {
|
||||||
@@ -3124,12 +3152,34 @@ describe("#documents.update", () => {
|
|||||||
body: {
|
body: {
|
||||||
token: user.getJwtToken(),
|
token: user.getJwtToken(),
|
||||||
id: document.id,
|
id: document.id,
|
||||||
emoji: "😂",
|
emoji: "🚢",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.emoji).toBe("😂");
|
expect(body.data.emoji).toBe("🚢");
|
||||||
|
expect(body.data.icon).toBe("🚢");
|
||||||
|
expect(body.data.color).toBeNull;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should successfully update the icon", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
const document = await buildDocument({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
const res = await server.post("/api/documents.update", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: document.id,
|
||||||
|
icon: "beaker",
|
||||||
|
color: "#FFDDEE",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.icon).toBe("beaker");
|
||||||
|
expect(body.data.color).toBe("#FFDDEE");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not add template to collection structure when publishing", async () => {
|
it("should not add template to collection structure when publishing", async () => {
|
||||||
|
|||||||
@@ -944,6 +944,8 @@ router.post(
|
|||||||
createdById: user.id,
|
createdById: user.id,
|
||||||
template: true,
|
template: true,
|
||||||
emoji: original.emoji,
|
emoji: original.emoji,
|
||||||
|
icon: original.icon,
|
||||||
|
color: original.color,
|
||||||
title: original.title,
|
title: original.title,
|
||||||
text: original.text,
|
text: original.text,
|
||||||
content: original.content,
|
content: original.content,
|
||||||
@@ -1041,6 +1043,7 @@ router.post(
|
|||||||
document,
|
document,
|
||||||
user,
|
user,
|
||||||
...input,
|
...input,
|
||||||
|
icon: input.icon ?? input.emoji,
|
||||||
publish,
|
publish,
|
||||||
collectionId,
|
collectionId,
|
||||||
insightsEnabled,
|
insightsEnabled,
|
||||||
@@ -1382,6 +1385,8 @@ router.post(
|
|||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
emoji,
|
emoji,
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
publish,
|
publish,
|
||||||
collectionId,
|
collectionId,
|
||||||
parentDocumentId,
|
parentDocumentId,
|
||||||
@@ -1445,7 +1450,8 @@ router.post(
|
|||||||
const document = await documentCreator({
|
const document = await documentCreator({
|
||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
emoji,
|
icon: icon ?? emoji,
|
||||||
|
color,
|
||||||
createdAt,
|
createdAt,
|
||||||
publish,
|
publish,
|
||||||
collectionId: collection?.id,
|
collectionId: collection?.id,
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import isEmpty from "lodash/isEmpty";
|
|||||||
import isUUID from "validator/lib/isUUID";
|
import isUUID from "validator/lib/isUUID";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { DocumentPermission, StatusFilter } from "@shared/types";
|
import { DocumentPermission, StatusFilter } from "@shared/types";
|
||||||
|
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||||
import { BaseSchema } from "@server/routes/api/schema";
|
import { BaseSchema } from "@server/routes/api/schema";
|
||||||
|
import { zodEnumFromObjectKeys } from "@server/utils/zod";
|
||||||
|
import { ValidateColor } from "@server/validation";
|
||||||
|
|
||||||
const DocumentsSortParamsSchema = z.object({
|
const DocumentsSortParamsSchema = z.object({
|
||||||
/** Specifies the attributes by which documents will be sorted in the list */
|
/** Specifies the attributes by which documents will be sorted in the list */
|
||||||
@@ -223,6 +226,20 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
|
|||||||
/** Emoji displayed alongside doc title */
|
/** Emoji displayed alongside doc title */
|
||||||
emoji: z.string().regex(emojiRegex()).nullish(),
|
emoji: z.string().regex(emojiRegex()).nullish(),
|
||||||
|
|
||||||
|
/** Icon displayed alongside doc title */
|
||||||
|
icon: z
|
||||||
|
.union([
|
||||||
|
z.string().regex(emojiRegex()),
|
||||||
|
zodEnumFromObjectKeys(IconLibrary.mapping),
|
||||||
|
])
|
||||||
|
.nullish(),
|
||||||
|
|
||||||
|
/** Icon color */
|
||||||
|
color: z
|
||||||
|
.string()
|
||||||
|
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
||||||
|
.nullish(),
|
||||||
|
|
||||||
/** Boolean to denote if the doc should occupy full width */
|
/** Boolean to denote if the doc should occupy full width */
|
||||||
fullWidth: z.boolean().optional(),
|
fullWidth: z.boolean().optional(),
|
||||||
|
|
||||||
@@ -319,7 +336,21 @@ export const DocumentsCreateSchema = BaseSchema.extend({
|
|||||||
text: z.string().default(""),
|
text: z.string().default(""),
|
||||||
|
|
||||||
/** Emoji displayed alongside doc title */
|
/** Emoji displayed alongside doc title */
|
||||||
emoji: z.string().regex(emojiRegex()).optional(),
|
emoji: z.string().regex(emojiRegex()).nullish(),
|
||||||
|
|
||||||
|
/** Icon displayed alongside doc title */
|
||||||
|
icon: z
|
||||||
|
.union([
|
||||||
|
z.string().regex(emojiRegex()),
|
||||||
|
zodEnumFromObjectKeys(IconLibrary.mapping),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
/** Icon color */
|
||||||
|
color: z
|
||||||
|
.string()
|
||||||
|
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
||||||
|
.nullish(),
|
||||||
|
|
||||||
/** Boolean to denote if the doc should be published */
|
/** Boolean to denote if the doc should be published */
|
||||||
publish: z.boolean().optional(),
|
publish: z.boolean().optional(),
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default async function main(exit = false, limit = 1000) {
|
|||||||
try {
|
try {
|
||||||
const { emoji, strippedTitle } = parseTitle(document.title);
|
const { emoji, strippedTitle } = parseTitle(document.title);
|
||||||
if (emoji) {
|
if (emoji) {
|
||||||
document.emoji = emoji;
|
document.icon = emoji;
|
||||||
document.title = strippedTitle;
|
document.title = strippedTitle;
|
||||||
|
|
||||||
if (document.changed()) {
|
if (document.changed()) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default async function main(exit = false, limit = 1000) {
|
|||||||
try {
|
try {
|
||||||
const { emoji, strippedTitle } = parseTitle(revision.title);
|
const { emoji, strippedTitle } = parseTitle(revision.title);
|
||||||
if (emoji) {
|
if (emoji) {
|
||||||
revision.emoji = emoji;
|
revision.icon = emoji;
|
||||||
revision.title = strippedTitle;
|
revision.title = strippedTitle;
|
||||||
|
|
||||||
if (revision.changed()) {
|
if (revision.changed()) {
|
||||||
|
|||||||
@@ -468,7 +468,13 @@ export type DocumentJSONExport = {
|
|||||||
id: string;
|
id: string;
|
||||||
urlId: string;
|
urlId: string;
|
||||||
title: string;
|
title: string;
|
||||||
emoji: string | null;
|
/**
|
||||||
|
* For backward compatibility, maintain the `emoji` field.
|
||||||
|
* Future exports will use the `icon` field.
|
||||||
|
* */
|
||||||
|
emoji?: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
color: string | null;
|
||||||
data: Record<string, any>;
|
data: Record<string, any>;
|
||||||
createdById: string;
|
createdById: string;
|
||||||
createdByName: string;
|
createdByName: string;
|
||||||
@@ -498,7 +504,7 @@ export type CollectionJSONExport = {
|
|||||||
data?: ProsemirrorData | null;
|
data?: ProsemirrorData | null;
|
||||||
description?: ProsemirrorData | null;
|
description?: ProsemirrorData | null;
|
||||||
permission?: CollectionPermission | null;
|
permission?: CollectionPermission | null;
|
||||||
color: string;
|
color?: string | null;
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
sort: CollectionSort;
|
sort: CollectionSort;
|
||||||
documentStructure: NavigationNode[] | null;
|
documentStructure: NavigationNode[] | null;
|
||||||
|
|||||||
9
server/utils/zod.ts
Normal file
9
server/utils/zod.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export function zodEnumFromObjectKeys<
|
||||||
|
TI extends Record<string, any>,
|
||||||
|
R extends string = TI extends Record<infer R, any> ? R : never
|
||||||
|
>(input: TI): z.ZodEnum<[R, ...R[]]> {
|
||||||
|
const [firstKey, ...otherKeys] = Object.keys(input) as [R, ...R[]];
|
||||||
|
return z.enum([firstKey, ...otherKeys]);
|
||||||
|
}
|
||||||
@@ -207,8 +207,6 @@
|
|||||||
"Title": "Title",
|
"Title": "Title",
|
||||||
"Published": "Published",
|
"Published": "Published",
|
||||||
"Include nested documents": "Include nested documents",
|
"Include nested documents": "Include nested documents",
|
||||||
"Emoji Picker": "Emoji Picker",
|
|
||||||
"Remove": "Remove",
|
|
||||||
"Module failed to load": "Module failed to load",
|
"Module failed to load": "Module failed to load",
|
||||||
"Loading Failed": "Loading Failed",
|
"Loading Failed": "Loading Failed",
|
||||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.",
|
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.",
|
||||||
@@ -244,11 +242,27 @@
|
|||||||
"Group members": "Group members",
|
"Group members": "Group members",
|
||||||
"{{authorName}} created <3></3>": "{{authorName}} created <3></3>",
|
"{{authorName}} created <3></3>": "{{authorName}} created <3></3>",
|
||||||
"{{authorName}} opened <3></3>": "{{authorName}} opened <3></3>",
|
"{{authorName}} opened <3></3>": "{{authorName}} opened <3></3>",
|
||||||
|
"Search emoji": "Search emoji",
|
||||||
|
"Search icons": "Search icons",
|
||||||
|
"Choose default skin tone": "Choose default skin tone",
|
||||||
"Show menu": "Show menu",
|
"Show menu": "Show menu",
|
||||||
"Choose an icon": "Choose an icon",
|
"Icon Picker": "Icon Picker",
|
||||||
"Filter": "Filter",
|
"Icons": "Icons",
|
||||||
"Loading": "Loading",
|
"Emojis": "Emojis",
|
||||||
|
"Remove": "Remove",
|
||||||
|
"All": "All",
|
||||||
|
"Frequently Used": "Frequently Used",
|
||||||
|
"Search Results": "Search Results",
|
||||||
|
"Smileys & People": "Smileys & People",
|
||||||
|
"Animals & Nature": "Animals & Nature",
|
||||||
|
"Food & Drink": "Food & Drink",
|
||||||
|
"Activity": "Activity",
|
||||||
|
"Travel & Places": "Travel & Places",
|
||||||
|
"Objects": "Objects",
|
||||||
|
"Symbols": "Symbols",
|
||||||
|
"Flags": "Flags",
|
||||||
"Select a color": "Select a color",
|
"Select a color": "Select a color",
|
||||||
|
"Loading": "Loading",
|
||||||
"Search": "Search",
|
"Search": "Search",
|
||||||
"Permission": "Permission",
|
"Permission": "Permission",
|
||||||
"View only": "View only",
|
"View only": "View only",
|
||||||
@@ -765,7 +779,6 @@
|
|||||||
"We were unable to find the page you’re looking for.": "We were unable to find the page you’re looking for.",
|
"We were unable to find the page you’re looking for.": "We were unable to find the page you’re looking for.",
|
||||||
"Search titles only": "Search titles only",
|
"Search titles only": "Search titles only",
|
||||||
"No documents found for your search filters.": "No documents found for your search filters.",
|
"No documents found for your search filters.": "No documents found for your search filters.",
|
||||||
"Search Results": "Search Results",
|
|
||||||
"API key copied to clipboard": "API key copied to clipboard",
|
"API key copied to clipboard": "API key copied to clipboard",
|
||||||
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
|
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
|
||||||
"Personal keys": "Personal keys",
|
"Personal keys": "Personal keys",
|
||||||
@@ -858,7 +871,6 @@
|
|||||||
"New group": "New group",
|
"New group": "New group",
|
||||||
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
|
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
|
||||||
"No groups have been created yet": "No groups have been created yet",
|
"No groups have been created yet": "No groups have been created yet",
|
||||||
"All": "All",
|
|
||||||
"Create a group": "Create a group",
|
"Create a group": "Create a group",
|
||||||
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.",
|
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.",
|
||||||
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)": "Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)",
|
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)": "Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)",
|
||||||
@@ -869,6 +881,7 @@
|
|||||||
"Enterprise": "Enterprise",
|
"Enterprise": "Enterprise",
|
||||||
"Recent imports": "Recent imports",
|
"Recent imports": "Recent imports",
|
||||||
"Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {{signinMethods}} but haven’t signed in yet.": "Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {{signinMethods}} but haven’t signed in yet.",
|
"Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {{signinMethods}} but haven’t signed in yet.": "Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {{signinMethods}} but haven’t signed in yet.",
|
||||||
|
"Filter": "Filter",
|
||||||
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
|
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
|
||||||
"Document updated": "Document updated",
|
"Document updated": "Document updated",
|
||||||
"Receive a notification when a document you are subscribed to is edited": "Receive a notification when a document you are subscribed to is edited",
|
"Receive a notification when a document you are subscribed to is edited": "Receive a notification when a document you are subscribed to is edited",
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ const buildBaseTheme = (input: Partial<Colors>) => {
|
|||||||
"-apple-system, BlinkMacSystemFont, Inter, 'Segoe UI', Roboto, Oxygen, sans-serif",
|
"-apple-system, BlinkMacSystemFont, Inter, 'Segoe UI', Roboto, Oxygen, sans-serif",
|
||||||
fontFamilyMono:
|
fontFamilyMono:
|
||||||
"'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace",
|
"'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace",
|
||||||
|
fontFamilyEmoji:
|
||||||
|
"Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Segoe UI, Twemoji Mozilla, Noto Color Emoji, Android Emoji",
|
||||||
fontWeightRegular: 400,
|
fontWeightRegular: 400,
|
||||||
fontWeightMedium: 500,
|
fontWeightMedium: 500,
|
||||||
fontWeightBold: 600,
|
fontWeightBold: 600,
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ export type NavigationNode = {
|
|||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
children: NavigationNode[];
|
children: NavigationNode[];
|
||||||
isDraft?: boolean;
|
isDraft?: boolean;
|
||||||
collectionId?: string;
|
collectionId?: string;
|
||||||
@@ -405,3 +407,43 @@ export type ProsemirrorDoc = {
|
|||||||
type: "doc";
|
type: "doc";
|
||||||
content: ProsemirrorData[];
|
content: ProsemirrorData[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum IconType {
|
||||||
|
Outline = "outline",
|
||||||
|
Emoji = "emoji",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum EmojiCategory {
|
||||||
|
People = "People",
|
||||||
|
Nature = "Nature",
|
||||||
|
Foods = "Foods",
|
||||||
|
Activity = "Activity",
|
||||||
|
Places = "Places",
|
||||||
|
Objects = "Objects",
|
||||||
|
Symbols = "Symbols",
|
||||||
|
Flags = "Flags",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum EmojiSkinTone {
|
||||||
|
Default = "Default",
|
||||||
|
Light = "Light",
|
||||||
|
MediumLight = "MediumLight",
|
||||||
|
Medium = "Medium",
|
||||||
|
MediumDark = "MediumDark",
|
||||||
|
Dark = "Dark",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Emoji = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EmojiVariants = {
|
||||||
|
[EmojiSkinTone.Default]: Emoji;
|
||||||
|
[EmojiSkinTone.Light]?: Emoji;
|
||||||
|
[EmojiSkinTone.MediumLight]?: Emoji;
|
||||||
|
[EmojiSkinTone.Medium]?: Emoji;
|
||||||
|
[EmojiSkinTone.MediumDark]?: Emoji;
|
||||||
|
[EmojiSkinTone.Dark]?: Emoji;
|
||||||
|
};
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export class IconLibrary {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter((icon: string | undefined): icon is string => !!icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const sortNavigationNodes = (
|
|||||||
|
|
||||||
export const colorPalette = [
|
export const colorPalette = [
|
||||||
"#4E5C6E",
|
"#4E5C6E",
|
||||||
"#0366d6",
|
"#0366D6",
|
||||||
"#9E5CF7",
|
"#9E5CF7",
|
||||||
"#FF825C",
|
"#FF825C",
|
||||||
"#FF5C80",
|
"#FF5C80",
|
||||||
|
|||||||
136
shared/utils/emoji.ts
Normal file
136
shared/utils/emoji.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import RawData from "@emoji-mart/data";
|
||||||
|
import type { EmojiMartData, Skin } from "@emoji-mart/data";
|
||||||
|
import { init, Data } from "emoji-mart";
|
||||||
|
import FuzzySearch from "fuzzy-search";
|
||||||
|
import capitalize from "lodash/capitalize";
|
||||||
|
import sortBy from "lodash/sortBy";
|
||||||
|
import { Emoji, EmojiCategory, EmojiSkinTone, EmojiVariants } from "../types";
|
||||||
|
import { isMac } from "./browser";
|
||||||
|
|
||||||
|
const isMacEnv = isMac();
|
||||||
|
|
||||||
|
init({ data: RawData });
|
||||||
|
|
||||||
|
// Data has the pre-processed "search" terms.
|
||||||
|
const TypedData = Data as EmojiMartData;
|
||||||
|
|
||||||
|
const flagEmojiIds =
|
||||||
|
TypedData.categories
|
||||||
|
.filter(({ id }) => id === EmojiCategory.Flags.toLowerCase())
|
||||||
|
.map(({ emojis }) => emojis)[0] ?? [];
|
||||||
|
|
||||||
|
const Categories = TypedData.categories.filter(
|
||||||
|
({ id }) => isMacEnv || capitalize(id) !== EmojiCategory.Flags
|
||||||
|
);
|
||||||
|
|
||||||
|
const Emojis = Object.fromEntries(
|
||||||
|
Object.entries(TypedData.emojis).filter(
|
||||||
|
([id]) => isMacEnv || !flagEmojiIds.includes(id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const searcher = new FuzzySearch(Object.values(Emojis), ["search"], {
|
||||||
|
caseSensitive: false,
|
||||||
|
sort: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Codes defined by unicode.org
|
||||||
|
const SKINTONE_CODE_TO_ENUM = {
|
||||||
|
"1f3fb": EmojiSkinTone.Light,
|
||||||
|
"1f3fc": EmojiSkinTone.MediumLight,
|
||||||
|
"1f3fd": EmojiSkinTone.Medium,
|
||||||
|
"1f3fe": EmojiSkinTone.MediumDark,
|
||||||
|
"1f3ff": EmojiSkinTone.Dark,
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetVariantsProps = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
skins: Skin[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVariants = ({ id, name, skins }: GetVariantsProps): EmojiVariants =>
|
||||||
|
skins.reduce((obj, skin) => {
|
||||||
|
const skinToneCode = skin.unified.split("-")[1];
|
||||||
|
const skinToneType =
|
||||||
|
SKINTONE_CODE_TO_ENUM[skinToneCode] ?? EmojiSkinTone.Default;
|
||||||
|
obj[skinToneType] = { id, name, value: skin.native } satisfies Emoji;
|
||||||
|
return obj;
|
||||||
|
}, {} as EmojiVariants);
|
||||||
|
|
||||||
|
const EMOJI_ID_TO_VARIANTS = Object.entries(Emojis).reduce(
|
||||||
|
(obj, [id, emoji]) => {
|
||||||
|
obj[id] = getVariants({
|
||||||
|
id,
|
||||||
|
name: emoji.name,
|
||||||
|
skins: emoji.skins,
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
{} as Record<string, EmojiVariants>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CATEGORY_TO_EMOJI_IDS: Record<EmojiCategory, string[]> =
|
||||||
|
Categories.reduce((obj, { id, emojis }) => {
|
||||||
|
const category = EmojiCategory[capitalize(id)];
|
||||||
|
if (!category) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
obj[category] = emojis;
|
||||||
|
return obj;
|
||||||
|
}, {} as Record<EmojiCategory, string[]>);
|
||||||
|
|
||||||
|
export const getEmojis = ({
|
||||||
|
ids,
|
||||||
|
skinTone,
|
||||||
|
}: {
|
||||||
|
ids: string[];
|
||||||
|
skinTone: EmojiSkinTone;
|
||||||
|
}): Emoji[] =>
|
||||||
|
ids.map(
|
||||||
|
(id) =>
|
||||||
|
EMOJI_ID_TO_VARIANTS[id][skinTone] ??
|
||||||
|
EMOJI_ID_TO_VARIANTS[id][EmojiSkinTone.Default]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getEmojisWithCategory = ({
|
||||||
|
skinTone,
|
||||||
|
}: {
|
||||||
|
skinTone: EmojiSkinTone;
|
||||||
|
}): Record<EmojiCategory, Emoji[]> =>
|
||||||
|
Object.keys(CATEGORY_TO_EMOJI_IDS).reduce((obj, category: EmojiCategory) => {
|
||||||
|
const emojiIds = CATEGORY_TO_EMOJI_IDS[category];
|
||||||
|
const emojis = emojiIds.map(
|
||||||
|
(emojiId) =>
|
||||||
|
EMOJI_ID_TO_VARIANTS[emojiId][skinTone] ??
|
||||||
|
EMOJI_ID_TO_VARIANTS[emojiId][EmojiSkinTone.Default]
|
||||||
|
);
|
||||||
|
obj[category] = emojis;
|
||||||
|
return obj;
|
||||||
|
}, {} as Record<EmojiCategory, Emoji[]>);
|
||||||
|
|
||||||
|
export const getEmojiVariants = ({ id }: { id: string }) =>
|
||||||
|
EMOJI_ID_TO_VARIANTS[id];
|
||||||
|
|
||||||
|
export const search = ({
|
||||||
|
query,
|
||||||
|
skinTone,
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
skinTone?: EmojiSkinTone;
|
||||||
|
}) => {
|
||||||
|
const queryLowercase = query.toLowerCase();
|
||||||
|
const emojiSkinTone = skinTone ?? EmojiSkinTone.Default;
|
||||||
|
|
||||||
|
const matchedEmojis = searcher
|
||||||
|
.search(queryLowercase)
|
||||||
|
.map(
|
||||||
|
(emoji) =>
|
||||||
|
EMOJI_ID_TO_VARIANTS[emoji.id][emojiSkinTone] ??
|
||||||
|
EMOJI_ID_TO_VARIANTS[emoji.id][EmojiSkinTone.Default]
|
||||||
|
);
|
||||||
|
return sortBy(matchedEmojis, (emoji) => {
|
||||||
|
const nlc = emoji.name.toLowerCase();
|
||||||
|
return query === nlc ? -1 : nlc.startsWith(queryLowercase) ? 0 : 1;
|
||||||
|
});
|
||||||
|
};
|
||||||
13
shared/utils/icon.ts
Normal file
13
shared/utils/icon.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { IconType } from "../types";
|
||||||
|
import { IconLibrary } from "./IconLibrary";
|
||||||
|
|
||||||
|
const outlineIconNames = new Set(Object.keys(IconLibrary.mapping));
|
||||||
|
|
||||||
|
export const determineIconType = (
|
||||||
|
icon?: string | null
|
||||||
|
): IconType | undefined => {
|
||||||
|
if (!icon) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return outlineIconNames.has(icon) ? IconType.Outline : IconType.Emoji;
|
||||||
|
};
|
||||||
@@ -2410,11 +2410,6 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.2.1.tgz#0ad70c662e3bc603e23e7d98413bd1e64c4fcb6c"
|
resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.2.1.tgz#0ad70c662e3bc603e23e7d98413bd1e64c4fcb6c"
|
||||||
integrity sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==
|
integrity sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==
|
||||||
|
|
||||||
"@emoji-mart/react@^1.1.1":
|
|
||||||
version "1.1.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/@emoji-mart/react/-/react-1.1.1.tgz#ddad52f93a25baf31c5383c3e7e4c6e05554312a"
|
|
||||||
integrity "sha1-3a1S+ToluvMcU4PD5+TG4FVUMSo= sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g=="
|
|
||||||
|
|
||||||
"@emotion/is-prop-valid@^0.8.2":
|
"@emotion/is-prop-valid@^0.8.2":
|
||||||
version "0.8.8"
|
version "0.8.8"
|
||||||
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
|
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
|
||||||
@@ -13375,7 +13370,7 @@ react-waypoint@^10.3.0:
|
|||||||
react-window@^1.8.10:
|
react-window@^1.8.10:
|
||||||
version "1.8.10"
|
version "1.8.10"
|
||||||
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03"
|
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03"
|
||||||
integrity "sha1-nmsIVIMWgUtEP3ACsc+P06G93gM= sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg=="
|
integrity sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime" "^7.0.0"
|
"@babel/runtime" "^7.0.0"
|
||||||
memoize-one ">=3.1.1 <6"
|
memoize-one ">=3.1.1 <6"
|
||||||
|
|||||||
Reference in New Issue
Block a user