diff --git a/app/components/Collection/CollectionForm.tsx b/app/components/Collection/CollectionForm.tsx index eb1fa3868..b567e36ed 100644 --- a/app/components/Collection/CollectionForm.tsx +++ b/app/components/Collection/CollectionForm.tsx @@ -11,7 +11,7 @@ import { CollectionValidation } from "@shared/validations"; import Collection from "~/models/Collection"; import Button from "~/components/Button"; import Flex from "~/components/Flex"; -import IconPicker from "~/components/IconPicker"; +import Icon from "~/components/Icon"; import Input from "~/components/Input"; import InputSelectPermission from "~/components/InputSelectPermission"; import Switch from "~/components/Switch"; @@ -20,10 +20,12 @@ import useBoolean from "~/hooks/useBoolean"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import { Feature, FeatureFlags } from "~/utils/FeatureFlags"; +const IconPicker = React.lazy(() => import("~/components/IconPicker")); + export interface FormData { name: string; icon: string; - color: string; + color: string | null; sharing: boolean; permission: CollectionPermission | undefined; } @@ -37,7 +39,16 @@ export const CollectionForm = observer(function CollectionForm_({ }) { const team = useCurrentTeam(); const { t } = useTranslation(); + const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false); + + const iconColor = React.useMemo( + () => collection?.color ?? randomElement(colorPalette), + [collection?.color] + ); + + const fallbackIcon = ; + const { register, handleSubmit: formHandleSubmit, @@ -53,7 +64,7 @@ export const CollectionForm = observer(function CollectionForm_({ icon: collection?.icon, sharing: collection?.sharing ?? true, permission: collection?.permission, - color: collection?.color ?? randomElement(colorPalette), + color: iconColor, }, }); @@ -70,20 +81,20 @@ export const CollectionForm = observer(function CollectionForm_({ "collection" ); } - }, [values.name, collection]); + }, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]); React.useEffect(() => { setTimeout(() => setFocus("name", { shouldSelect: true }), 100); }, [setFocus]); - const handleIconPickerChange = React.useCallback( - (color: string, icon: string) => { + const handleIconChange = React.useCallback( + (icon: string, color: string | null) => { if (icon !== values.icon) { setFocus("name"); } - setValue("color", color); setValue("icon", icon); + setValue("color", color); }, [setFocus, setValue, values.icon] ); @@ -105,13 +116,16 @@ export const CollectionForm = observer(function CollectionForm_({ maxLength: CollectionValidation.maxNameLength, })} prefix={ - + + + } autoComplete="off" autoFocus diff --git a/app/components/DocumentBreadcrumb.tsx b/app/components/DocumentBreadcrumb.tsx index 5dcd5300b..96da0d700 100644 --- a/app/components/DocumentBreadcrumb.tsx +++ b/app/components/DocumentBreadcrumb.tsx @@ -6,6 +6,7 @@ import styled from "styled-components"; import type { NavigationNode } from "@shared/types"; import Document from "~/models/Document"; import Breadcrumb from "~/components/Breadcrumb"; +import Icon from "~/components/Icon"; import CollectionIcon from "~/components/Icons/CollectionIcon"; import useStores from "~/hooks/useStores"; import { MenuInternalLink } from "~/types"; @@ -15,7 +16,6 @@ import { settingsPath, trashPath, } from "~/utils/routeHelpers"; -import EmojiIcon from "./Icons/EmojiIcon"; type Props = { children?: React.ReactNode; @@ -106,9 +106,9 @@ const DocumentBreadcrumb: React.FC = ({ path.slice(0, -1).forEach((node: NavigationNode) => { output.push({ type: "route", - title: node.emoji ? ( + title: node.icon ? ( <> - {node.title} + {node.title} > ) : ( node.title @@ -144,6 +144,10 @@ const DocumentBreadcrumb: React.FC = ({ ); }; +const StyledIcon = styled(Icon)` + margin-right: 2px; +`; + const SmallSlash = styled(GoToIcon)` width: 12px; height: 12px; diff --git a/app/components/DocumentCard.tsx b/app/components/DocumentCard.tsx index 1875f35f6..894e160e4 100644 --- a/app/components/DocumentCard.tsx +++ b/app/components/DocumentCard.tsx @@ -9,15 +9,17 @@ import { Link } from "react-router-dom"; import styled, { useTheme } from "styled-components"; import Squircle from "@shared/components/Squircle"; import { s, ellipsis } from "@shared/styles"; +import { IconType } from "@shared/types"; +import { determineIconType } from "@shared/utils/icon"; import Document from "~/models/Document"; import Pin from "~/models/Pin"; import Flex from "~/components/Flex"; +import Icon from "~/components/Icon"; import NudeButton from "~/components/NudeButton"; import Time from "~/components/Time"; import useStores from "~/hooks/useStores"; import { hover } from "~/styles"; import CollectionIcon from "./Icons/CollectionIcon"; -import EmojiIcon from "./Icons/EmojiIcon"; import Text from "./Text"; import Tooltip from "./Tooltip"; @@ -52,6 +54,8 @@ function DocumentCard(props: Props) { disabled: !isDraggable || !canUpdatePin, }); + const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji; + const style = { transform: CSS.Transform.toString(transform), transition, @@ -109,12 +113,18 @@ function DocumentCard(props: Props) { - {document.emoji ? ( - - - + {document.icon ? ( + ) : ( - + {collection?.icon && collection?.icon !== "letter" && collection?.icon !== "collection" && @@ -127,8 +137,8 @@ function DocumentCard(props: Props) { )} - {document.emoji - ? document.titleWithDefault.replace(document.emoji, "") + {hasEmojiInTitle + ? document.titleWithDefault.replace(document.icon!, "") : document.titleWithDefault} @@ -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 ( + + + + ); +}; + const Clock = styled(ClockIcon)` flex-shrink: 0; `; diff --git a/app/components/DocumentExplorer.tsx b/app/components/DocumentExplorer.tsx index 156f3c13d..24fc24ed3 100644 --- a/app/components/DocumentExplorer.tsx +++ b/app/components/DocumentExplorer.tsx @@ -18,8 +18,8 @@ import { NavigationNode } from "@shared/types"; import DocumentExplorerNode from "~/components/DocumentExplorerNode"; import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult"; import Flex from "~/components/Flex"; +import Icon from "~/components/Icon"; import CollectionIcon from "~/components/Icons/CollectionIcon"; -import EmojiIcon from "~/components/Icons/EmojiIcon"; import { Outline } from "~/components/Input"; import InputSearch from "~/components/InputSearch"; import Text from "~/components/Text"; @@ -216,25 +216,30 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) { }) => { const node = data[index]; const isCollection = node.type === "collection"; - let icon, title: string, emoji: string | undefined, path; + let renderedIcon, + title: string, + icon: string | undefined, + color: string | undefined, + path; if (isCollection) { const col = collections.get(node.collectionId as string); - icon = col && ( + renderedIcon = col && ( ); title = node.title; } else { const doc = documents.get(node.id); - emoji = doc?.emoji ?? node.emoji; + icon = doc?.icon ?? node.icon; + color = doc?.color ?? node.color; title = doc?.title ?? node.title; - if (emoji) { - icon = ; + if (icon) { + renderedIcon = ; } else if (doc?.isStarred) { - icon = ; + renderedIcon = ; } else { - icon = ; + renderedIcon = ; } path = ancestors(node) @@ -254,7 +259,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) { }} onPointerMove={() => setActiveNode(index)} onClick={() => toggleSelect(index)} - icon={icon} + icon={renderedIcon} title={title} path={path} /> @@ -275,7 +280,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) { selected={isSelected(index)} active={activeNode === index} expanded={isExpanded(index)} - icon={icon} + icon={renderedIcon} title={title} depth={node.depth as number} hasChildren={hasChildren(index)} diff --git a/app/components/DocumentListItem.tsx b/app/components/DocumentListItem.tsx index 7ac0a0e04..b797c485e 100644 --- a/app/components/DocumentListItem.tsx +++ b/app/components/DocumentListItem.tsx @@ -15,6 +15,7 @@ import Badge from "~/components/Badge"; import DocumentMeta from "~/components/DocumentMeta"; import Flex from "~/components/Flex"; import Highlight from "~/components/Highlight"; +import Icon from "~/components/Icon"; import NudeButton from "~/components/NudeButton"; import StarButton, { AnimatedStar } from "~/components/Star"; import Tooltip from "~/components/Tooltip"; @@ -23,7 +24,6 @@ import useCurrentUser from "~/hooks/useCurrentUser"; import DocumentMenu from "~/menus/DocumentMenu"; import { hover } from "~/styles"; import { documentPath } from "~/utils/routeHelpers"; -import EmojiIcon from "./Icons/EmojiIcon"; type Props = { document: Document; @@ -97,9 +97,9 @@ function DocumentListItem( > - {document.emoji && ( + {document.icon && ( <> - + > )} diff --git a/app/components/EmojiPicker/components.tsx b/app/components/EmojiPicker/components.tsx deleted file mode 100644 index 471366655..000000000 --- a/app/components/EmojiPicker/components.tsx +++ /dev/null @@ -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` : "")} -`; diff --git a/app/components/EmojiPicker/index.tsx b/app/components/EmojiPicker/index.tsx deleted file mode 100644 index c85d94ab7..000000000 --- a/app/components/EmojiPicker/index.tsx +++ /dev/null @@ -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; - /** 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(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 ( - <> - - {(props) => ( - - {value} - - ) : ( - - ) - } - neutral - borderOnHover - /> - )} - - e.stopPropagation()} - width={352} - aria-label={t("Emoji Picker")} - > - {popover.visible && ( - <> - {value && ( - handleEmojiChange(null)}> - {t("Remove")} - - )} - - - - > - )} - - > - ); -} - -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; diff --git a/app/components/Icon.tsx b/app/components/Icon.tsx new file mode 100644 index 000000000..4e67bb8d7 --- /dev/null +++ b/app/components/Icon.tsx @@ -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 ( + + ); + } + + return ; + } 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 ( + + {initial} + + ); +}; + +export default Icon; diff --git a/app/components/IconPicker.tsx b/app/components/IconPicker.tsx deleted file mode 100644 index 182f51c4e..000000000 --- a/app/components/IconPicker.tsx +++ /dev/null @@ -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) => { - 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 ( - <> - - {(props) => ( - - - {initial} - - - )} - - - - - {t("Choose an icon")} - - - - {iconNames.map((name, index) => ( - onChange(color, name)}> - {(props) => ( - - - {initial} - - - )} - - ))} - - - - {t("Loading")}โฆ - - } - > - onChange(color.hex, icon)} - colors={colorPalette} - triangle="hide" - styles={styles} - /> - - - - - > - ); -} - -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; diff --git a/app/components/IconPicker/components/ColorPicker.tsx b/app/components/IconPicker/components/ColorPicker.tsx new file mode 100644 index 000000000..1a9a96775 --- /dev/null +++ b/app/components/IconPicker/components/ColorPicker.tsx @@ -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 ? ( + + + + + ) : ( + + + + {panel === Panel.Builtin ? "#" : } + + + {panel === Panel.Builtin ? ( + + ) : ( + + )} + + ); +}; + +const BuiltinColors = ({ + activeColor, + onClick, + className, +}: { + activeColor: string; + onClick: (color: string) => void; + className?: string; +}) => ( + + {colorPalette.map((color) => ( + onClick(color)} + > + + + ))} + +); + +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) => { + 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 ( + + + HEX + + + + ); +}; + +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; diff --git a/app/components/IconPicker/components/Emoji.tsx b/app/components/IconPicker/components/Emoji.tsx new file mode 100644 index 000000000..222314728 --- /dev/null +++ b/app/components/IconPicker/components/Emoji.tsx @@ -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; +`; diff --git a/app/components/IconPicker/components/EmojiPanel.tsx b/app/components/IconPicker/components/EmojiPanel.tsx new file mode 100644 index 000000000..1bc22a362 --- /dev/null +++ b/app/components/IconPicker/components/EmojiPanel.tsx @@ -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( + emojiSkinToneKey, + EmojiSkinTone.Default + ); + const [emojisFreq, setEmojisFreq] = usePersistedState>( + emojisFreqKey, + {} + ); + const [lastEmoji, setLastEmoji] = usePersistedState( + 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(null); + const scrollableRef = React.useRef(null); + + const { + emojiSkinTone: skinTone, + setEmojiSkinTone, + incrementEmojiCount, + getFreqEmojis, + } = useEmojiState(); + + const freqEmojis = React.useMemo(() => getFreqEmojis(), [getFreqEmojis]); + + const handleFilter = React.useCallback( + (event: React.ChangeEvent) => { + 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 ( + + + + + + + + ); +}; + +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; diff --git a/app/components/IconPicker/components/Grid.tsx b/app/components/IconPicker/components/Grid.tsx new file mode 100644 index 000000000..aec654a64 --- /dev/null +++ b/app/components/IconPicker/components/Grid.tsx @@ -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 +) => ( + + {Row} + +); + +type RowProps = { + data: React.ReactNode[][]; + columns: number; +}; + +const Row = ({ index, style, data }: ListChildComponentProps) => { + const { data: rows, columns } = data; + const row = rows[index]; + + return ( + + {row} + + ); +}; + +const Container = styled(FixedSizeList)` + 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); diff --git a/app/components/IconPicker/components/GridTemplate.tsx b/app/components/IconPicker/components/GridTemplate.tsx new file mode 100644 index 000000000..3ab7c12cc --- /dev/null +++ b/app/components/IconPicker/components/GridTemplate.tsx @@ -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 +) => { + // 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 = ( + + {TRANSLATED_CATEGORIES[node.category]} + + ); + + const items = node.icons.map((item) => { + if (item.type === IconType.Outline) { + return ( + onIconSelect({ id: item.name, value: item.name })} + delay={item.delay} + > + + {item.initial} + + + ); + } + + return ( + onIconSelect({ id: item.id, value: item.value })} + > + {item.value} + + ); + }); + + const chunks = chunk(items, itemsPerRow); + return [[category], ...chunks]; + }) + ); + + return ( + + ); +}; + +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); diff --git a/app/components/IconPicker/components/IconButton.tsx b/app/components/IconPicker/components/IconButton.tsx new file mode 100644 index 000000000..af8923fb4 --- /dev/null +++ b/app/components/IconPicker/components/IconButton.tsx @@ -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")}; + } +`; diff --git a/app/components/IconPicker/components/IconPanel.tsx b/app/components/IconPicker/components/IconPanel.tsx new file mode 100644 index 000000000..c5ee144c5 --- /dev/null +++ b/app/components/IconPicker/components/IconPanel.tsx @@ -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>( + iconsFreqKey, + {} + ); + const [lastIcon, setLastIcon] = usePersistedState( + 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(null); + const scrollableRef = React.useRef(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) => { + 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 ( + + + + + + + + ); +}; + +const InputSearchContainer = styled(Flex)` + height: 48px; + padding: 6px 12px 0px; +`; + +const StyledInputSearch = styled(InputSearch)` + flex-grow: 1; +`; + +export default IconPanel; diff --git a/app/components/IconPicker/components/PopoverButton.tsx b/app/components/IconPicker/components/PopoverButton.tsx new file mode 100644 index 000000000..e53bf482e --- /dev/null +++ b/app/components/IconPicker/components/PopoverButton.tsx @@ -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; + `}; + } +`; diff --git a/app/components/IconPicker/components/SkinTonePicker.tsx b/app/components/IconPicker/components/SkinTonePicker.tsx new file mode 100644 index 000000000..2061fd302 --- /dev/null +++ b/app/components/IconPicker/components/SkinTonePicker.tsx @@ -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]) => ( + + {(menuprops) => ( + handleSkinClick(eskin)}> + {emoji.value} + + )} + + )), + [menu, handEmojiVariants, handleSkinClick] + ); + + return ( + <> + + {(props) => ( + + {handEmojiVariants[skinTone]!.value} + + )} + + + {(props) => {menuItems}} + + > + ); +}; + +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; diff --git a/app/components/IconPicker/index.tsx b/app/components/IconPicker/index.tsx new file mode 100644 index 000000000..e5cdd25dd --- /dev/null +++ b/app/components/IconPicker/index.tsx @@ -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(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 ( + <> + + {(props) => ( + + {iconType && icon ? ( + + ) : ( + + )} + + )} + + e.stopPropagation()} + hideOnClickOutside={false} + > + <> + + + + {t("Icons")} + + + {t("Emojis")} + + + {allowDelete && icon && ( + + {t("Remove")} + + )} + + + + + + + + > + + > + ); +}; + +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; diff --git a/app/components/IconPicker/utils.ts b/app/components/IconPicker/utils.ts new file mode 100644 index 000000000..6ef68a969 --- /dev/null +++ b/app/components/IconPicker/utils.ts @@ -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)); diff --git a/app/components/Icons/CollectionIcon.tsx b/app/components/Icons/CollectionIcon.tsx index 2454bc316..f3fbfb345 100644 --- a/app/components/Icons/CollectionIcon.tsx +++ b/app/components/Icons/CollectionIcon.tsx @@ -2,10 +2,11 @@ import { observer } from "mobx-react"; import { CollectionIcon } from "outline-icons"; import { getLuminance } from "polished"; import * as React from "react"; -import { IconLibrary } from "@shared/utils/IconLibrary"; +import { randomElement } from "@shared/random"; +import { colorPalette } from "@shared/utils/collections"; import Collection from "~/models/Collection"; +import Icon from "~/components/Icon"; import useStores from "~/hooks/useStores"; -import Logger from "~/utils/Logger"; type Props = { /** The collection to show an icon for */ @@ -16,6 +17,7 @@ type Props = { size?: number; /** The color of the icon, defaults to the collection color */ color?: string; + className?: string; }; function ResolvedCollectionIcon({ @@ -23,35 +25,41 @@ function ResolvedCollectionIcon({ color: inputColor, expanded, size, + className, }: Props) { const { ui } = useStores(); - // If the chosen icon color is very dark then we invert it in dark mode - // otherwise it will be impossible to see against the dark background. - const color = - inputColor || - (ui.resolvedTheme === "dark" && collection.color !== "currentColor" - ? getLuminance(collection.color) > 0.09 - ? collection.color - : "currentColor" - : collection.color); + if (!collection.icon || collection.icon === "collection") { + // If the chosen icon color is very dark then we invert it in dark mode + // otherwise it will be impossible to see against the dark background. + const collectionColor = collection.color ?? randomElement(colorPalette); + const color = + inputColor || + (ui.resolvedTheme === "dark" && collectionColor !== "currentColor" + ? getLuminance(collectionColor) > 0.09 + ? collectionColor + : "currentColor" + : collectionColor); - if (collection.icon && collection.icon !== "collection") { - try { - const Component = IconLibrary.getComponent(collection.icon); - return ( - - {collection.initial} - - ); - } catch (error) { - Logger.warn("Failed to render custom icon", { - icon: collection.icon, - }); - } + return ( + + ); } - return ; + return ( + + ); } export default observer(ResolvedCollectionIcon); diff --git a/app/components/Icons/EmojiIcon.tsx b/app/components/Icons/EmojiIcon.tsx index d4ce0e61f..651200827 100644 --- a/app/components/Icons/EmojiIcon.tsx +++ b/app/components/Icons/EmojiIcon.tsx @@ -1,11 +1,13 @@ import * as React from "react"; import styled from "styled-components"; +import { s } from "@shared/styles"; type Props = { /** The emoji to render */ emoji: string; /** The size of the emoji, 24px is default to match standard icons */ size?: number; + className?: string; }; /** @@ -15,19 +17,28 @@ type Props = { export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) { return ( - {emoji} + ); } const Span = styled.span<{ $size: number }>` - display: inline-flex; - align-items: center; - justify-content: center; - text-align: center; - flex-shrink: 0; + font-family: ${s("fontFamilyEmoji")}; + display: inline-block; width: ${(props) => props.$size}px; height: ${(props) => props.$size}px; - text-indent: -0.15em; - font-size: ${(props) => props.$size - 10}px; `; + +const SVG = ({ size, emoji }: { size: number; emoji: string }) => ( + + + {emoji} + + +); diff --git a/app/components/Popover.tsx b/app/components/Popover.tsx index eecf0f71e..afc1ba9b4 100644 --- a/app/components/Popover.tsx +++ b/app/components/Popover.tsx @@ -20,15 +20,18 @@ type Props = PopoverProps & { hide: () => void; }; -const Popover: React.FC = ({ - children, - shrink, - width = 380, - scrollable = true, - flex, - mobilePosition, - ...rest -}: Props) => { +const Popover = ( + { + children, + shrink, + width = 380, + scrollable = true, + flex, + mobilePosition, + ...rest + }: Props, + ref: React.Ref +) => { const isMobile = useMobile(); // Custom Escape handler rather than using hideOnEsc from reakit so we can @@ -50,6 +53,7 @@ const Popover: React.FC = ({ return ( = ({ return ( ` `}; `; -export default Popover; +export default React.forwardRef(Popover); diff --git a/app/components/Sharing/Document/OtherAccess.tsx b/app/components/Sharing/Document/OtherAccess.tsx index 1eadd30af..62e299345 100644 --- a/app/components/Sharing/Document/OtherAccess.tsx +++ b/app/components/Sharing/Document/OtherAccess.tsx @@ -4,7 +4,8 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components"; import Squircle from "@shared/components/Squircle"; -import { CollectionPermission } from "@shared/types"; +import { CollectionPermission, IconType } from "@shared/types"; +import { determineIconType } from "@shared/utils/icon"; import type Collection from "~/models/Collection"; import type Document from "~/models/Document"; import Flex from "~/components/Flex"; @@ -54,15 +55,7 @@ export const OtherAccess = observer(({ document, children }: Props) => { /> ) : usersInCollection ? ( - - - } + image={} title={collection.name} subtitle={t("Everyone in the collection")} actions={{t("Can view")}} @@ -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 ( + + + + ); +}; + function useUsersInCollection(collection?: Collection) { const { users, memberships } = useStores(); const { request } = useRequest(() => diff --git a/app/components/Sidebar/components/DocumentLink.tsx b/app/components/Sidebar/components/DocumentLink.tsx index 628bec9bc..6d3581cbb 100644 --- a/app/components/Sidebar/components/DocumentLink.tsx +++ b/app/components/Sidebar/components/DocumentLink.tsx @@ -14,6 +14,7 @@ import { DocumentValidation } from "@shared/validations"; import Collection from "~/models/Collection"; import Document from "~/models/Document"; import Fade from "~/components/Fade"; +import Icon from "~/components/Icon"; import NudeButton from "~/components/NudeButton"; import Tooltip from "~/components/Tooltip"; import useBoolean from "~/hooks/useBoolean"; @@ -282,6 +283,8 @@ function InnerDocumentLink( const title = (activeDocument?.id === node.id ? activeDocument.title : node.title) || t("Untitled"); + const icon = document?.icon || node.icon; + const color = document?.color || node.color; const isExpanded = expanded && !isDragging; const hasChildren = nodeChildren.length > 0; @@ -324,7 +327,7 @@ function InnerDocumentLink( starred: inStarredSection, }, }} - emoji={document?.emoji || node.emoji} + icon={icon && } label={ } label={title} depth={depth} exact={false} diff --git a/app/components/Sidebar/components/SharedWithMeLink.tsx b/app/components/Sidebar/components/SharedWithMeLink.tsx index 9460bed4a..4b370c456 100644 --- a/app/components/Sidebar/components/SharedWithMeLink.tsx +++ b/app/components/Sidebar/components/SharedWithMeLink.tsx @@ -2,7 +2,8 @@ import fractionalIndex from "fractional-index"; import { observer } from "mobx-react"; import * as React from "react"; import styled from "styled-components"; -import { NotificationEventType } from "@shared/types"; +import { IconType, NotificationEventType } from "@shared/types"; +import { determineIconType } from "@shared/utils/icon"; import UserMembership from "~/models/UserMembership"; import Fade from "~/components/Fade"; import useBoolean from "~/hooks/useBoolean"; @@ -78,10 +79,11 @@ function SharedWithMeLink({ userMembership }: Props) { return null; } - const { emoji } = document; - const label = emoji - ? document.title.replace(emoji, "") - : document.titleWithDefault; + const { icon: docIcon } = document; + const label = + determineIconType(docIcon) === IconType.Emoji + ? document.title.replace(docIcon!, "") + : document.titleWithDefault; const collection = document.collectionId ? collections.get(document.collectionId) : undefined; diff --git a/app/components/Sidebar/components/SidebarLink.tsx b/app/components/Sidebar/components/SidebarLink.tsx index ef8258893..67fe54105 100644 --- a/app/components/Sidebar/components/SidebarLink.tsx +++ b/app/components/Sidebar/components/SidebarLink.tsx @@ -5,7 +5,6 @@ import breakpoint from "styled-components-breakpoint"; import EventBoundary from "@shared/components/EventBoundary"; import { s } from "@shared/styles"; import { NavigationNode } from "@shared/types"; -import EmojiIcon from "~/components/Icons/EmojiIcon"; import NudeButton from "~/components/NudeButton"; import { UnreadBadge } from "~/components/UnreadBadge"; import useUnmount from "~/hooks/useUnmount"; @@ -27,7 +26,6 @@ type Props = Omit & { onClickIntent?: () => void; onDisclosureClick?: React.MouseEventHandler; icon?: React.ReactNode; - emoji?: string | null; label?: React.ReactNode; menu?: React.ReactNode; unreadBadge?: boolean; @@ -52,7 +50,6 @@ function SidebarLink( onClick, onClickIntent, to, - emoji, label, active, isActiveDrop, @@ -142,7 +139,6 @@ function SidebarLink( /> )} {icon && {icon}} - {emoji && } {label} {unreadBadge && } diff --git a/app/components/Sidebar/components/useSidebarLabelAndIcon.tsx b/app/components/Sidebar/components/useSidebarLabelAndIcon.tsx index c1afa20a7..b729fb0d2 100644 --- a/app/components/Sidebar/components/useSidebarLabelAndIcon.tsx +++ b/app/components/Sidebar/components/useSidebarLabelAndIcon.tsx @@ -1,7 +1,7 @@ import { DocumentIcon } from "outline-icons"; import * as React from "react"; +import Icon from "~/components/Icon"; import CollectionIcon from "~/components/Icons/CollectionIcon"; -import EmojiIcon from "~/components/Icons/EmojiIcon"; import useStores from "~/hooks/useStores"; interface SidebarItem { @@ -21,7 +21,11 @@ export function useSidebarLabelAndIcon( if (document) { return { label: document.titleWithDefault, - icon: document.emoji ? : icon, + icon: document.icon ? ( + + ) : ( + icon + ), }; } } diff --git a/app/editor/components/EmojiMenu.tsx b/app/editor/components/EmojiMenu.tsx index 0cdb603c2..ce8946aa8 100644 --- a/app/editor/components/EmojiMenu.tsx +++ b/app/editor/components/EmojiMenu.tsx @@ -1,11 +1,7 @@ -import data, { type Emoji as TEmoji } from "@emoji-mart/data"; -import { init, Data } from "emoji-mart"; -import FuzzySearch from "fuzzy-search"; import capitalize from "lodash/capitalize"; -import sortBy from "lodash/sortBy"; import React from "react"; import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji"; -import { isMac } from "@shared/utils/browser"; +import { search as emojiSearch } from "@shared/utils/emoji"; import EmojiMenuItem from "./EmojiMenuItem"; import SuggestionsMenu, { Props as SuggestionsMenuProps, @@ -19,13 +15,6 @@ type Emoji = { attrs: { markup: string; "data-name": string }; }; -init({ - data, - noCountryFlags: isMac() ? false : undefined, -}); - -let searcher: FuzzySearch; - type Props = Omit< SuggestionsMenuProps, "renderMenuItem" | "items" | "embeds" | "trigger" @@ -34,36 +23,26 @@ type Props = Omit< const EmojiMenu = (props: Props) => { const { search = "" } = props; - if (!searcher) { - searcher = new FuzzySearch(Object.values(Data.emojis), ["search"], { - caseSensitive: false, - sort: true, - }); - } + const items = React.useMemo( + () => + emojiSearch({ query: search }) + .map((item) => { + // We snake_case the shortcode for backwards compatability with gemoji to + // avoid multiple formats being written into documents. + const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id); + const emoji = item.value; - const items = React.useMemo(() => { - const n = search.toLowerCase(); - - return sortBy(searcher.search(n), (item) => { - const nlc = item.name.toLowerCase(); - return nlc === n ? -1 : nlc.startsWith(n) ? 0 : 1; - }) - .map((item) => { - // We snake_case the shortcode for backwards compatability with gemoji to - // avoid multiple formats being written into documents. - const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id); - const emoji = item.skins[0].native; - - return { - name: "emoji", - title: emoji, - description: capitalize(item.name.toLowerCase()), - emoji, - attrs: { markup: shortcode, "data-name": shortcode }, - }; - }) - .slice(0, 15); - }, [search]); + return { + name: "emoji", + title: emoji, + description: capitalize(item.name.toLowerCase()), + emoji, + attrs: { markup: shortcode, "data-name": shortcode }, + }; + }) + .slice(0, 15), + [search] + ); return ( { name: item.titleWithDefault, analyticsName: "New document", section: DocumentSection, - icon: item.emoji ? ( - + icon: item.icon ? ( + ) : ( ), diff --git a/app/menus/TemplatesMenu.tsx b/app/menus/TemplatesMenu.tsx index a7d5e3983..9332b8b7f 100644 --- a/app/menus/TemplatesMenu.tsx +++ b/app/menus/TemplatesMenu.tsx @@ -8,7 +8,7 @@ import Button from "~/components/Button"; import ContextMenu from "~/components/ContextMenu"; import MenuItem from "~/components/ContextMenu/MenuItem"; import Separator from "~/components/ContextMenu/Separator"; -import EmojiIcon from "~/components/Icons/EmojiIcon"; +import Icon from "~/components/Icon"; import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; import { replaceTitleVariables } from "~/utils/date"; @@ -43,7 +43,11 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) { key={template.id} onClick={() => onSelectTemplate(template)} icon={ - template.emoji ? : + template.icon ? ( + + ) : ( + + ) } {...menu} > diff --git a/app/models/Collection.ts b/app/models/Collection.ts index 4544c43a6..f9ee80b1c 100644 --- a/app/models/Collection.ts +++ b/app/models/Collection.ts @@ -40,18 +40,18 @@ export default class Collection extends ParanoidModel { data: ProsemirrorData; /** - * An emoji to use as the collection icon. + * An icon (or) emoji to use as the collection icon. */ @Field @observable icon: string; /** - * A color to use for the collection icon and other highlights. + * The color to use for the collection icon and other highlights. */ @Field @observable - color: string; + color?: string | null; /** * The default permission for workspace users. diff --git a/app/models/Document.ts b/app/models/Document.ts index 7bd32f32e..77cc48b11 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -129,11 +129,18 @@ export default class Document extends ParanoidModel { title: string; /** - * An emoji to use as the document icon. + * An icon (or) emoji to use as the document icon. */ @Field @observable - emoji: string | undefined | null; + icon?: string | null; + + /** + * The color to use for the document icon. + */ + @Field + @observable + color?: string | null; /** * Whether this is a template. diff --git a/app/models/Revision.ts b/app/models/Revision.ts index 1a73a6145..50be73d49 100644 --- a/app/models/Revision.ts +++ b/app/models/Revision.ts @@ -22,8 +22,11 @@ class Revision extends Model { /** Prosemirror data of the content when revision was created */ data: ProsemirrorData; - /** The emoji of the document when the revision was created */ - emoji: string | null; + /** The icon (or) emoji of the document when the revision was created */ + icon: string | null; + + /** The color of the document icon when the revision was created */ + color: string | null; /** HTML string representing the revision as a diff from the previous version */ html: string; diff --git a/app/scenes/Collection/index.tsx b/app/scenes/Collection/index.tsx index f2b3e1e2d..29564982a 100644 --- a/app/scenes/Collection/index.tsx +++ b/app/scenes/Collection/index.tsx @@ -306,8 +306,9 @@ const HeadingWithIcon = styled(Heading)` `; const HeadingIcon = styled(CollectionIcon)` - align-self: flex-start; flex-shrink: 0; + margin-left: -8px; + margin-right: 8px; `; export default observer(CollectionScene); diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index a13bc04c9..2ffc66ef5 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -19,9 +19,15 @@ import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper"; import { s } from "@shared/styles"; -import { NavigationNode, TOCPosition, TeamPreference } from "@shared/types"; +import { + IconType, + NavigationNode, + TOCPosition, + TeamPreference, +} from "@shared/types"; import { ProsemirrorHelper, Heading } from "@shared/utils/ProsemirrorHelper"; import { parseDomain } from "@shared/utils/domains"; +import { determineIconType } from "@shared/utils/icon"; import RootStore from "~/stores/RootStore"; import Document from "~/models/Document"; import Revision from "~/models/Revision"; @@ -169,8 +175,11 @@ class DocumentScene extends React.Component { this.title = title; this.props.document.title = title; } - if (template.emoji) { - this.props.document.emoji = template.emoji; + if (template.icon) { + this.props.document.icon = template.icon; + } + if (template.color) { + this.props.document.color = template.color; } this.props.document.data = cloneDeep(template.data); @@ -383,8 +392,9 @@ class DocumentScene extends React.Component { void this.autosave(); }); - handleChangeEmoji = action((value: string) => { - this.props.document.emoji = value; + handleChangeIcon = action((icon: string | null, color: string | null) => { + this.props.document.icon = icon; + this.props.document.color = color; void this.onSave(); }); @@ -425,6 +435,12 @@ class DocumentScene extends React.Component { ? this.props.match.url : updateDocumentPath(this.props.match.url, document); + const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji; + const title = hasEmojiInTitle + ? document.titleWithDefault.replace(document.icon!, "") + : document.titleWithDefault; + const favicon = hasEmojiInTitle ? emojiToUrl(document.icon!) : undefined; + return ( {this.props.location.pathname !== canonicalUrl && ( @@ -459,10 +475,7 @@ class DocumentScene extends React.Component { column auto > - + {(this.isUploading || this.isSaving) && } {!readOnly && ( @@ -542,7 +555,7 @@ class DocumentScene extends React.Component { onSearchLink={this.props.onSearchLink} onCreateLink={this.props.onCreateLink} onChangeTitle={this.handleChangeTitle} - onChangeEmoji={this.handleChangeEmoji} + onChangeIcon={this.handleChangeIcon} onChange={this.handleChange} onHeadingsChange={this.onHeadingsChange} onSave={this.onSave} diff --git a/app/scenes/Document/components/DocumentTitle.tsx b/app/scenes/Document/components/DocumentTitle.tsx index 7eadb76a5..7d689a69d 100644 --- a/app/scenes/Document/components/DocumentTitle.tsx +++ b/app/scenes/Document/components/DocumentTitle.tsx @@ -18,29 +18,32 @@ import { import { DocumentValidation } from "@shared/validations"; import ContentEditable, { RefHandle } from "~/components/ContentEditable"; import { useDocumentContext } from "~/components/DocumentContext"; -import { Emoji, EmojiButton } from "~/components/EmojiPicker/components"; import Flex from "~/components/Flex"; +import Icon from "~/components/Icon"; +import { PopoverButton } from "~/components/IconPicker/components/PopoverButton"; import useBoolean from "~/hooks/useBoolean"; import usePolicy from "~/hooks/usePolicy"; import { isModKey } from "~/utils/keyboard"; -const EmojiPicker = React.lazy(() => import("~/components/EmojiPicker")); +const IconPicker = React.lazy(() => import("~/components/IconPicker")); type Props = { /** ID of the associated document */ documentId: string; /** Title to display */ title: string; - /** Emoji to display */ - emoji?: string | null; + /** Icon to display */ + icon?: string | null; + /** Icon color */ + color: string; /** Placeholder to display when the document has no title */ placeholder?: string; /** Should the title be editable, policies will also be considered separately */ readOnly?: boolean; /** Callback called on any edits to text */ onChangeTitle?: (text: string) => void; - /** Callback called when the user selects an emoji */ - onChangeEmoji?: (emoji: string | null) => void; + /** Callback called when the user selects an icon */ + onChangeIcon?: (icon: string | null, color: string | null) => void; /** Callback called when the user expects to move to the "next" input */ onGoToNextInput?: (insertParagraph?: boolean) => void; /** Callback called when the user expects to save (CMD+S) */ @@ -56,10 +59,11 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle( { documentId, title, - emoji, + icon, + color, readOnly, onChangeTitle, - onChangeEmoji, + onChangeIcon, onSave, onGoToNextInput, onBlur, @@ -68,7 +72,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle( externalRef: React.RefObject ) { const ref = React.useRef(null); - const [emojiPickerIsOpen, handleOpen, handleClose] = useBoolean(); + const [iconPickerIsOpen, handleOpen, handleClose] = useBoolean(); const { editor } = useDocumentContext(); const can = usePolicy(documentId); @@ -212,19 +216,26 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle( [editor] ); - const handleEmojiChange = React.useCallback( - async (value: string | null) => { - // Restore focus on title - restoreFocus(); - if (emoji !== value) { - onChangeEmoji?.(value); + const handleIconChange = React.useCallback( + (chosenIcon: string | null, iconColor: string | null) => { + if (icon !== chosenIcon || color !== iconColor) { + onChangeIcon?.(chosenIcon, iconColor); } }, - [emoji, onChangeEmoji, restoreFocus] + [icon, color, onChangeIcon] ); + React.useEffect(() => { + if (!iconPickerIsOpen) { + restoreFocus(); + } + }, [iconPickerIsOpen, restoreFocus]); + const dir = ref.current?.getComputedDirection(); - const emojiIcon = {emoji}; + + const fallbackIcon = icon ? ( + + ) : null; return ( {can.update && !readOnly ? ( - - - + + - - ) : emoji ? ( - - {emojiIcon} - + + ) : icon ? ( + + {fallbackIcon} + ) : null} ); }); -const StyledEmojiPicker = styled(EmojiPicker)` - ${extraArea(8)} -`; - -const EmojiWrapper = styled(Flex)<{ dir?: string }>` - position: absolute; - top: 8px; - height: 32px; - width: 32px; - - // Always move above TOC - z-index: 1; - - ${(props: { dir?: string }) => - props.dir === "rtl" ? "right: -40px" : "left: -40px"}; -`; - type TitleProps = { - $containsEmoji: boolean; - $emojiPickerIsOpen: boolean; + $containsIcon: boolean; + $iconPickerIsOpen: boolean; }; const Title = styled(ContentEditable)` @@ -293,7 +290,7 @@ const Title = styled(ContentEditable)` margin-top: 6vh; margin-bottom: 0.5em; margin-left: ${(props) => - props.$containsEmoji || props.$emojiPickerIsOpen ? "40px" : "0px"}; + props.$containsIcon || props.$iconPickerIsOpen ? "40px" : "0px"}; font-size: ${fontSize}; font-weight: 600; border: 0; @@ -314,14 +311,14 @@ const Title = styled(ContentEditable)` &:focus { margin-left: 40px; - ${EmojiButton} { + ${PopoverButton} { opacity: 1 !important; } } - ${EmojiButton} { + ${PopoverButton} { opacity: ${(props: TitleProps) => - props.$containsEmoji ? "1 !important" : 0}; + props.$containsIcon ? "1 !important" : 0}; } ${breakpoint("tablet")` @@ -333,7 +330,7 @@ const Title = styled(ContentEditable)` } &:hover { - ${EmojiButton} { + ${PopoverButton} { opacity: 0.5; &:hover { @@ -349,4 +346,21 @@ const Title = styled(ContentEditable)` } `; +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); diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 789bf1f6a..f827efc20 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -4,7 +4,9 @@ import { useTranslation } from "react-i18next"; import { mergeRefs } from "react-merge-refs"; import { useHistory, useRouteMatch } from "react-router-dom"; import { richExtensions, withComments } from "@shared/editor/nodes"; +import { randomElement } from "@shared/random"; import { TeamPreference } from "@shared/types"; +import { colorPalette } from "@shared/utils/collections"; import Comment from "~/models/Comment"; import Document from "~/models/Document"; import { RefHandle } from "~/components/ContentEditable"; @@ -52,7 +54,7 @@ const extensions = [ type Props = Omit & { onChangeTitle: (title: string) => void; - onChangeEmoji: (emoji: string | null) => void; + onChangeIcon: (icon: string | null, color: string | null) => void; id: string; document: Document; isDraft: boolean; @@ -81,7 +83,7 @@ function DocumentEditor(props: Props, ref: React.RefObject) { const { document, onChangeTitle, - onChangeEmoji, + onChangeIcon, isDraft, shareId, readOnly, @@ -91,6 +93,10 @@ function DocumentEditor(props: Props, ref: React.RefObject) { } = props; const can = usePolicy(document); + const iconColor = React.useMemo( + () => document.color ?? randomElement(colorPalette), + [document.color] + ); const childRef = React.useRef(null); const focusAtStart = React.useCallback(() => { if (ref.current) { @@ -186,9 +192,10 @@ function DocumentEditor(props: Props, ref: React.RefObject) { ? document.titleWithDefault : document.title } - emoji={document.emoji} + icon={document.icon} + color={iconColor} onChangeTitle={onChangeTitle} - onChangeEmoji={onChangeEmoji} + onChangeIcon={onChangeIcon} onGoToNextInput={handleGoToNextInput} onBlur={handleBlur} placeholder={t("Untitled")} diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index 01fb61833..79a57a717 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -24,8 +24,9 @@ import { useDocumentContext, useEditingFocus, } from "~/components/DocumentContext"; +import Flex from "~/components/Flex"; import Header from "~/components/Header"; -import EmojiIcon from "~/components/Icons/EmojiIcon"; +import Icon from "~/components/Icon"; import Star from "~/components/Star"; import Tooltip from "~/components/Tooltip"; import { publishDocument } from "~/actions/definitions/documents"; @@ -189,7 +190,14 @@ function DocumentHeader({ return ( + {document.icon && ( + + )} + {document.title} + + } hasSidebar={sharedTree && sharedTree.children?.length > 0} left={ isMobile ? ( @@ -229,17 +237,15 @@ function DocumentHeader({ ) } title={ - <> - {document.emoji && ( - <> - {" "} - > + + {document.icon && ( + )} - {document.title}{" "} + {document.title} {document.isArchived && ( {t("Archived")} )} - > + } actions={ <> diff --git a/app/scenes/Document/components/PublicBreadcrumb.tsx b/app/scenes/Document/components/PublicBreadcrumb.tsx index 806801626..5b9f95b46 100644 --- a/app/scenes/Document/components/PublicBreadcrumb.tsx +++ b/app/scenes/Document/components/PublicBreadcrumb.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { NavigationNode } from "@shared/types"; import Breadcrumb from "~/components/Breadcrumb"; -import EmojiIcon from "~/components/Icons/EmojiIcon"; +import Icon from "~/components/Icon"; import { MenuInternalLink } from "~/types"; import { sharedDocumentPath } from "~/utils/routeHelpers"; @@ -53,13 +53,10 @@ const PublicBreadcrumb: React.FC = ({ .slice(0, -1) .map((item) => ({ ...item, - title: item.emoji ? ( - <> - {item.title} - > - ) : ( - item.title - ), + icon: item.icon ? ( + + ) : undefined, + title: item.title, type: "route", to: sharedDocumentPath(shareId, item.url), })), diff --git a/app/scenes/Document/components/ReferenceListItem.tsx b/app/scenes/Document/components/ReferenceListItem.tsx index 66a5306c2..7106fbd95 100644 --- a/app/scenes/Document/components/ReferenceListItem.tsx +++ b/app/scenes/Document/components/ReferenceListItem.tsx @@ -4,10 +4,11 @@ import * as React from "react"; import { Link } from "react-router-dom"; import styled from "styled-components"; import { s, ellipsis } from "@shared/styles"; -import { NavigationNode } from "@shared/types"; +import { IconType, NavigationNode } from "@shared/types"; +import { determineIconType } from "@shared/utils/icon"; import Document from "~/models/Document"; import Flex from "~/components/Flex"; -import EmojiIcon from "~/components/Icons/EmojiIcon"; +import Icon from "~/components/Icon"; import { hover } from "~/styles"; import { sharedDocumentPath } from "~/utils/routeHelpers"; @@ -58,7 +59,8 @@ function ReferenceListItem({ shareId, ...rest }: Props) { - const { emoji } = document; + const { icon, color } = document; + const isEmoji = determineIconType(icon) === IconType.Emoji; return ( - {emoji ? : } + {icon ? ( + + ) : ( + + )} - {emoji ? document.title.replace(emoji, "") : document.title} + {isEmoji ? document.title.replace(icon!, "") : document.title} diff --git a/app/scenes/Document/components/RevisionViewer.tsx b/app/scenes/Document/components/RevisionViewer.tsx index 549e3c1ca..08b017360 100644 --- a/app/scenes/Document/components/RevisionViewer.tsx +++ b/app/scenes/Document/components/RevisionViewer.tsx @@ -1,6 +1,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import EditorContainer from "@shared/editor/components/Styles"; +import { colorPalette } from "@shared/utils/collections"; import Document from "~/models/Document"; import Revision from "~/models/Revision"; import { Props as EditorProps } from "~/components/Editor"; @@ -30,7 +31,8 @@ function RevisionViewer(props: Props) { { paranoid: false, }); + const collection = model && (await presentCollection(undefined, model)); + if (collection) { + // For backward compatibility, set a default color. + collection.color = collection.color ?? colorPalette[0]; + } + await this.sendWebhook({ event, subscription, payload: { id: event.collectionId, - model: model && (await presentCollection(undefined, model)), + model: collection, }, }); } @@ -448,14 +455,20 @@ export default class DeliverWebhookTask extends BaseTask { paranoid: false, }); + const collection = + model && (await presentCollection(undefined, model.collection!)); + if (collection) { + // For backward compatibility, set a default color. + collection.color = collection.color ?? colorPalette[0]; + } + await this.sendWebhook({ event, subscription, payload: { id: event.modelId, model: model && presentMembership(model), - collection: - model && (await presentCollection(undefined, model.collection!)), + collection, user: model && presentUser(model.user), }, }); @@ -476,14 +489,20 @@ export default class DeliverWebhookTask extends BaseTask { paranoid: false, }); + const collection = + model && (await presentCollection(undefined, model.collection!)); + if (collection) { + // For backward compatibility, set a default color. + collection.color = collection.color ?? colorPalette[0]; + } + await this.sendWebhook({ event, subscription, payload: { id: event.modelId, model: model && presentCollectionGroupMembership(model), - collection: - model && (await presentCollection(undefined, model.collection!)), + collection, group: model && presentGroup(model.group), }, }); diff --git a/server/commands/documentCreator.ts b/server/commands/documentCreator.ts index b9e8286f6..be0115af8 100644 --- a/server/commands/documentCreator.ts +++ b/server/commands/documentCreator.ts @@ -12,7 +12,8 @@ type Props = Optional< | "title" | "text" | "content" - | "emoji" + | "icon" + | "color" | "collectionId" | "parentDocumentId" | "importId" @@ -36,7 +37,8 @@ type Props = Optional< export default async function documentCreator({ title = "", text = "", - emoji, + icon, + color, state, id, urlId, @@ -96,9 +98,9 @@ export default async function documentCreator({ importId, sourceMetadata, fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth, - emoji: templateDocument ? templateDocument.emoji : emoji, - icon: templateDocument ? templateDocument.emoji : emoji, - color: templateDocument ? templateDocument.color : null, + emoji: templateDocument ? templateDocument.emoji : icon, + icon: templateDocument ? templateDocument.emoji : icon, + color: templateDocument ? templateDocument.color : color, title: TextHelper.replaceTemplateVariables( templateDocument ? templateDocument.title : title, user diff --git a/server/commands/documentDuplicator.test.ts b/server/commands/documentDuplicator.test.ts index 4d88964aa..b841b5e5c 100644 --- a/server/commands/documentDuplicator.test.ts +++ b/server/commands/documentDuplicator.test.ts @@ -26,7 +26,8 @@ describe("documentDuplicator", () => { expect(response[0].title).toEqual(original.title); expect(response[0].text).toEqual(original.text); expect(response[0].emoji).toEqual(original.emoji); - expect(response[0].icon).toEqual(original.emoji); + expect(response[0].icon).toEqual(original.icon); + expect(response[0].color).toEqual(original.color); expect(response[0].publishedAt).toBeInstanceOf(Date); }); @@ -35,7 +36,7 @@ describe("documentDuplicator", () => { const original = await buildDocument({ userId: user.id, teamId: user.teamId, - emoji: "๐", + icon: "๐", }); const response = await sequelize.transaction((transaction) => @@ -52,8 +53,9 @@ describe("documentDuplicator", () => { expect(response).toHaveLength(1); expect(response[0].title).toEqual("New title"); expect(response[0].text).toEqual(original.text); - expect(response[0].emoji).toEqual(original.emoji); - expect(response[0].icon).toEqual(original.emoji); + expect(response[0].emoji).toEqual(original.icon); + expect(response[0].icon).toEqual(original.icon); + expect(response[0].color).toEqual(original.color); expect(response[0].publishedAt).toBeInstanceOf(Date); }); @@ -62,7 +64,7 @@ describe("documentDuplicator", () => { const original = await buildDocument({ userId: user.id, teamId: user.teamId, - emoji: "๐", + icon: "๐", }); await buildDocument({ @@ -108,7 +110,8 @@ describe("documentDuplicator", () => { expect(response[0].title).toEqual(original.title); expect(response[0].text).toEqual(original.text); expect(response[0].emoji).toEqual(original.emoji); - expect(response[0].icon).toEqual(original.emoji); + expect(response[0].icon).toEqual(original.icon); + expect(response[0].color).toEqual(original.color); expect(response[0].publishedAt).toBeNull(); }); }); diff --git a/server/commands/documentDuplicator.ts b/server/commands/documentDuplicator.ts index eb094bfd7..8e0b32928 100644 --- a/server/commands/documentDuplicator.ts +++ b/server/commands/documentDuplicator.ts @@ -45,7 +45,8 @@ export default async function documentDuplicator({ const duplicated = await documentCreator({ parentDocumentId: parentDocumentId ?? document.parentDocumentId, - emoji: document.emoji, + icon: document.icon ?? document.emoji, + color: document.color, template: document.template, title: title ?? document.title, content: document.content, @@ -78,7 +79,8 @@ export default async function documentDuplicator({ for (const childDocument of childDocuments) { const duplicatedChildDocument = await documentCreator({ parentDocumentId: duplicated.id, - emoji: childDocument.emoji, + icon: childDocument.icon ?? childDocument.emoji, + color: childDocument.color, title: childDocument.title, text: childDocument.text, ...sharedProperties, diff --git a/server/commands/documentImporter.ts b/server/commands/documentImporter.ts index a17842c62..13fa95670 100644 --- a/server/commands/documentImporter.ts +++ b/server/commands/documentImporter.ts @@ -28,7 +28,7 @@ async function documentImporter({ ip, transaction, }: Props): Promise<{ - emoji?: string; + icon?: string; text: string; title: string; state: Buffer; @@ -43,9 +43,9 @@ async function documentImporter({ // find and extract emoji near the beginning of the document. const regex = emojiRegex(); const matches = regex.exec(text.slice(0, 10)); - const emoji = matches ? matches[0] : undefined; - if (emoji) { - text = text.replace(emoji, ""); + const icon = matches ? matches[0] : undefined; + if (icon) { + text = text.replace(icon, ""); } // If the first line of the imported text looks like a markdown heading @@ -96,7 +96,7 @@ async function documentImporter({ text, state, title, - emoji, + icon, }; } diff --git a/server/commands/documentUpdater.ts b/server/commands/documentUpdater.ts index 80c76bbdd..155278fa9 100644 --- a/server/commands/documentUpdater.ts +++ b/server/commands/documentUpdater.ts @@ -9,8 +9,10 @@ type Props = { document: Document; /** The new title */ title?: string; - /** The document emoji */ - emoji?: string | null; + /** The document icon */ + icon?: string | null; + /** The document icon's color */ + color?: string | null; /** The new text content */ text?: string; /** Whether the editing session is complete */ @@ -46,7 +48,8 @@ export default async function documentUpdater({ user, document, title, - emoji, + icon, + color, text, editorVersion, templateId, @@ -65,9 +68,12 @@ export default async function documentUpdater({ if (title !== undefined) { document.title = title.trim(); } - if (emoji !== undefined) { - document.emoji = emoji; - document.icon = emoji; + if (icon !== undefined) { + document.emoji = icon; + document.icon = icon; + } + if (color !== undefined) { + document.color = color; } if (editorVersion) { document.editorVersion = editorVersion; diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 13ada8814..2bf9509fb 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -183,6 +183,7 @@ class Collection extends ParanoidModel< @Column(DataType.JSONB) content: ProsemirrorData | null; + /** An icon (or) emoji to use as the collection icon. */ @Length({ max: 50, msg: `icon must be 50 characters or less`, @@ -190,6 +191,7 @@ class Collection extends ParanoidModel< @Column icon: string | null; + /** The color of the icon. */ @IsHexColor @Column color: string | null; @@ -270,10 +272,6 @@ class Collection extends ParanoidModel< @BeforeSave static async onBeforeSave(model: Collection) { - if (model.icon === "collection") { - model.icon = null; - } - if (!model.content) { model.content = await DocumentHelper.toJSON(model); } diff --git a/server/models/Document.ts b/server/models/Document.ts index b3c4a723f..3de0309db 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -255,14 +255,18 @@ class Document extends ParanoidModel< @Column editorVersion: string; - /** An emoji to use as the document icon. */ + /** + * An emoji to use as the document icon, + * This is used as fallback (for backward compat) when icon is not set. + */ @Length({ - max: 1, - msg: `Emoji must be a single character`, + max: 50, + msg: `Emoji must be 50 characters or less`, }) @Column emoji: string | null; + /** An icon to use as the document icon. */ @Length({ max: 50, msg: `icon must be 50 characters or less`, @@ -365,7 +369,11 @@ class Document extends ParanoidModel< model.archivedAt || model.template || !model.publishedAt || - !(model.changed("title") || model.changed("emoji")) || + !( + model.changed("title") || + model.changed("icon") || + model.changed("color") + ) || !model.collectionId ) { return; @@ -721,6 +729,8 @@ class Document extends ParanoidModel< this.text = revision.text; this.title = revision.title; this.emoji = revision.emoji; + this.icon = revision.icon; + this.color = revision.color; }; /** @@ -1083,6 +1093,8 @@ class Document extends ParanoidModel< title: this.title, url: this.url, emoji: isNil(this.emoji) ? undefined : this.emoji, + icon: isNil(this.icon) ? undefined : this.icon, + color: isNil(this.color) ? undefined : this.color, children, }; }; diff --git a/server/models/Revision.ts b/server/models/Revision.ts index f1e8500fe..b2e39b00e 100644 --- a/server/models/Revision.ts +++ b/server/models/Revision.ts @@ -71,13 +71,18 @@ class Revision extends IdModel< @Column(DataType.JSONB) content: ProsemirrorData; + /** + * An emoji to use as the document icon, + * This is used as fallback (for backward compat) when icon is not set. + */ @Length({ - max: 1, - msg: `Emoji must be a single character`, + max: 50, + msg: `Emoji must be 50 characters or less`, }) @Column emoji: string | null; + /** An icon to use as the document icon. */ @Length({ max: 50, msg: `icon must be 50 characters or less`, @@ -134,7 +139,7 @@ class Revision extends IdModel< title: document.title, text: document.text, emoji: document.emoji, - icon: document.emoji, + icon: document.icon, color: document.color, content: document.content, userId: document.lastModifiedById, diff --git a/server/models/helpers/DocumentHelper.tsx b/server/models/helpers/DocumentHelper.tsx index 0d5bf837a..be461d42f 100644 --- a/server/models/helpers/DocumentHelper.tsx +++ b/server/models/helpers/DocumentHelper.tsx @@ -8,7 +8,8 @@ import { Node } from "prosemirror-model"; import * as Y from "yjs"; import textBetween from "@shared/editor/lib/textBetween"; import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper"; -import { ProsemirrorData } from "@shared/types"; +import { IconType, ProsemirrorData } from "@shared/types"; +import { determineIconType } from "@shared/utils/icon"; import { parser, serializer, schema } from "@server/editor"; import { addTags } from "@server/logging/tracer"; import { trace } from "@server/logging/tracing"; @@ -148,7 +149,10 @@ export class DocumentHelper { return text; } - const title = `${document.emoji ? document.emoji + " " : ""}${ + const icon = document.icon ?? document.emoji; + const iconType = determineIconType(icon); + + const title = `${iconType === IconType.Emoji ? icon + " " : ""}${ document.title }`; diff --git a/server/presenters/collection.ts b/server/presenters/collection.ts index 70e8da24c..0e7a6da5e 100644 --- a/server/presenters/collection.ts +++ b/server/presenters/collection.ts @@ -1,4 +1,3 @@ -import { colorPalette } from "@shared/utils/collections"; import Collection from "@server/models/Collection"; import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; import { APIContext } from "@server/types"; @@ -19,7 +18,7 @@ export default async function presentCollection( sort: collection.sort, icon: collection.icon, index: collection.index, - color: collection.color || colorPalette[0], + color: collection.color, permission: collection.permission, sharing: collection.sharing, createdAt: collection.createdAt, diff --git a/server/presenters/document.ts b/server/presenters/document.ts index a35396556..476560528 100644 --- a/server/presenters/document.ts +++ b/server/presenters/document.ts @@ -49,6 +49,8 @@ async function presentDocument( : undefined, text: !asData || options?.includeText ? text : undefined, emoji: document.emoji, + icon: document.icon, + color: document.color, tasks: document.tasks, createdAt: document.createdAt, createdBy: undefined, diff --git a/server/presenters/revision.ts b/server/presenters/revision.ts index df6c90875..953b47898 100644 --- a/server/presenters/revision.ts +++ b/server/presenters/revision.ts @@ -13,7 +13,8 @@ async function presentRevision(revision: Revision, diff?: string) { documentId: revision.documentId, title: strippedTitle, data: await DocumentHelper.toJSON(revision), - emoji: revision.emoji ?? emoji, + icon: revision.icon ?? revision.emoji ?? emoji, + color: revision.color, html: diff, createdAt: revision.createdAt, createdBy: presentUser(revision.user), diff --git a/server/queues/tasks/DocumentImportTask.ts b/server/queues/tasks/DocumentImportTask.ts index 0dff34cce..78a787b6c 100644 --- a/server/queues/tasks/DocumentImportTask.ts +++ b/server/queues/tasks/DocumentImportTask.ts @@ -43,7 +43,7 @@ export default class DocumentImportTask extends BaseTask { transaction, }); - const { text, state, title, emoji } = await documentImporter({ + const { text, state, title, icon } = await documentImporter({ user, fileName: sourceMetadata.fileName, mimeType: sourceMetadata.mimeType, @@ -55,7 +55,7 @@ export default class DocumentImportTask extends BaseTask { return documentCreator({ sourceMetadata, title, - emoji, + icon, text, state, publish, diff --git a/server/queues/tasks/ExportJSONTask.ts b/server/queues/tasks/ExportJSONTask.ts index 17f5a6608..f888f947c 100644 --- a/server/queues/tasks/ExportJSONTask.ts +++ b/server/queues/tasks/ExportJSONTask.ts @@ -124,7 +124,8 @@ export default class ExportJSONTask extends ExportTask { id: document.id, urlId: document.urlId, title: document.title, - emoji: document.emoji, + icon: document.icon, + color: document.color, data: DocumentHelper.toProsemirror(document), createdById: document.createdById, createdByName: document.createdBy.name, diff --git a/server/queues/tasks/ImportJSONTask.ts b/server/queues/tasks/ImportJSONTask.ts index a7cb25453..64a6d0cb2 100644 --- a/server/queues/tasks/ImportJSONTask.ts +++ b/server/queues/tasks/ImportJSONTask.ts @@ -79,9 +79,9 @@ export default class ImportJSONTask extends ImportTask { // TODO: This is kind of temporary, we can import the document // structure directly in the future. text: serializer.serialize(Node.fromJSON(schema, node.data)), - emoji: node.emoji, - icon: node.emoji, - color: null, + emoji: node.icon ?? node.emoji, + icon: node.icon ?? node.emoji, + color: node.color, createdAt: node.createdAt ? new Date(node.createdAt) : undefined, updatedAt: node.updatedAt ? new Date(node.updatedAt) : undefined, publishedAt: node.publishedAt ? new Date(node.publishedAt) : null, diff --git a/server/queues/tasks/ImportMarkdownZipTask.ts b/server/queues/tasks/ImportMarkdownZipTask.ts index 6a7ec2c15..8d1d8d4f4 100644 --- a/server/queues/tasks/ImportMarkdownZipTask.ts +++ b/server/queues/tasks/ImportMarkdownZipTask.ts @@ -79,7 +79,7 @@ export default class ImportMarkdownZipTask extends ImportTask { return; } - const { title, emoji, text } = await documentImporter({ + const { title, icon, text } = await documentImporter({ mimeType: "text/markdown", fileName: child.name, content: @@ -115,8 +115,8 @@ export default class ImportMarkdownZipTask extends ImportTask { output.documents.push({ id, title, - emoji, - icon: emoji, + emoji: icon, + icon, text, collectionId, parentDocumentId, diff --git a/server/queues/tasks/ImportNotionTask.ts b/server/queues/tasks/ImportNotionTask.ts index 86d01d683..ac315f8ea 100644 --- a/server/queues/tasks/ImportNotionTask.ts +++ b/server/queues/tasks/ImportNotionTask.ts @@ -96,7 +96,7 @@ export default class ImportNotionTask extends ImportTask { Logger.debug("task", `Processing ${name} as ${mimeType}`); - const { title, emoji, text } = await documentImporter({ + const { title, icon, text } = await documentImporter({ mimeType: mimeType || "text/markdown", fileName: name, content: @@ -130,8 +130,8 @@ export default class ImportNotionTask extends ImportTask { output.documents.push({ id, title, - emoji, - icon: emoji, + emoji: icon, + icon, text, collectionId, parentDocumentId, diff --git a/server/queues/tasks/ImportTask.ts b/server/queues/tasks/ImportTask.ts index a0d9d5f99..8596a1574 100644 --- a/server/queues/tasks/ImportTask.ts +++ b/server/queues/tasks/ImportTask.ts @@ -38,7 +38,7 @@ export type StructuredImportData = { collections: { id: string; urlId?: string; - color?: string; + color?: string | null; icon?: string | null; sort?: CollectionSort; permission?: CollectionPermission | null; diff --git a/server/routes/api/collections/collections.test.ts b/server/routes/api/collections/collections.test.ts index b50d44df1..58dc0841b 100644 --- a/server/routes/api/collections/collections.test.ts +++ b/server/routes/api/collections/collections.test.ts @@ -1,5 +1,4 @@ import { CollectionPermission } from "@shared/types"; -import { colorPalette } from "@shared/utils/collections"; import { Document, UserMembership, GroupPermission } from "@server/models"; import { buildUser, @@ -182,6 +181,23 @@ describe("#collections.move", () => { expect(body.success).toBe(true); }); + it("should allow setting an emoji as icon", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + const collection = await buildCollection({ teamId: team.id }); + const res = await server.post("/api/collections.move", { + body: { + token: admin.getJwtToken(), + id: collection.id, + index: "P", + icon: "๐", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.success).toBe(true); + }); + it("should return error when icon is not valid", async () => { const team = await buildTeam(); const admin = await buildAdmin({ teamId: team.id }); @@ -1150,7 +1166,6 @@ describe("#collections.create", () => { expect(body.data.name).toBe("Test"); expect(body.data.sort.field).toBe("index"); expect(body.data.sort.direction).toBe("asc"); - expect(colorPalette.includes(body.data.color)).toBeTruthy(); expect(body.policies.length).toBe(1); expect(body.policies[0].abilities.read).toBeTruthy(); }); diff --git a/server/routes/api/collections/schema.ts b/server/routes/api/collections/schema.ts index 0c33e4c23..c69097530 100644 --- a/server/routes/api/collections/schema.ts +++ b/server/routes/api/collections/schema.ts @@ -1,21 +1,13 @@ +import emojiRegex from "emoji-regex"; import isUndefined from "lodash/isUndefined"; import { z } from "zod"; -import { randomElement } from "@shared/random"; import { CollectionPermission, FileOperationFormat } from "@shared/types"; import { IconLibrary } from "@shared/utils/IconLibrary"; -import { colorPalette } from "@shared/utils/collections"; import { Collection } from "@server/models"; +import { zodEnumFromObjectKeys } from "@server/utils/zod"; import { ValidateColor, ValidateIndex } from "@server/validation"; import { BaseSchema, ProsemirrorSchema } from "../schema"; -function zodEnumFromObjectKeys< - TI extends Record, - R extends string = TI extends Record ? R : never ->(input: TI): z.ZodEnum<[R, ...R[]]> { - const [firstKey, ...otherKeys] = Object.keys(input) as [R, ...R[]]; - return z.enum([firstKey, ...otherKeys]); -} - const BaseIdSchema = z.object({ /** Id of the collection to be updated */ id: z.string(), @@ -27,7 +19,7 @@ export const CollectionsCreateSchema = BaseSchema.extend({ color: z .string() .regex(ValidateColor.regex, { message: ValidateColor.message }) - .default(randomElement(colorPalette)), + .nullish(), description: z.string().nullish(), data: ProsemirrorSchema.nullish(), permission: z @@ -35,7 +27,12 @@ export const CollectionsCreateSchema = BaseSchema.extend({ .nullish() .transform((val) => (isUndefined(val) ? null : val)), sharing: z.boolean().default(true), - icon: zodEnumFromObjectKeys(IconLibrary.mapping).optional(), + icon: z + .union([ + z.string().regex(emojiRegex()), + zodEnumFromObjectKeys(IconLibrary.mapping), + ]) + .optional(), sort: z .object({ field: z.union([z.literal("title"), z.literal("index")]), @@ -174,7 +171,12 @@ export const CollectionsUpdateSchema = BaseSchema.extend({ name: z.string().optional(), description: z.string().nullish(), data: ProsemirrorSchema.nullish(), - icon: zodEnumFromObjectKeys(IconLibrary.mapping).nullish(), + icon: z + .union([ + z.string().regex(emojiRegex()), + zodEnumFromObjectKeys(IconLibrary.mapping), + ]) + .nullish(), permission: z.nativeEnum(CollectionPermission).nullish(), color: z .string() diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index d7be7b323..41b9dd99f 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -2786,7 +2786,7 @@ describe("#documents.create", () => { expect(body.message).toEqual("parentDocumentId: Invalid uuid"); }); - it("should create as a new document", async () => { + it("should create as a new document with emoji", async () => { const team = await buildTeam(); const user = await buildUser({ teamId: team.id }); const collection = await buildCollection({ @@ -2809,6 +2809,34 @@ describe("#documents.create", () => { expect(newDocument!.parentDocumentId).toBe(null); expect(newDocument!.collectionId).toBe(collection.id); expect(newDocument!.emoji).toBe("๐ข"); + expect(newDocument!.icon).toBe("๐ข"); + expect(body.policies[0].abilities.update).toEqual(true); + }); + + it("should create as a new document with icon", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + userId: user.id, + teamId: team.id, + }); + const res = await server.post("/api/documents.create", { + body: { + token: user.getJwtToken(), + collectionId: collection.id, + icon: "๐ข", + title: "new document", + text: "hello", + publish: true, + }, + }); + const body = await res.json(); + const newDocument = await Document.findByPk(body.data.id); + expect(res.status).toEqual(200); + expect(newDocument!.parentDocumentId).toBe(null); + expect(newDocument!.collectionId).toBe(collection.id); + expect(newDocument!.emoji).toBe("๐ข"); + expect(newDocument!.icon).toBe("๐ข"); expect(body.policies[0].abilities.update).toEqual(true); }); @@ -3094,7 +3122,7 @@ describe("#documents.update", () => { expect(res.status).toEqual(403); }); - it("should fail to update an invalid emoji value", async () => { + it("should fail to update an invalid icon value", async () => { const user = await buildUser(); const document = await buildDocument({ userId: user.id, @@ -3105,13 +3133,13 @@ describe("#documents.update", () => { body: { token: user.getJwtToken(), id: document.id, - emoji: ":)", + icon: ":)", }, }); const body = await res.json(); expect(res.status).toEqual(400); - expect(body.message).toBe("emoji: Invalid"); + expect(body.message).toBe("icon: Invalid"); }); it("should successfully update the emoji", async () => { @@ -3124,12 +3152,34 @@ describe("#documents.update", () => { body: { token: user.getJwtToken(), id: document.id, - emoji: "๐", + emoji: "๐ข", }, }); const body = await res.json(); expect(res.status).toEqual(200); - expect(body.data.emoji).toBe("๐"); + expect(body.data.emoji).toBe("๐ข"); + expect(body.data.icon).toBe("๐ข"); + expect(body.data.color).toBeNull; + }); + + it("should successfully update the icon", async () => { + const user = await buildUser(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + const res = await server.post("/api/documents.update", { + body: { + token: user.getJwtToken(), + id: document.id, + icon: "beaker", + color: "#FFDDEE", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.icon).toBe("beaker"); + expect(body.data.color).toBe("#FFDDEE"); }); it("should not add template to collection structure when publishing", async () => { diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index ba4a1056f..d583d1ec2 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -944,6 +944,8 @@ router.post( createdById: user.id, template: true, emoji: original.emoji, + icon: original.icon, + color: original.color, title: original.title, text: original.text, content: original.content, @@ -1041,6 +1043,7 @@ router.post( document, user, ...input, + icon: input.icon ?? input.emoji, publish, collectionId, insightsEnabled, @@ -1382,6 +1385,8 @@ router.post( title, text, emoji, + icon, + color, publish, collectionId, parentDocumentId, @@ -1445,7 +1450,8 @@ router.post( const document = await documentCreator({ title, text, - emoji, + icon: icon ?? emoji, + color, createdAt, publish, collectionId: collection?.id, diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index 8d40117d5..ea7b4a6bc 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -4,8 +4,11 @@ import isEmpty from "lodash/isEmpty"; import isUUID from "validator/lib/isUUID"; import { z } from "zod"; import { DocumentPermission, StatusFilter } from "@shared/types"; +import { IconLibrary } from "@shared/utils/IconLibrary"; import { UrlHelper } from "@shared/utils/UrlHelper"; import { BaseSchema } from "@server/routes/api/schema"; +import { zodEnumFromObjectKeys } from "@server/utils/zod"; +import { ValidateColor } from "@server/validation"; const DocumentsSortParamsSchema = z.object({ /** Specifies the attributes by which documents will be sorted in the list */ @@ -223,6 +226,20 @@ export const DocumentsUpdateSchema = BaseSchema.extend({ /** Emoji displayed alongside doc title */ emoji: z.string().regex(emojiRegex()).nullish(), + /** Icon displayed alongside doc title */ + icon: z + .union([ + z.string().regex(emojiRegex()), + zodEnumFromObjectKeys(IconLibrary.mapping), + ]) + .nullish(), + + /** Icon color */ + color: z + .string() + .regex(ValidateColor.regex, { message: ValidateColor.message }) + .nullish(), + /** Boolean to denote if the doc should occupy full width */ fullWidth: z.boolean().optional(), @@ -319,7 +336,21 @@ export const DocumentsCreateSchema = BaseSchema.extend({ text: z.string().default(""), /** Emoji displayed alongside doc title */ - emoji: z.string().regex(emojiRegex()).optional(), + emoji: z.string().regex(emojiRegex()).nullish(), + + /** Icon displayed alongside doc title */ + icon: z + .union([ + z.string().regex(emojiRegex()), + zodEnumFromObjectKeys(IconLibrary.mapping), + ]) + .optional(), + + /** Icon color */ + color: z + .string() + .regex(ValidateColor.regex, { message: ValidateColor.message }) + .nullish(), /** Boolean to denote if the doc should be published */ publish: z.boolean().optional(), diff --git a/server/scripts/20230815063834-migrate-emoji-in-document-title.ts b/server/scripts/20230815063834-migrate-emoji-in-document-title.ts index 19da81ef7..bbfd5ef9b 100644 --- a/server/scripts/20230815063834-migrate-emoji-in-document-title.ts +++ b/server/scripts/20230815063834-migrate-emoji-in-document-title.ts @@ -52,7 +52,7 @@ export default async function main(exit = false, limit = 1000) { try { const { emoji, strippedTitle } = parseTitle(document.title); if (emoji) { - document.emoji = emoji; + document.icon = emoji; document.title = strippedTitle; if (document.changed()) { diff --git a/server/scripts/20230827234031-migrate-emoji-in-revision-title.ts b/server/scripts/20230827234031-migrate-emoji-in-revision-title.ts index 0e290b00f..9680ab06d 100644 --- a/server/scripts/20230827234031-migrate-emoji-in-revision-title.ts +++ b/server/scripts/20230827234031-migrate-emoji-in-revision-title.ts @@ -26,7 +26,7 @@ export default async function main(exit = false, limit = 1000) { try { const { emoji, strippedTitle } = parseTitle(revision.title); if (emoji) { - revision.emoji = emoji; + revision.icon = emoji; revision.title = strippedTitle; if (revision.changed()) { diff --git a/server/types.ts b/server/types.ts index 69571e4ba..f661e6206 100644 --- a/server/types.ts +++ b/server/types.ts @@ -468,7 +468,13 @@ export type DocumentJSONExport = { id: string; urlId: string; title: string; - emoji: string | null; + /** + * For backward compatibility, maintain the `emoji` field. + * Future exports will use the `icon` field. + * */ + emoji?: string | null; + icon: string | null; + color: string | null; data: Record; createdById: string; createdByName: string; @@ -498,7 +504,7 @@ export type CollectionJSONExport = { data?: ProsemirrorData | null; description?: ProsemirrorData | null; permission?: CollectionPermission | null; - color: string; + color?: string | null; icon?: string | null; sort: CollectionSort; documentStructure: NavigationNode[] | null; diff --git a/server/utils/zod.ts b/server/utils/zod.ts new file mode 100644 index 000000000..bbd84c48d --- /dev/null +++ b/server/utils/zod.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export function zodEnumFromObjectKeys< + TI extends Record, + R extends string = TI extends Record ? R : never +>(input: TI): z.ZodEnum<[R, ...R[]]> { + const [firstKey, ...otherKeys] = Object.keys(input) as [R, ...R[]]; + return z.enum([firstKey, ...otherKeys]); +} diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 2c9256f6a..75ce40c6b 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -207,8 +207,6 @@ "Title": "Title", "Published": "Published", "Include nested documents": "Include nested documents", - "Emoji Picker": "Emoji Picker", - "Remove": "Remove", "Module failed to load": "Module failed to load", "Loading Failed": "Loading Failed", "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.", @@ -244,11 +242,27 @@ "Group members": "Group members", "{{authorName}} created <3>3>": "{{authorName}} created <3>3>", "{{authorName}} opened <3>3>": "{{authorName}} opened <3>3>", + "Search emoji": "Search emoji", + "Search icons": "Search icons", + "Choose default skin tone": "Choose default skin tone", "Show menu": "Show menu", - "Choose an icon": "Choose an icon", - "Filter": "Filter", - "Loading": "Loading", + "Icon Picker": "Icon Picker", + "Icons": "Icons", + "Emojis": "Emojis", + "Remove": "Remove", + "All": "All", + "Frequently Used": "Frequently Used", + "Search Results": "Search Results", + "Smileys & People": "Smileys & People", + "Animals & Nature": "Animals & Nature", + "Food & Drink": "Food & Drink", + "Activity": "Activity", + "Travel & Places": "Travel & Places", + "Objects": "Objects", + "Symbols": "Symbols", + "Flags": "Flags", "Select a color": "Select a color", + "Loading": "Loading", "Search": "Search", "Permission": "Permission", "View only": "View only", @@ -765,7 +779,6 @@ "We were unable to find the page youโre looking for.": "We were unable to find the page youโre looking for.", "Search titles only": "Search titles only", "No documents found for your search filters.": "No documents found for your search filters.", - "Search Results": "Search Results", "API key copied to clipboard": "API key copied to clipboard", "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the developer documentation.": "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 developer documentation.", "Personal keys": "Personal keys", @@ -858,7 +871,6 @@ "New group": "New group", "Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.", "No groups have been created yet": "No groups have been created yet", - "All": "All", "Create a group": "Create a group", "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.", "Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)": "Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)", @@ -869,6 +881,7 @@ "Enterprise": "Enterprise", "Recent imports": "Recent imports", "Everyone that has signed into {{appName}} is listed here. Itโs possible that there are other users who have access through {{signinMethods}} but havenโt signed in yet.": "Everyone that has signed into {{appName}} is listed here. Itโs possible that there are other users who have access through {{signinMethods}} but havenโt signed in yet.", + "Filter": "Filter", "Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published", "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", diff --git a/shared/styles/theme.ts b/shared/styles/theme.ts index dc4001015..35b410dde 100644 --- a/shared/styles/theme.ts +++ b/shared/styles/theme.ts @@ -61,6 +61,8 @@ const buildBaseTheme = (input: Partial) => { "-apple-system, BlinkMacSystemFont, Inter, 'Segoe UI', Roboto, Oxygen, sans-serif", fontFamilyMono: "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace", + fontFamilyEmoji: + "Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Segoe UI, Twemoji Mozilla, Noto Color Emoji, Android Emoji", fontWeightRegular: 400, fontWeightMedium: 500, fontWeightBold: 600, diff --git a/shared/types.ts b/shared/types.ts index ff531f488..73f90e8d0 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -230,6 +230,8 @@ export type NavigationNode = { title: string; url: string; emoji?: string; + icon?: string; + color?: string; children: NavigationNode[]; isDraft?: boolean; collectionId?: string; @@ -405,3 +407,43 @@ export type ProsemirrorDoc = { type: "doc"; content: ProsemirrorData[]; }; + +export enum IconType { + Outline = "outline", + Emoji = "emoji", +} + +export enum EmojiCategory { + People = "People", + Nature = "Nature", + Foods = "Foods", + Activity = "Activity", + Places = "Places", + Objects = "Objects", + Symbols = "Symbols", + Flags = "Flags", +} + +export enum EmojiSkinTone { + Default = "Default", + Light = "Light", + MediumLight = "MediumLight", + Medium = "Medium", + MediumDark = "MediumDark", + Dark = "Dark", +} + +export type Emoji = { + id: string; + name: string; + value: string; +}; + +export type EmojiVariants = { + [EmojiSkinTone.Default]: Emoji; + [EmojiSkinTone.Light]?: Emoji; + [EmojiSkinTone.MediumLight]?: Emoji; + [EmojiSkinTone.Medium]?: Emoji; + [EmojiSkinTone.MediumDark]?: Emoji; + [EmojiSkinTone.Dark]?: Emoji; +}; diff --git a/shared/utils/IconLibrary.tsx b/shared/utils/IconLibrary.tsx index 1e69bd984..025f4185d 100644 --- a/shared/utils/IconLibrary.tsx +++ b/shared/utils/IconLibrary.tsx @@ -210,7 +210,7 @@ export class IconLibrary { } return undefined; }) - .filter(Boolean); + .filter((icon: string | undefined): icon is string => !!icon); } /** diff --git a/shared/utils/collections.ts b/shared/utils/collections.ts index deed68971..b153f539f 100644 --- a/shared/utils/collections.ts +++ b/shared/utils/collections.ts @@ -31,7 +31,7 @@ export const sortNavigationNodes = ( export const colorPalette = [ "#4E5C6E", - "#0366d6", + "#0366D6", "#9E5CF7", "#FF825C", "#FF5C80", diff --git a/shared/utils/emoji.ts b/shared/utils/emoji.ts new file mode 100644 index 000000000..0194012ed --- /dev/null +++ b/shared/utils/emoji.ts @@ -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 +); + +const CATEGORY_TO_EMOJI_IDS: Record = + Categories.reduce((obj, { id, emojis }) => { + const category = EmojiCategory[capitalize(id)]; + if (!category) { + return obj; + } + obj[category] = emojis; + return obj; + }, {} as Record); + +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 => + 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); + +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; + }); +}; diff --git a/shared/utils/icon.ts b/shared/utils/icon.ts new file mode 100644 index 000000000..7f0f0ea63 --- /dev/null +++ b/shared/utils/icon.ts @@ -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; +}; diff --git a/yarn.lock b/yarn.lock index d97cab671..ea9c1b7ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2410,11 +2410,6 @@ resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.2.1.tgz#0ad70c662e3bc603e23e7d98413bd1e64c4fcb6c" integrity sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw== -"@emoji-mart/react@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@emoji-mart/react/-/react-1.1.1.tgz#ddad52f93a25baf31c5383c3e7e4c6e05554312a" - integrity "sha1-3a1S+ToluvMcU4PD5+TG4FVUMSo= sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==" - "@emotion/is-prop-valid@^0.8.2": version "0.8.8" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" @@ -13375,7 +13370,7 @@ react-waypoint@^10.3.0: react-window@^1.8.10: version "1.8.10" resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03" - integrity "sha1-nmsIVIMWgUtEP3ACsc+P06G93gM= sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==" + integrity sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg== dependencies: "@babel/runtime" "^7.0.0" memoize-one ">=3.1.1 <6"