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

View File

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

View File

@@ -9,15 +9,17 @@ import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle"; import Squircle from "@shared/components/Squircle";
import { s, ellipsis } from "@shared/styles"; import { s, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Pin from "~/models/Pin"; import Pin from "~/models/Pin";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time"; import Time from "~/components/Time";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { hover } from "~/styles"; import { hover } from "~/styles";
import CollectionIcon from "./Icons/CollectionIcon"; import CollectionIcon from "./Icons/CollectionIcon";
import EmojiIcon from "./Icons/EmojiIcon";
import Text from "./Text"; import Text from "./Text";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@@ -52,6 +54,8 @@ function DocumentCard(props: Props) {
disabled: !isDraggable || !canUpdatePin, disabled: !isDraggable || !canUpdatePin,
}); });
const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji;
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
@@ -109,12 +113,18 @@ function DocumentCard(props: Props) {
<path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" /> <path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" />
</Fold> </Fold>
{document.emoji ? ( {document.icon ? (
<Squircle color={theme.slateLight}> <DocumentSquircle
<EmojiIcon emoji={document.emoji} size={24} /> icon={document.icon}
</Squircle> color={document.color ?? undefined}
/>
) : ( ) : (
<Squircle color={collection?.color}> <Squircle
color={
collection?.color ??
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
}
>
{collection?.icon && {collection?.icon &&
collection?.icon !== "letter" && collection?.icon !== "letter" &&
collection?.icon !== "collection" && collection?.icon !== "collection" &&
@@ -127,8 +137,8 @@ function DocumentCard(props: Props) {
)} )}
<div> <div>
<Heading dir={document.dir}> <Heading dir={document.dir}>
{document.emoji {hasEmojiInTitle
? document.titleWithDefault.replace(document.emoji, "") ? document.titleWithDefault.replace(document.icon!, "")
: document.titleWithDefault} : document.titleWithDefault}
</Heading> </Heading>
<DocumentMeta size="xsmall"> <DocumentMeta size="xsmall">
@@ -159,6 +169,25 @@ function DocumentCard(props: Props) {
); );
} }
const DocumentSquircle = ({
icon,
color,
}: {
icon: string;
color?: string;
}) => {
const theme = useTheme();
const iconType = determineIconType(icon)!;
const squircleColor =
iconType === IconType.Outline ? color : theme.slateLight;
return (
<Squircle color={squircleColor}>
<Icon value={icon} color={theme.white} />
</Squircle>
);
};
const Clock = styled(ClockIcon)` const Clock = styled(ClockIcon)`
flex-shrink: 0; flex-shrink: 0;
`; `;

View File

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

View File

@@ -15,6 +15,7 @@ import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta"; import DocumentMeta from "~/components/DocumentMeta";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight"; import Highlight from "~/components/Highlight";
import Icon from "~/components/Icon";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star"; import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
@@ -23,7 +24,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import DocumentMenu from "~/menus/DocumentMenu"; import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles"; import { hover } from "~/styles";
import { documentPath } from "~/utils/routeHelpers"; import { documentPath } from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = { type Props = {
document: Document; document: Document;
@@ -97,9 +97,9 @@ function DocumentListItem(
> >
<Content> <Content>
<Heading dir={document.dir}> <Heading dir={document.dir}>
{document.emoji && ( {document.icon && (
<> <>
<EmojiIcon emoji={document.emoji} size={24} /> <Icon value={document.icon} color={document.color ?? undefined} />
&nbsp; &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 { CollectionIcon } from "outline-icons";
import { getLuminance } from "polished"; import { getLuminance } from "polished";
import * as React from "react"; import * as React from "react";
import { IconLibrary } from "@shared/utils/IconLibrary"; import { randomElement } from "@shared/random";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Icon from "~/components/Icon";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
type Props = { type Props = {
/** The collection to show an icon for */ /** The collection to show an icon for */
@@ -16,6 +17,7 @@ type Props = {
size?: number; size?: number;
/** The color of the icon, defaults to the collection color */ /** The color of the icon, defaults to the collection color */
color?: string; color?: string;
className?: string;
}; };
function ResolvedCollectionIcon({ function ResolvedCollectionIcon({
@@ -23,35 +25,41 @@ function ResolvedCollectionIcon({
color: inputColor, color: inputColor,
expanded, expanded,
size, size,
className,
}: Props) { }: Props) {
const { ui } = useStores(); const { ui } = useStores();
// If the chosen icon color is very dark then we invert it in dark mode if (!collection.icon || collection.icon === "collection") {
// otherwise it will be impossible to see against the dark background. // If the chosen icon color is very dark then we invert it in dark mode
const color = // otherwise it will be impossible to see against the dark background.
inputColor || const collectionColor = collection.color ?? randomElement(colorPalette);
(ui.resolvedTheme === "dark" && collection.color !== "currentColor" const color =
? getLuminance(collection.color) > 0.09 inputColor ||
? collection.color (ui.resolvedTheme === "dark" && collectionColor !== "currentColor"
: "currentColor" ? getLuminance(collectionColor) > 0.09
: collection.color); ? collectionColor
: "currentColor"
: collectionColor);
if (collection.icon && collection.icon !== "collection") { return (
try { <CollectionIcon
const Component = IconLibrary.getComponent(collection.icon); color={color}
return ( expanded={expanded}
<Component color={color} size={size}> size={size}
{collection.initial} className={className}
</Component> />
); );
} catch (error) {
Logger.warn("Failed to render custom icon", {
icon: collection.icon,
});
}
} }
return <CollectionIcon color={color} expanded={expanded} size={size} />; return (
<Icon
value={collection.icon}
color={inputColor ?? collection.color ?? undefined}
size={size}
initial={collection.initial}
className={className}
/>
);
} }
export default observer(ResolvedCollectionIcon); export default observer(ResolvedCollectionIcon);

View File

@@ -1,11 +1,13 @@
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { s } from "@shared/styles";
type Props = { type Props = {
/** The emoji to render */ /** The emoji to render */
emoji: string; emoji: string;
/** The size of the emoji, 24px is default to match standard icons */ /** The size of the emoji, 24px is default to match standard icons */
size?: number; size?: number;
className?: string;
}; };
/** /**
@@ -15,19 +17,28 @@ type Props = {
export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) { export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) {
return ( return (
<Span $size={size} {...rest}> <Span $size={size} {...rest}>
{emoji} <SVG size={size} emoji={emoji} />
</Span> </Span>
); );
} }
const Span = styled.span<{ $size: number }>` const Span = styled.span<{ $size: number }>`
display: inline-flex; font-family: ${s("fontFamilyEmoji")};
align-items: center; display: inline-block;
justify-content: center;
text-align: center;
flex-shrink: 0;
width: ${(props) => props.$size}px; width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px; height: ${(props) => props.$size}px;
text-indent: -0.15em;
font-size: ${(props) => props.$size - 10}px;
`; `;
const SVG = ({ size, emoji }: { size: number; emoji: string }) => (
<svg width={size} height={size} xmlns="http://www.w3.org/2000/svg">
<text
x="50%"
y={"55%"}
dominantBaseline="middle"
textAnchor="middle"
fontSize={size * 0.7}
>
{emoji}
</text>
</svg>
);

View File

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

View File

@@ -4,7 +4,8 @@ import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components"; import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle"; import Squircle from "@shared/components/Squircle";
import { CollectionPermission } from "@shared/types"; import { CollectionPermission, IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import type Collection from "~/models/Collection"; import type Collection from "~/models/Collection";
import type Document from "~/models/Document"; import type Document from "~/models/Document";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
@@ -54,15 +55,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
/> />
) : usersInCollection ? ( ) : usersInCollection ? (
<ListItem <ListItem
image={ image={<CollectionSquircle collection={collection} />}
<Squircle color={collection.color} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={16}
/>
</Squircle>
}
title={collection.name} title={collection.name}
subtitle={t("Everyone in the collection")} subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>} actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
@@ -136,6 +129,24 @@ const AccessTooltip = ({
); );
}; };
const CollectionSquircle = ({ collection }: { collection: Collection }) => {
const theme = useTheme();
const iconType = determineIconType(collection.icon)!;
const squircleColor =
iconType === IconType.Outline ? collection.color! : theme.slateLight;
const iconSize = iconType === IconType.Outline ? 16 : 22;
return (
<Squircle color={squircleColor} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={iconSize}
/>
</Squircle>
);
};
function useUsersInCollection(collection?: Collection) { function useUsersInCollection(collection?: Collection) {
const { users, memberships } = useStores(); const { users, memberships } = useStores();
const { request } = useRequest(() => const { request } = useRequest(() =>

View File

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

View File

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

View File

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

View File

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

View File

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

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 capitalize from "lodash/capitalize";
import sortBy from "lodash/sortBy";
import React from "react"; import React from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji"; import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import { isMac } from "@shared/utils/browser"; import { search as emojiSearch } from "@shared/utils/emoji";
import EmojiMenuItem from "./EmojiMenuItem"; import EmojiMenuItem from "./EmojiMenuItem";
import SuggestionsMenu, { import SuggestionsMenu, {
Props as SuggestionsMenuProps, Props as SuggestionsMenuProps,
@@ -19,13 +15,6 @@ type Emoji = {
attrs: { markup: string; "data-name": string }; attrs: { markup: string; "data-name": string };
}; };
init({
data,
noCountryFlags: isMac() ? false : undefined,
});
let searcher: FuzzySearch<TEmoji>;
type Props = Omit< type Props = Omit<
SuggestionsMenuProps<Emoji>, SuggestionsMenuProps<Emoji>,
"renderMenuItem" | "items" | "embeds" | "trigger" "renderMenuItem" | "items" | "embeds" | "trigger"
@@ -34,36 +23,26 @@ type Props = Omit<
const EmojiMenu = (props: Props) => { const EmojiMenu = (props: Props) => {
const { search = "" } = props; const { search = "" } = props;
if (!searcher) { const items = React.useMemo(
searcher = new FuzzySearch(Object.values(Data.emojis), ["search"], { () =>
caseSensitive: false, emojiSearch({ query: search })
sort: true, .map((item) => {
}); // We snake_case the shortcode for backwards compatability with gemoji to
} // avoid multiple formats being written into documents.
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
const emoji = item.value;
const items = React.useMemo(() => { return {
const n = search.toLowerCase(); name: "emoji",
title: emoji,
return sortBy(searcher.search(n), (item) => { description: capitalize(item.name.toLowerCase()),
const nlc = item.name.toLowerCase(); emoji,
return nlc === n ? -1 : nlc.startsWith(n) ? 0 : 1; attrs: { markup: shortcode, "data-name": shortcode },
}) };
.map((item) => { })
// We snake_case the shortcode for backwards compatability with gemoji to .slice(0, 15),
// avoid multiple formats being written into documents. [search]
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id); );
const emoji = item.skins[0].native;
return {
name: "emoji",
title: emoji,
description: capitalize(item.name.toLowerCase()),
emoji,
attrs: { markup: shortcode, "data-name": shortcode },
};
})
.slice(0, 15);
}, [search]);
return ( return (
<SuggestionsMenu <SuggestionsMenu

View File

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

View File

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

View File

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

View File

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

View File

@@ -129,11 +129,18 @@ export default class Document extends ParanoidModel {
title: string; title: string;
/** /**
* An emoji to use as the document icon. * An icon (or) emoji to use as the document icon.
*/ */
@Field @Field
@observable @observable
emoji: string | undefined | null; icon?: string | null;
/**
* The color to use for the document icon.
*/
@Field
@observable
color?: string | null;
/** /**
* Whether this is a template. * Whether this is a template.

View File

@@ -22,8 +22,11 @@ class Revision extends Model {
/** Prosemirror data of the content when revision was created */ /** Prosemirror data of the content when revision was created */
data: ProsemirrorData; data: ProsemirrorData;
/** The emoji of the document when the revision was created */ /** The icon (or) emoji of the document when the revision was created */
emoji: string | null; icon: string | null;
/** The color of the document icon when the revision was created */
color: string | null;
/** HTML string representing the revision as a diff from the previous version */ /** HTML string representing the revision as a diff from the previous version */
html: string; html: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { FetchError } from "node-fetch"; import { FetchError } from "node-fetch";
import { Op } from "sequelize"; import { Op } from "sequelize";
import { colorPalette } from "@shared/utils/collections";
import WebhookDisabledEmail from "@server/emails/templates/WebhookDisabledEmail"; import WebhookDisabledEmail from "@server/emails/templates/WebhookDisabledEmail";
import env from "@server/env"; import env from "@server/env";
import Logger from "@server/logging/Logger"; import Logger from "@server/logging/Logger";
@@ -423,12 +424,18 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
paranoid: false, paranoid: false,
}); });
const collection = model && (await presentCollection(undefined, model));
if (collection) {
// For backward compatibility, set a default color.
collection.color = collection.color ?? colorPalette[0];
}
await this.sendWebhook({ await this.sendWebhook({
event, event,
subscription, subscription,
payload: { payload: {
id: event.collectionId, id: event.collectionId,
model: model && (await presentCollection(undefined, model)), model: collection,
}, },
}); });
} }
@@ -448,14 +455,20 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
paranoid: false, paranoid: false,
}); });
const collection =
model && (await presentCollection(undefined, model.collection!));
if (collection) {
// For backward compatibility, set a default color.
collection.color = collection.color ?? colorPalette[0];
}
await this.sendWebhook({ await this.sendWebhook({
event, event,
subscription, subscription,
payload: { payload: {
id: event.modelId, id: event.modelId,
model: model && presentMembership(model), model: model && presentMembership(model),
collection: collection,
model && (await presentCollection(undefined, model.collection!)),
user: model && presentUser(model.user), user: model && presentUser(model.user),
}, },
}); });
@@ -476,14 +489,20 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
paranoid: false, paranoid: false,
}); });
const collection =
model && (await presentCollection(undefined, model.collection!));
if (collection) {
// For backward compatibility, set a default color.
collection.color = collection.color ?? colorPalette[0];
}
await this.sendWebhook({ await this.sendWebhook({
event, event,
subscription, subscription,
payload: { payload: {
id: event.modelId, id: event.modelId,
model: model && presentCollectionGroupMembership(model), model: model && presentCollectionGroupMembership(model),
collection: collection,
model && (await presentCollection(undefined, model.collection!)),
group: model && presentGroup(model.group), group: model && presentGroup(model.group),
}, },
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,8 @@ import { Node } from "prosemirror-model";
import * as Y from "yjs"; import * as Y from "yjs";
import textBetween from "@shared/editor/lib/textBetween"; import textBetween from "@shared/editor/lib/textBetween";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper"; import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { ProsemirrorData } from "@shared/types"; import { IconType, ProsemirrorData } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import { parser, serializer, schema } from "@server/editor"; import { parser, serializer, schema } from "@server/editor";
import { addTags } from "@server/logging/tracer"; import { addTags } from "@server/logging/tracer";
import { trace } from "@server/logging/tracing"; import { trace } from "@server/logging/tracing";
@@ -148,7 +149,10 @@ export class DocumentHelper {
return text; return text;
} }
const title = `${document.emoji ? document.emoji + " " : ""}${ const icon = document.icon ?? document.emoji;
const iconType = determineIconType(icon);
const title = `${iconType === IconType.Emoji ? icon + " " : ""}${
document.title document.title
}`; }`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { CollectionPermission } from "@shared/types"; import { CollectionPermission } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import { Document, UserMembership, GroupPermission } from "@server/models"; import { Document, UserMembership, GroupPermission } from "@server/models";
import { import {
buildUser, buildUser,
@@ -182,6 +181,23 @@ describe("#collections.move", () => {
expect(body.success).toBe(true); expect(body.success).toBe(true);
}); });
it("should allow setting an emoji as icon", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({ teamId: team.id });
const res = await server.post("/api/collections.move", {
body: {
token: admin.getJwtToken(),
id: collection.id,
index: "P",
icon: "😁",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toBe(true);
});
it("should return error when icon is not valid", async () => { it("should return error when icon is not valid", async () => {
const team = await buildTeam(); const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id }); const admin = await buildAdmin({ teamId: team.id });
@@ -1150,7 +1166,6 @@ describe("#collections.create", () => {
expect(body.data.name).toBe("Test"); expect(body.data.name).toBe("Test");
expect(body.data.sort.field).toBe("index"); expect(body.data.sort.field).toBe("index");
expect(body.data.sort.direction).toBe("asc"); expect(body.data.sort.direction).toBe("asc");
expect(colorPalette.includes(body.data.color)).toBeTruthy();
expect(body.policies.length).toBe(1); expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.read).toBeTruthy(); expect(body.policies[0].abilities.read).toBeTruthy();
}); });

View File

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

View File

@@ -2786,7 +2786,7 @@ describe("#documents.create", () => {
expect(body.message).toEqual("parentDocumentId: Invalid uuid"); expect(body.message).toEqual("parentDocumentId: Invalid uuid");
}); });
it("should create as a new document", async () => { it("should create as a new document with emoji", async () => {
const team = await buildTeam(); const team = await buildTeam();
const user = await buildUser({ teamId: team.id }); const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({ const collection = await buildCollection({
@@ -2809,6 +2809,34 @@ describe("#documents.create", () => {
expect(newDocument!.parentDocumentId).toBe(null); expect(newDocument!.parentDocumentId).toBe(null);
expect(newDocument!.collectionId).toBe(collection.id); expect(newDocument!.collectionId).toBe(collection.id);
expect(newDocument!.emoji).toBe("🚢"); expect(newDocument!.emoji).toBe("🚢");
expect(newDocument!.icon).toBe("🚢");
expect(body.policies[0].abilities.update).toEqual(true);
});
it("should create as a new document with icon", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const res = await server.post("/api/documents.create", {
body: {
token: user.getJwtToken(),
collectionId: collection.id,
icon: "🚢",
title: "new document",
text: "hello",
publish: true,
},
});
const body = await res.json();
const newDocument = await Document.findByPk(body.data.id);
expect(res.status).toEqual(200);
expect(newDocument!.parentDocumentId).toBe(null);
expect(newDocument!.collectionId).toBe(collection.id);
expect(newDocument!.emoji).toBe("🚢");
expect(newDocument!.icon).toBe("🚢");
expect(body.policies[0].abilities.update).toEqual(true); expect(body.policies[0].abilities.update).toEqual(true);
}); });
@@ -3094,7 +3122,7 @@ describe("#documents.update", () => {
expect(res.status).toEqual(403); expect(res.status).toEqual(403);
}); });
it("should fail to update an invalid emoji value", async () => { it("should fail to update an invalid icon value", async () => {
const user = await buildUser(); const user = await buildUser();
const document = await buildDocument({ const document = await buildDocument({
userId: user.id, userId: user.id,
@@ -3105,13 +3133,13 @@ describe("#documents.update", () => {
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),
id: document.id, id: document.id,
emoji: ":)", icon: ":)",
}, },
}); });
const body = await res.json(); const body = await res.json();
expect(res.status).toEqual(400); expect(res.status).toEqual(400);
expect(body.message).toBe("emoji: Invalid"); expect(body.message).toBe("icon: Invalid");
}); });
it("should successfully update the emoji", async () => { it("should successfully update the emoji", async () => {
@@ -3124,12 +3152,34 @@ describe("#documents.update", () => {
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),
id: document.id, id: document.id,
emoji: "😂", emoji: "🚢",
}, },
}); });
const body = await res.json(); const body = await res.json();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(body.data.emoji).toBe("😂"); expect(body.data.emoji).toBe("🚢");
expect(body.data.icon).toBe("🚢");
expect(body.data.color).toBeNull;
});
it("should successfully update the icon", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/documents.update", {
body: {
token: user.getJwtToken(),
id: document.id,
icon: "beaker",
color: "#FFDDEE",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.icon).toBe("beaker");
expect(body.data.color).toBe("#FFDDEE");
}); });
it("should not add template to collection structure when publishing", async () => { it("should not add template to collection structure when publishing", async () => {

View File

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

View File

@@ -4,8 +4,11 @@ import isEmpty from "lodash/isEmpty";
import isUUID from "validator/lib/isUUID"; import isUUID from "validator/lib/isUUID";
import { z } from "zod"; import { z } from "zod";
import { DocumentPermission, StatusFilter } from "@shared/types"; import { DocumentPermission, StatusFilter } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { UrlHelper } from "@shared/utils/UrlHelper"; import { UrlHelper } from "@shared/utils/UrlHelper";
import { BaseSchema } from "@server/routes/api/schema"; import { BaseSchema } from "@server/routes/api/schema";
import { zodEnumFromObjectKeys } from "@server/utils/zod";
import { ValidateColor } from "@server/validation";
const DocumentsSortParamsSchema = z.object({ const DocumentsSortParamsSchema = z.object({
/** Specifies the attributes by which documents will be sorted in the list */ /** Specifies the attributes by which documents will be sorted in the list */
@@ -223,6 +226,20 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
/** Emoji displayed alongside doc title */ /** Emoji displayed alongside doc title */
emoji: z.string().regex(emojiRegex()).nullish(), emoji: z.string().regex(emojiRegex()).nullish(),
/** Icon displayed alongside doc title */
icon: z
.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
])
.nullish(),
/** Icon color */
color: z
.string()
.regex(ValidateColor.regex, { message: ValidateColor.message })
.nullish(),
/** Boolean to denote if the doc should occupy full width */ /** Boolean to denote if the doc should occupy full width */
fullWidth: z.boolean().optional(), fullWidth: z.boolean().optional(),
@@ -319,7 +336,21 @@ export const DocumentsCreateSchema = BaseSchema.extend({
text: z.string().default(""), text: z.string().default(""),
/** Emoji displayed alongside doc title */ /** Emoji displayed alongside doc title */
emoji: z.string().regex(emojiRegex()).optional(), emoji: z.string().regex(emojiRegex()).nullish(),
/** Icon displayed alongside doc title */
icon: z
.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
])
.optional(),
/** Icon color */
color: z
.string()
.regex(ValidateColor.regex, { message: ValidateColor.message })
.nullish(),
/** Boolean to denote if the doc should be published */ /** Boolean to denote if the doc should be published */
publish: z.boolean().optional(), publish: z.boolean().optional(),

View File

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

View File

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

View File

@@ -468,7 +468,13 @@ export type DocumentJSONExport = {
id: string; id: string;
urlId: string; urlId: string;
title: string; title: string;
emoji: string | null; /**
* For backward compatibility, maintain the `emoji` field.
* Future exports will use the `icon` field.
* */
emoji?: string | null;
icon: string | null;
color: string | null;
data: Record<string, any>; data: Record<string, any>;
createdById: string; createdById: string;
createdByName: string; createdByName: string;
@@ -498,7 +504,7 @@ export type CollectionJSONExport = {
data?: ProsemirrorData | null; data?: ProsemirrorData | null;
description?: ProsemirrorData | null; description?: ProsemirrorData | null;
permission?: CollectionPermission | null; permission?: CollectionPermission | null;
color: string; color?: string | null;
icon?: string | null; icon?: string | null;
sort: CollectionSort; sort: CollectionSort;
documentStructure: NavigationNode[] | null; documentStructure: NavigationNode[] | null;

9
server/utils/zod.ts Normal file
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", "Title": "Title",
"Published": "Published", "Published": "Published",
"Include nested documents": "Include nested documents", "Include nested documents": "Include nested documents",
"Emoji Picker": "Emoji Picker",
"Remove": "Remove",
"Module failed to load": "Module failed to load", "Module failed to load": "Module failed to load",
"Loading Failed": "Loading Failed", "Loading Failed": "Loading Failed",
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.", "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.",
@@ -244,11 +242,27 @@
"Group members": "Group members", "Group members": "Group members",
"{{authorName}} created <3></3>": "{{authorName}} created <3></3>", "{{authorName}} created <3></3>": "{{authorName}} created <3></3>",
"{{authorName}} opened <3></3>": "{{authorName}} opened <3></3>", "{{authorName}} opened <3></3>": "{{authorName}} opened <3></3>",
"Search emoji": "Search emoji",
"Search icons": "Search icons",
"Choose default skin tone": "Choose default skin tone",
"Show menu": "Show menu", "Show menu": "Show menu",
"Choose an icon": "Choose an icon", "Icon Picker": "Icon Picker",
"Filter": "Filter", "Icons": "Icons",
"Loading": "Loading", "Emojis": "Emojis",
"Remove": "Remove",
"All": "All",
"Frequently Used": "Frequently Used",
"Search Results": "Search Results",
"Smileys & People": "Smileys & People",
"Animals & Nature": "Animals & Nature",
"Food & Drink": "Food & Drink",
"Activity": "Activity",
"Travel & Places": "Travel & Places",
"Objects": "Objects",
"Symbols": "Symbols",
"Flags": "Flags",
"Select a color": "Select a color", "Select a color": "Select a color",
"Loading": "Loading",
"Search": "Search", "Search": "Search",
"Permission": "Permission", "Permission": "Permission",
"View only": "View only", "View only": "View only",
@@ -765,7 +779,6 @@
"We were unable to find the page youre looking for.": "We were unable to find the page youre looking for.", "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", "Search titles only": "Search titles only",
"No documents found for your search filters.": "No documents found for your search filters.", "No documents found for your search filters.": "No documents found for your search filters.",
"Search Results": "Search Results",
"API key copied to clipboard": "API key copied to clipboard", "API key copied to clipboard": "API key copied to clipboard",
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.", "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
"Personal keys": "Personal keys", "Personal keys": "Personal keys",
@@ -858,7 +871,6 @@
"New group": "New group", "New group": "New group",
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.", "Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
"No groups have been created yet": "No groups have been created yet", "No groups have been created yet": "No groups have been created yet",
"All": "All",
"Create a group": "Create a group", "Create a group": "Create a group",
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.", "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.",
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)": "Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)", "Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)": "Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)",
@@ -869,6 +881,7 @@
"Enterprise": "Enterprise", "Enterprise": "Enterprise",
"Recent imports": "Recent imports", "Recent imports": "Recent imports",
"Everyone that has signed into {{appName}} is listed here. 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.", "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", "Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
"Document updated": "Document updated", "Document updated": "Document updated",
"Receive a notification when a document you are subscribed to is edited": "Receive a notification when a document you are subscribed to is edited", "Receive a notification when a document you are subscribed to is edited": "Receive a notification when a document you are subscribed to is edited",

View File

@@ -61,6 +61,8 @@ const buildBaseTheme = (input: Partial<Colors>) => {
"-apple-system, BlinkMacSystemFont, Inter, 'Segoe UI', Roboto, Oxygen, sans-serif", "-apple-system, BlinkMacSystemFont, Inter, 'Segoe UI', Roboto, Oxygen, sans-serif",
fontFamilyMono: fontFamilyMono:
"'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace", "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace",
fontFamilyEmoji:
"Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Segoe UI, Twemoji Mozilla, Noto Color Emoji, Android Emoji",
fontWeightRegular: 400, fontWeightRegular: 400,
fontWeightMedium: 500, fontWeightMedium: 500,
fontWeightBold: 600, fontWeightBold: 600,

View File

@@ -230,6 +230,8 @@ export type NavigationNode = {
title: string; title: string;
url: string; url: string;
emoji?: string; emoji?: string;
icon?: string;
color?: string;
children: NavigationNode[]; children: NavigationNode[];
isDraft?: boolean; isDraft?: boolean;
collectionId?: string; collectionId?: string;
@@ -405,3 +407,43 @@ export type ProsemirrorDoc = {
type: "doc"; type: "doc";
content: ProsemirrorData[]; content: ProsemirrorData[];
}; };
export enum IconType {
Outline = "outline",
Emoji = "emoji",
}
export enum EmojiCategory {
People = "People",
Nature = "Nature",
Foods = "Foods",
Activity = "Activity",
Places = "Places",
Objects = "Objects",
Symbols = "Symbols",
Flags = "Flags",
}
export enum EmojiSkinTone {
Default = "Default",
Light = "Light",
MediumLight = "MediumLight",
Medium = "Medium",
MediumDark = "MediumDark",
Dark = "Dark",
}
export type Emoji = {
id: string;
name: string;
value: string;
};
export type EmojiVariants = {
[EmojiSkinTone.Default]: Emoji;
[EmojiSkinTone.Light]?: Emoji;
[EmojiSkinTone.MediumLight]?: Emoji;
[EmojiSkinTone.Medium]?: Emoji;
[EmojiSkinTone.MediumDark]?: Emoji;
[EmojiSkinTone.Dark]?: Emoji;
};

View File

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

View File

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

136
shared/utils/emoji.ts Normal file
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" resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.2.1.tgz#0ad70c662e3bc603e23e7d98413bd1e64c4fcb6c"
integrity sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw== integrity sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==
"@emoji-mart/react@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@emoji-mart/react/-/react-1.1.1.tgz#ddad52f93a25baf31c5383c3e7e4c6e05554312a"
integrity "sha1-3a1S+ToluvMcU4PD5+TG4FVUMSo= sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g=="
"@emotion/is-prop-valid@^0.8.2": "@emotion/is-prop-valid@^0.8.2":
version "0.8.8" version "0.8.8"
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
@@ -13375,7 +13370,7 @@ react-waypoint@^10.3.0:
react-window@^1.8.10: react-window@^1.8.10:
version "1.8.10" version "1.8.10"
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03" resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03"
integrity "sha1-nmsIVIMWgUtEP3ACsc+P06G93gM= sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==" integrity sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==
dependencies: dependencies:
"@babel/runtime" "^7.0.0" "@babel/runtime" "^7.0.0"
memoize-one ">=3.1.1 <6" memoize-one ">=3.1.1 <6"