feat: Unified icon picker (#7038)

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

View File

@@ -11,7 +11,7 @@ import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import IconPicker from "~/components/IconPicker";
import Icon from "~/components/Icon";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import Switch from "~/components/Switch";
@@ -20,10 +20,12 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
export interface FormData {
name: string;
icon: string;
color: string;
color: string | null;
sharing: boolean;
permission: CollectionPermission | undefined;
}
@@ -37,7 +39,16 @@ export const CollectionForm = observer(function CollectionForm_({
}) {
const team = useCurrentTeam();
const { t } = useTranslation();
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = React.useMemo(
() => collection?.color ?? randomElement(colorPalette),
[collection?.color]
);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
register,
handleSubmit: formHandleSubmit,
@@ -53,7 +64,7 @@ export const CollectionForm = observer(function CollectionForm_({
icon: collection?.icon,
sharing: collection?.sharing ?? true,
permission: collection?.permission,
color: collection?.color ?? randomElement(colorPalette),
color: iconColor,
},
});
@@ -70,20 +81,20 @@ export const CollectionForm = observer(function CollectionForm_({
"collection"
);
}
}, [values.name, collection]);
}, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]);
React.useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]);
const handleIconPickerChange = React.useCallback(
(color: string, icon: string) => {
const handleIconChange = React.useCallback(
(icon: string, color: string | null) => {
if (icon !== values.icon) {
setFocus("name");
}
setValue("color", color);
setValue("icon", icon);
setValue("color", color);
},
[setFocus, setValue, values.icon]
);
@@ -105,13 +116,16 @@ export const CollectionForm = observer(function CollectionForm_({
maxLength: CollectionValidation.maxNameLength,
})}
prefix={
<StyledIconPicker
onOpen={setHasOpenedIconPicker}
onChange={handleIconPickerChange}
initial={values.name[0]}
color={values.color}
icon={values.icon}
/>
<React.Suspense fallback={fallbackIcon}>
<StyledIconPicker
icon={values.icon}
color={values.color ?? iconColor}
initial={values.name[0]}
popoverPosition="right"
onOpen={setHasOpenedIconPicker}
onChange={handleIconChange}
/>
</React.Suspense>
}
autoComplete="off"
autoFocus

View File

@@ -6,6 +6,7 @@ import styled from "styled-components";
import type { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import useStores from "~/hooks/useStores";
import { MenuInternalLink } from "~/types";
@@ -15,7 +16,6 @@ import {
settingsPath,
trashPath,
} from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = {
children?: React.ReactNode;
@@ -106,9 +106,9 @@ const DocumentBreadcrumb: React.FC<Props> = ({
path.slice(0, -1).forEach((node: NavigationNode) => {
output.push({
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
@@ -144,6 +144,10 @@ const DocumentBreadcrumb: React.FC<Props> = ({
);
};
const StyledIcon = styled(Icon)`
margin-right: 2px;
`;
const SmallSlash = styled(GoToIcon)`
width: 12px;
height: 12px;

View File

@@ -9,15 +9,17 @@ import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { s, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import CollectionIcon from "./Icons/CollectionIcon";
import EmojiIcon from "./Icons/EmojiIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -52,6 +54,8 @@ function DocumentCard(props: Props) {
disabled: !isDraggable || !canUpdatePin,
});
const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji;
const style = {
transform: CSS.Transform.toString(transform),
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" />
</Fold>
{document.emoji ? (
<Squircle color={theme.slateLight}>
<EmojiIcon emoji={document.emoji} size={24} />
</Squircle>
{document.icon ? (
<DocumentSquircle
icon={document.icon}
color={document.color ?? undefined}
/>
) : (
<Squircle color={collection?.color}>
<Squircle
color={
collection?.color ??
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
}
>
{collection?.icon &&
collection?.icon !== "letter" &&
collection?.icon !== "collection" &&
@@ -127,8 +137,8 @@ function DocumentCard(props: Props) {
)}
<div>
<Heading dir={document.dir}>
{document.emoji
? document.titleWithDefault.replace(document.emoji, "")
{hasEmojiInTitle
? document.titleWithDefault.replace(document.icon!, "")
: document.titleWithDefault}
</Heading>
<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)`
flex-shrink: 0;
`;

View File

@@ -18,8 +18,8 @@ import { NavigationNode } from "@shared/types";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
@@ -216,25 +216,30 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
}) => {
const node = data[index];
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) {
const col = collections.get(node.collectionId as string);
icon = col && (
renderedIcon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
title = node.title;
} else {
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;
if (emoji) {
icon = <EmojiIcon emoji={emoji} />;
if (icon) {
renderedIcon = <Icon value={icon} color={color} />;
} else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />;
renderedIcon = <StarredIcon color={theme.yellow} />;
} else {
icon = <DocumentIcon color={theme.textSecondary} />;
renderedIcon = <DocumentIcon color={theme.textSecondary} />;
}
path = ancestors(node)
@@ -254,7 +259,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
icon={icon}
icon={renderedIcon}
title={title}
path={path}
/>
@@ -275,7 +280,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
selected={isSelected(index)}
active={activeNode === index}
expanded={isExpanded(index)}
icon={icon}
icon={renderedIcon}
title={title}
depth={node.depth as number}
hasChildren={hasChildren(index)}

View File

@@ -15,6 +15,7 @@ import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
@@ -23,7 +24,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles";
import { documentPath } from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = {
document: Document;
@@ -97,9 +97,9 @@ function DocumentListItem(
>
<Content>
<Heading dir={document.dir}>
{document.emoji && (
{document.icon && (
<>
<EmojiIcon emoji={document.emoji} size={24} />
<Icon value={document.icon} color={document.color ?? undefined} />
&nbsp;
</>
)}

View File

@@ -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` : "")}
`;

View File

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

View File

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

View 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;

View 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;
`;

View 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;

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

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

View 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")};
}
`;

View 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;

View 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;
`};
}
`;

View 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;

View 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;

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

View File

@@ -2,10 +2,11 @@ import { observer } from "mobx-react";
import { CollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
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 Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
type Props = {
/** The collection to show an icon for */
@@ -16,6 +17,7 @@ type Props = {
size?: number;
/** The color of the icon, defaults to the collection color */
color?: string;
className?: string;
};
function ResolvedCollectionIcon({
@@ -23,35 +25,41 @@ function ResolvedCollectionIcon({
color: inputColor,
expanded,
size,
className,
}: Props) {
const { ui } = useStores();
// 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.
const color =
inputColor ||
(ui.resolvedTheme === "dark" && collection.color !== "currentColor"
? getLuminance(collection.color) > 0.09
? collection.color
: "currentColor"
: collection.color);
if (!collection.icon || collection.icon === "collection") {
// 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.
const collectionColor = collection.color ?? randomElement(colorPalette);
const color =
inputColor ||
(ui.resolvedTheme === "dark" && collectionColor !== "currentColor"
? getLuminance(collectionColor) > 0.09
? collectionColor
: "currentColor"
: collectionColor);
if (collection.icon && collection.icon !== "collection") {
try {
const Component = IconLibrary.getComponent(collection.icon);
return (
<Component color={color} size={size}>
{collection.initial}
</Component>
);
} catch (error) {
Logger.warn("Failed to render custom icon", {
icon: collection.icon,
});
}
return (
<CollectionIcon
color={color}
expanded={expanded}
size={size}
className={className}
/>
);
}
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);

View File

@@ -1,11 +1,13 @@
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = {
/** The emoji to render */
emoji: string;
/** The size of the emoji, 24px is default to match standard icons */
size?: number;
className?: string;
};
/**
@@ -15,19 +17,28 @@ type Props = {
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
return (
<Span $size={size} {...rest}>
{emoji}
<SVG size={size} emoji={emoji} />
</Span>
);
}
const Span = styled.span<{ $size: number }>`
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
flex-shrink: 0;
font-family: ${s("fontFamilyEmoji")};
display: inline-block;
width: ${(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>
);

View File

@@ -20,15 +20,18 @@ type Props = PopoverProps & {
hide: () => void;
};
const Popover: React.FC<Props> = ({
children,
shrink,
width = 380,
scrollable = true,
flex,
mobilePosition,
...rest
}: Props) => {
const Popover = (
{
children,
shrink,
width = 380,
scrollable = true,
flex,
mobilePosition,
...rest
}: Props,
ref: React.Ref<HTMLDivElement>
) => {
const isMobile = useMobile();
// Custom Escape handler rather than using hideOnEsc from reakit so we can
@@ -50,6 +53,7 @@ const Popover: React.FC<Props> = ({
return (
<Dialog {...rest} modal>
<Contents
ref={ref}
$shrink={shrink}
$scrollable={scrollable}
$flex={flex}
@@ -64,6 +68,7 @@ const Popover: React.FC<Props> = ({
return (
<StyledPopover {...rest} hideOnEsc={false} hideOnClickOutside>
<Contents
ref={ref}
$shrink={shrink}
$width={width}
$scrollable={scrollable}
@@ -123,4 +128,4 @@ const Contents = styled.div<ContentsProps>`
`};
`;
export default Popover;
export default React.forwardRef(Popover);

View File

@@ -4,7 +4,8 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
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 Document from "~/models/Document";
import Flex from "~/components/Flex";
@@ -54,15 +55,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
/>
) : usersInCollection ? (
<ListItem
image={
<Squircle color={collection.color} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={16}
/>
</Squircle>
}
image={<CollectionSquircle collection={collection} />}
title={collection.name}
subtitle={t("Everyone in the collection")}
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) {
const { users, memberships } = useStores();
const { request } = useRequest(() =>

View File

@@ -14,6 +14,7 @@ import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
@@ -282,6 +283,8 @@ function InnerDocumentLink(
const title =
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
t("Untitled");
const icon = document?.icon || node.icon;
const color = document?.color || node.color;
const isExpanded = expanded && !isDragging;
const hasChildren = nodeChildren.length > 0;
@@ -324,7 +327,7 @@ function InnerDocumentLink(
starred: inStarredSection,
},
}}
emoji={document?.emoji || node.emoji}
icon={icon && <Icon value={icon} color={color} />}
label={
<EditableTitle
title={title}

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { descendants } from "~/utils/tree";
@@ -111,7 +112,7 @@ function DocumentLink(
}}
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
emoji={node.emoji}
icon={node.icon && <Icon value={node.icon} color={node.color} />}
label={title}
depth={depth}
exact={false}

View File

@@ -2,7 +2,8 @@ import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
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 Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean";
@@ -78,10 +79,11 @@ function SharedWithMeLink({ userMembership }: Props) {
return null;
}
const { emoji } = document;
const label = emoji
? document.title.replace(emoji, "")
: document.titleWithDefault;
const { icon: docIcon } = document;
const label =
determineIconType(docIcon) === IconType.Emoji
? document.title.replace(docIcon!, "")
: document.titleWithDefault;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;

View File

@@ -5,7 +5,6 @@ import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
import useUnmount from "~/hooks/useUnmount";
@@ -27,7 +26,6 @@ type Props = Omit<NavLinkProps, "to"> & {
onClickIntent?: () => void;
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
icon?: React.ReactNode;
emoji?: string | null;
label?: React.ReactNode;
menu?: React.ReactNode;
unreadBadge?: boolean;
@@ -52,7 +50,6 @@ function SidebarLink(
onClick,
onClickIntent,
to,
emoji,
label,
active,
isActiveDrop,
@@ -142,7 +139,6 @@ function SidebarLink(
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
{emoji && <EmojiIcon emoji={emoji} />}
<Label>{label}</Label>
{unreadBadge && <UnreadBadge />}
</Content>

View File

@@ -1,7 +1,7 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useStores from "~/hooks/useStores";
interface SidebarItem {
@@ -21,7 +21,11 @@ export function useSidebarLabelAndIcon(
if (document) {
return {
label: document.titleWithDefault,
icon: document.emoji ? <EmojiIcon emoji={document.emoji} /> : icon,
icon: document.icon ? (
<Icon value={document.icon} color={document.color ?? undefined} />
) : (
icon
),
};
}
}

View File

@@ -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 sortBy from "lodash/sortBy";
import React from "react";
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 SuggestionsMenu, {
Props as SuggestionsMenuProps,
@@ -19,13 +15,6 @@ type Emoji = {
attrs: { markup: string; "data-name": string };
};
init({
data,
noCountryFlags: isMac() ? false : undefined,
});
let searcher: FuzzySearch<TEmoji>;
type Props = Omit<
SuggestionsMenuProps<Emoji>,
"renderMenuItem" | "items" | "embeds" | "trigger"
@@ -34,36 +23,26 @@ type Props = Omit<
const EmojiMenu = (props: Props) => {
const { search = "" } = props;
if (!searcher) {
searcher = new FuzzySearch(Object.values(Data.emojis), ["search"], {
caseSensitive: false,
sort: true,
});
}
const items = React.useMemo(
() =>
emojiSearch({ query: search })
.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(() => {
const n = search.toLowerCase();
return sortBy(searcher.search(n), (item) => {
const nlc = item.name.toLowerCase();
return nlc === n ? -1 : nlc.startsWith(n) ? 0 : 1;
})
.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.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 {
name: "emoji",
title: emoji,
description: capitalize(item.name.toLowerCase()),
emoji,
attrs: { markup: shortcode, "data-name": shortcode },
};
})
.slice(0, 15),
[search]
);
return (
<SuggestionsMenu

View File

@@ -7,6 +7,8 @@ import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { isInCode } from "@shared/editor/queries/isInCode";
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 { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
@@ -179,9 +181,12 @@ export default class PasteHandler extends Extension {
if (document) {
const { hash } = new URL(text);
const title = `${
document.emoji ? document.emoji + " " : ""
}${document.titleWithDefault}`;
const hasEmoji =
determineIconType(document.icon) === IconType.Emoji;
const title = `${hasEmoji ? document.icon + " " : ""}${
document.titleWithDefault
}`;
insertLink(`${document.path}${hash}`, title);
}
})

View File

@@ -1,6 +1,6 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import Icon from "~/components/Icon";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import history from "~/utils/history";
@@ -21,8 +21,8 @@ const useTemplatesActions = () => {
name: item.titleWithDefault,
analyticsName: "New document",
section: DocumentSection,
icon: item.emoji ? (
<EmojiIcon emoji={item.emoji} />
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<NewDocumentIcon />
),

View File

@@ -8,7 +8,7 @@ import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Separator from "~/components/ContextMenu/Separator";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import Icon from "~/components/Icon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { replaceTitleVariables } from "~/utils/date";
@@ -43,7 +43,11 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
key={template.id}
onClick={() => onSelectTemplate(template)}
icon={
template.emoji ? <EmojiIcon emoji={template.emoji} /> : <DocumentIcon />
template.icon ? (
<Icon value={template.icon} color={template.color ?? undefined} />
) : (
<DocumentIcon />
)
}
{...menu}
>

View File

@@ -40,18 +40,18 @@ export default class Collection extends ParanoidModel {
data: ProsemirrorData;
/**
* An emoji to use as the collection icon.
* An icon (or) emoji to use as the collection icon.
*/
@Field
@observable
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
@observable
color: string;
color?: string | null;
/**
* The default permission for workspace users.

View File

@@ -129,11 +129,18 @@ export default class Document extends ParanoidModel {
title: string;
/**
* An emoji to use as the document icon.
* An icon (or) emoji to use as the document icon.
*/
@Field
@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.

View File

@@ -22,8 +22,11 @@ class Revision extends Model {
/** Prosemirror data of the content when revision was created */
data: ProsemirrorData;
/** The emoji of the document when the revision was created */
emoji: string | null;
/** The icon (or) emoji of the document when the revision was created */
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,6 @@ declare module "autotrack/autotrack.js";
declare module "emoji-mart";
declare module "@emoji-mart/react";
declare module "string-replace-to-array";
declare module "sequelize-encrypted";

View File

@@ -19,6 +19,7 @@ declare module "styled-components" {
scrollbarThumb: string;
fontFamily: string;
fontFamilyMono: string;
fontFamilyEmoji: string;
fontWeightRegular: number;
fontWeightMedium: number;
fontWeightBold: number;

View File

@@ -66,7 +66,6 @@
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.0",

View File

@@ -1,5 +1,6 @@
import { FetchError } from "node-fetch";
import { Op } from "sequelize";
import { colorPalette } from "@shared/utils/collections";
import WebhookDisabledEmail from "@server/emails/templates/WebhookDisabledEmail";
import env from "@server/env";
import Logger from "@server/logging/Logger";
@@ -423,12 +424,18 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
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({
event,
subscription,
payload: {
id: event.collectionId,
model: model && (await presentCollection(undefined, model)),
model: collection,
},
});
}
@@ -448,14 +455,20 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
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({
event,
subscription,
payload: {
id: event.modelId,
model: model && presentMembership(model),
collection:
model && (await presentCollection(undefined, model.collection!)),
collection,
user: model && presentUser(model.user),
},
});
@@ -476,14 +489,20 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
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({
event,
subscription,
payload: {
id: event.modelId,
model: model && presentCollectionGroupMembership(model),
collection:
model && (await presentCollection(undefined, model.collection!)),
collection,
group: model && presentGroup(model.group),
},
});

View File

@@ -12,7 +12,8 @@ type Props = Optional<
| "title"
| "text"
| "content"
| "emoji"
| "icon"
| "color"
| "collectionId"
| "parentDocumentId"
| "importId"
@@ -36,7 +37,8 @@ type Props = Optional<
export default async function documentCreator({
title = "",
text = "",
emoji,
icon,
color,
state,
id,
urlId,
@@ -96,9 +98,9 @@ export default async function documentCreator({
importId,
sourceMetadata,
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
emoji: templateDocument ? templateDocument.emoji : emoji,
icon: templateDocument ? templateDocument.emoji : emoji,
color: templateDocument ? templateDocument.color : null,
emoji: templateDocument ? templateDocument.emoji : icon,
icon: templateDocument ? templateDocument.emoji : icon,
color: templateDocument ? templateDocument.color : color,
title: TextHelper.replaceTemplateVariables(
templateDocument ? templateDocument.title : title,
user

View File

@@ -26,7 +26,8 @@ describe("documentDuplicator", () => {
expect(response[0].title).toEqual(original.title);
expect(response[0].text).toEqual(original.text);
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);
});
@@ -35,7 +36,7 @@ describe("documentDuplicator", () => {
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
emoji: "👋",
icon: "👋",
});
const response = await sequelize.transaction((transaction) =>
@@ -52,8 +53,9 @@ describe("documentDuplicator", () => {
expect(response).toHaveLength(1);
expect(response[0].title).toEqual("New title");
expect(response[0].text).toEqual(original.text);
expect(response[0].emoji).toEqual(original.emoji);
expect(response[0].icon).toEqual(original.emoji);
expect(response[0].emoji).toEqual(original.icon);
expect(response[0].icon).toEqual(original.icon);
expect(response[0].color).toEqual(original.color);
expect(response[0].publishedAt).toBeInstanceOf(Date);
});
@@ -62,7 +64,7 @@ describe("documentDuplicator", () => {
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
emoji: "👋",
icon: "👋",
});
await buildDocument({
@@ -108,7 +110,8 @@ describe("documentDuplicator", () => {
expect(response[0].title).toEqual(original.title);
expect(response[0].text).toEqual(original.text);
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();
});
});

View File

@@ -45,7 +45,8 @@ export default async function documentDuplicator({
const duplicated = await documentCreator({
parentDocumentId: parentDocumentId ?? document.parentDocumentId,
emoji: document.emoji,
icon: document.icon ?? document.emoji,
color: document.color,
template: document.template,
title: title ?? document.title,
content: document.content,
@@ -78,7 +79,8 @@ export default async function documentDuplicator({
for (const childDocument of childDocuments) {
const duplicatedChildDocument = await documentCreator({
parentDocumentId: duplicated.id,
emoji: childDocument.emoji,
icon: childDocument.icon ?? childDocument.emoji,
color: childDocument.color,
title: childDocument.title,
text: childDocument.text,
...sharedProperties,

View File

@@ -28,7 +28,7 @@ async function documentImporter({
ip,
transaction,
}: Props): Promise<{
emoji?: string;
icon?: string;
text: string;
title: string;
state: Buffer;
@@ -43,9 +43,9 @@ async function documentImporter({
// find and extract emoji near the beginning of the document.
const regex = emojiRegex();
const matches = regex.exec(text.slice(0, 10));
const emoji = matches ? matches[0] : undefined;
if (emoji) {
text = text.replace(emoji, "");
const icon = matches ? matches[0] : undefined;
if (icon) {
text = text.replace(icon, "");
}
// If the first line of the imported text looks like a markdown heading
@@ -96,7 +96,7 @@ async function documentImporter({
text,
state,
title,
emoji,
icon,
};
}

View File

@@ -9,8 +9,10 @@ type Props = {
document: Document;
/** The new title */
title?: string;
/** The document emoji */
emoji?: string | null;
/** The document icon */
icon?: string | null;
/** The document icon's color */
color?: string | null;
/** The new text content */
text?: string;
/** Whether the editing session is complete */
@@ -46,7 +48,8 @@ export default async function documentUpdater({
user,
document,
title,
emoji,
icon,
color,
text,
editorVersion,
templateId,
@@ -65,9 +68,12 @@ export default async function documentUpdater({
if (title !== undefined) {
document.title = title.trim();
}
if (emoji !== undefined) {
document.emoji = emoji;
document.icon = emoji;
if (icon !== undefined) {
document.emoji = icon;
document.icon = icon;
}
if (color !== undefined) {
document.color = color;
}
if (editorVersion) {
document.editorVersion = editorVersion;

View File

@@ -183,6 +183,7 @@ class Collection extends ParanoidModel<
@Column(DataType.JSONB)
content: ProsemirrorData | null;
/** An icon (or) emoji to use as the collection icon. */
@Length({
max: 50,
msg: `icon must be 50 characters or less`,
@@ -190,6 +191,7 @@ class Collection extends ParanoidModel<
@Column
icon: string | null;
/** The color of the icon. */
@IsHexColor
@Column
color: string | null;
@@ -270,10 +272,6 @@ class Collection extends ParanoidModel<
@BeforeSave
static async onBeforeSave(model: Collection) {
if (model.icon === "collection") {
model.icon = null;
}
if (!model.content) {
model.content = await DocumentHelper.toJSON(model);
}

View File

@@ -255,14 +255,18 @@ class Document extends ParanoidModel<
@Column
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({
max: 1,
msg: `Emoji must be a single character`,
max: 50,
msg: `Emoji must be 50 characters or less`,
})
@Column
emoji: string | null;
/** An icon to use as the document icon. */
@Length({
max: 50,
msg: `icon must be 50 characters or less`,
@@ -365,7 +369,11 @@ class Document extends ParanoidModel<
model.archivedAt ||
model.template ||
!model.publishedAt ||
!(model.changed("title") || model.changed("emoji")) ||
!(
model.changed("title") ||
model.changed("icon") ||
model.changed("color")
) ||
!model.collectionId
) {
return;
@@ -721,6 +729,8 @@ class Document extends ParanoidModel<
this.text = revision.text;
this.title = revision.title;
this.emoji = revision.emoji;
this.icon = revision.icon;
this.color = revision.color;
};
/**
@@ -1083,6 +1093,8 @@ class Document extends ParanoidModel<
title: this.title,
url: this.url,
emoji: isNil(this.emoji) ? undefined : this.emoji,
icon: isNil(this.icon) ? undefined : this.icon,
color: isNil(this.color) ? undefined : this.color,
children,
};
};

View File

@@ -71,13 +71,18 @@ class Revision extends IdModel<
@Column(DataType.JSONB)
content: ProsemirrorData;
/**
* An emoji to use as the document icon,
* This is used as fallback (for backward compat) when icon is not set.
*/
@Length({
max: 1,
msg: `Emoji must be a single character`,
max: 50,
msg: `Emoji must be 50 characters or less`,
})
@Column
emoji: string | null;
/** An icon to use as the document icon. */
@Length({
max: 50,
msg: `icon must be 50 characters or less`,
@@ -134,7 +139,7 @@ class Revision extends IdModel<
title: document.title,
text: document.text,
emoji: document.emoji,
icon: document.emoji,
icon: document.icon,
color: document.color,
content: document.content,
userId: document.lastModifiedById,

View File

@@ -8,7 +8,8 @@ import { Node } from "prosemirror-model";
import * as Y from "yjs";
import textBetween from "@shared/editor/lib/textBetween";
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 { addTags } from "@server/logging/tracer";
import { trace } from "@server/logging/tracing";
@@ -148,7 +149,10 @@ export class DocumentHelper {
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
}`;

View File

@@ -1,4 +1,3 @@
import { colorPalette } from "@shared/utils/collections";
import Collection from "@server/models/Collection";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { APIContext } from "@server/types";
@@ -19,7 +18,7 @@ export default async function presentCollection(
sort: collection.sort,
icon: collection.icon,
index: collection.index,
color: collection.color || colorPalette[0],
color: collection.color,
permission: collection.permission,
sharing: collection.sharing,
createdAt: collection.createdAt,

View File

@@ -49,6 +49,8 @@ async function presentDocument(
: undefined,
text: !asData || options?.includeText ? text : undefined,
emoji: document.emoji,
icon: document.icon,
color: document.color,
tasks: document.tasks,
createdAt: document.createdAt,
createdBy: undefined,

View File

@@ -13,7 +13,8 @@ async function presentRevision(revision: Revision, diff?: string) {
documentId: revision.documentId,
title: strippedTitle,
data: await DocumentHelper.toJSON(revision),
emoji: revision.emoji ?? emoji,
icon: revision.icon ?? revision.emoji ?? emoji,
color: revision.color,
html: diff,
createdAt: revision.createdAt,
createdBy: presentUser(revision.user),

View File

@@ -43,7 +43,7 @@ export default class DocumentImportTask extends BaseTask<Props> {
transaction,
});
const { text, state, title, emoji } = await documentImporter({
const { text, state, title, icon } = await documentImporter({
user,
fileName: sourceMetadata.fileName,
mimeType: sourceMetadata.mimeType,
@@ -55,7 +55,7 @@ export default class DocumentImportTask extends BaseTask<Props> {
return documentCreator({
sourceMetadata,
title,
emoji,
icon,
text,
state,
publish,

View File

@@ -124,7 +124,8 @@ export default class ExportJSONTask extends ExportTask {
id: document.id,
urlId: document.urlId,
title: document.title,
emoji: document.emoji,
icon: document.icon,
color: document.color,
data: DocumentHelper.toProsemirror(document),
createdById: document.createdById,
createdByName: document.createdBy.name,

View File

@@ -79,9 +79,9 @@ export default class ImportJSONTask extends ImportTask {
// TODO: This is kind of temporary, we can import the document
// structure directly in the future.
text: serializer.serialize(Node.fromJSON(schema, node.data)),
emoji: node.emoji,
icon: node.emoji,
color: null,
emoji: node.icon ?? node.emoji,
icon: node.icon ?? node.emoji,
color: node.color,
createdAt: node.createdAt ? new Date(node.createdAt) : undefined,
updatedAt: node.updatedAt ? new Date(node.updatedAt) : undefined,
publishedAt: node.publishedAt ? new Date(node.publishedAt) : null,

View File

@@ -79,7 +79,7 @@ export default class ImportMarkdownZipTask extends ImportTask {
return;
}
const { title, emoji, text } = await documentImporter({
const { title, icon, text } = await documentImporter({
mimeType: "text/markdown",
fileName: child.name,
content:
@@ -115,8 +115,8 @@ export default class ImportMarkdownZipTask extends ImportTask {
output.documents.push({
id,
title,
emoji,
icon: emoji,
emoji: icon,
icon,
text,
collectionId,
parentDocumentId,

View File

@@ -96,7 +96,7 @@ export default class ImportNotionTask extends ImportTask {
Logger.debug("task", `Processing ${name} as ${mimeType}`);
const { title, emoji, text } = await documentImporter({
const { title, icon, text } = await documentImporter({
mimeType: mimeType || "text/markdown",
fileName: name,
content:
@@ -130,8 +130,8 @@ export default class ImportNotionTask extends ImportTask {
output.documents.push({
id,
title,
emoji,
icon: emoji,
emoji: icon,
icon,
text,
collectionId,
parentDocumentId,

View File

@@ -38,7 +38,7 @@ export type StructuredImportData = {
collections: {
id: string;
urlId?: string;
color?: string;
color?: string | null;
icon?: string | null;
sort?: CollectionSort;
permission?: CollectionPermission | null;

View File

@@ -1,5 +1,4 @@
import { CollectionPermission } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import { Document, UserMembership, GroupPermission } from "@server/models";
import {
buildUser,
@@ -182,6 +181,23 @@ describe("#collections.move", () => {
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 () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
@@ -1150,7 +1166,6 @@ describe("#collections.create", () => {
expect(body.data.name).toBe("Test");
expect(body.data.sort.field).toBe("index");
expect(body.data.sort.direction).toBe("asc");
expect(colorPalette.includes(body.data.color)).toBeTruthy();
expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.read).toBeTruthy();
});

View File

@@ -1,21 +1,13 @@
import emojiRegex from "emoji-regex";
import isUndefined from "lodash/isUndefined";
import { z } from "zod";
import { randomElement } from "@shared/random";
import { CollectionPermission, FileOperationFormat } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { Collection } from "@server/models";
import { zodEnumFromObjectKeys } from "@server/utils/zod";
import { ValidateColor, ValidateIndex } from "@server/validation";
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({
/** Id of the collection to be updated */
id: z.string(),
@@ -27,7 +19,7 @@ export const CollectionsCreateSchema = BaseSchema.extend({
color: z
.string()
.regex(ValidateColor.regex, { message: ValidateColor.message })
.default(randomElement(colorPalette)),
.nullish(),
description: z.string().nullish(),
data: ProsemirrorSchema.nullish(),
permission: z
@@ -35,7 +27,12 @@ export const CollectionsCreateSchema = BaseSchema.extend({
.nullish()
.transform((val) => (isUndefined(val) ? null : val)),
sharing: z.boolean().default(true),
icon: zodEnumFromObjectKeys(IconLibrary.mapping).optional(),
icon: z
.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
])
.optional(),
sort: z
.object({
field: z.union([z.literal("title"), z.literal("index")]),
@@ -174,7 +171,12 @@ export const CollectionsUpdateSchema = BaseSchema.extend({
name: z.string().optional(),
description: z.string().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(),
color: z
.string()

View File

@@ -2786,7 +2786,7 @@ describe("#documents.create", () => {
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 user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
@@ -2809,6 +2809,34 @@ describe("#documents.create", () => {
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);
});
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);
});
@@ -3094,7 +3122,7 @@ describe("#documents.update", () => {
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 document = await buildDocument({
userId: user.id,
@@ -3105,13 +3133,13 @@ describe("#documents.update", () => {
body: {
token: user.getJwtToken(),
id: document.id,
emoji: ":)",
icon: ":)",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toBe("emoji: Invalid");
expect(body.message).toBe("icon: Invalid");
});
it("should successfully update the emoji", async () => {
@@ -3124,12 +3152,34 @@ describe("#documents.update", () => {
body: {
token: user.getJwtToken(),
id: document.id,
emoji: "😂",
emoji: "🚢",
},
});
const body = await res.json();
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 () => {

View File

@@ -944,6 +944,8 @@ router.post(
createdById: user.id,
template: true,
emoji: original.emoji,
icon: original.icon,
color: original.color,
title: original.title,
text: original.text,
content: original.content,
@@ -1041,6 +1043,7 @@ router.post(
document,
user,
...input,
icon: input.icon ?? input.emoji,
publish,
collectionId,
insightsEnabled,
@@ -1382,6 +1385,8 @@ router.post(
title,
text,
emoji,
icon,
color,
publish,
collectionId,
parentDocumentId,
@@ -1445,7 +1450,8 @@ router.post(
const document = await documentCreator({
title,
text,
emoji,
icon: icon ?? emoji,
color,
createdAt,
publish,
collectionId: collection?.id,

View File

@@ -4,8 +4,11 @@ import isEmpty from "lodash/isEmpty";
import isUUID from "validator/lib/isUUID";
import { z } from "zod";
import { DocumentPermission, StatusFilter } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { BaseSchema } from "@server/routes/api/schema";
import { zodEnumFromObjectKeys } from "@server/utils/zod";
import { ValidateColor } from "@server/validation";
const DocumentsSortParamsSchema = z.object({
/** 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: 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 */
fullWidth: z.boolean().optional(),
@@ -319,7 +336,21 @@ export const DocumentsCreateSchema = BaseSchema.extend({
text: z.string().default(""),
/** 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 */
publish: z.boolean().optional(),

View File

@@ -52,7 +52,7 @@ export default async function main(exit = false, limit = 1000) {
try {
const { emoji, strippedTitle } = parseTitle(document.title);
if (emoji) {
document.emoji = emoji;
document.icon = emoji;
document.title = strippedTitle;
if (document.changed()) {

View File

@@ -26,7 +26,7 @@ export default async function main(exit = false, limit = 1000) {
try {
const { emoji, strippedTitle } = parseTitle(revision.title);
if (emoji) {
revision.emoji = emoji;
revision.icon = emoji;
revision.title = strippedTitle;
if (revision.changed()) {

View File

@@ -468,7 +468,13 @@ export type DocumentJSONExport = {
id: string;
urlId: 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>;
createdById: string;
createdByName: string;
@@ -498,7 +504,7 @@ export type CollectionJSONExport = {
data?: ProsemirrorData | null;
description?: ProsemirrorData | null;
permission?: CollectionPermission | null;
color: string;
color?: string | null;
icon?: string | null;
sort: CollectionSort;
documentStructure: NavigationNode[] | null;

9
server/utils/zod.ts Normal file
View 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]);
}

View File

@@ -207,8 +207,6 @@
"Title": "Title",
"Published": "Published",
"Include nested documents": "Include nested documents",
"Emoji Picker": "Emoji Picker",
"Remove": "Remove",
"Module failed to load": "Module failed to load",
"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.",
@@ -244,11 +242,27 @@
"Group members": "Group members",
"{{authorName}} created <3></3>": "{{authorName}} created <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",
"Choose an icon": "Choose an icon",
"Filter": "Filter",
"Loading": "Loading",
"Icon Picker": "Icon Picker",
"Icons": "Icons",
"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",
"Loading": "Loading",
"Search": "Search",
"Permission": "Permission",
"View only": "View only",
@@ -765,7 +779,6 @@
"We were unable to find the page youre looking for.": "We were unable to find the page youre looking for.",
"Search titles only": "Search titles only",
"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",
"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",
@@ -858,7 +871,6 @@
"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.",
"No groups have been created yet": "No groups have been created yet",
"All": "All",
"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.",
"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",
"Recent imports": "Recent imports",
"Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.": "Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.",
"Filter": "Filter",
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
"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",

View File

@@ -61,6 +61,8 @@ const buildBaseTheme = (input: Partial<Colors>) => {
"-apple-system, BlinkMacSystemFont, Inter, 'Segoe UI', Roboto, Oxygen, sans-serif",
fontFamilyMono:
"'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,
fontWeightMedium: 500,
fontWeightBold: 600,

View File

@@ -230,6 +230,8 @@ export type NavigationNode = {
title: string;
url: string;
emoji?: string;
icon?: string;
color?: string;
children: NavigationNode[];
isDraft?: boolean;
collectionId?: string;
@@ -405,3 +407,43 @@ export type ProsemirrorDoc = {
type: "doc";
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;
};

View File

@@ -210,7 +210,7 @@ export class IconLibrary {
}
return undefined;
})
.filter(Boolean);
.filter((icon: string | undefined): icon is string => !!icon);
}
/**

View File

@@ -31,7 +31,7 @@ export const sortNavigationNodes = (
export const colorPalette = [
"#4E5C6E",
"#0366d6",
"#0366D6",
"#9E5CF7",
"#FF825C",
"#FF5C80",

136
shared/utils/emoji.ts Normal file
View 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
View 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;
};

View File

@@ -2410,11 +2410,6 @@
resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.2.1.tgz#0ad70c662e3bc603e23e7d98413bd1e64c4fcb6c"
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":
version "0.8.8"
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:
version "1.8.10"
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:
"@babel/runtime" "^7.0.0"
memoize-one ">=3.1.1 <6"