diff --git a/Makefile b/Makefile index 5a59afc68a..8866628982 100644 --- a/Makefile +++ b/Makefile @@ -11,14 +11,14 @@ test: docker-compose up -d redis postgres s3 yarn sequelize db:drop --env=test yarn sequelize db:create --env=test - yarn sequelize db:migrate --env=test + NODE_ENV=test yarn sequelize db:migrate --env=test yarn test watch: docker-compose up -d redis postgres s3 yarn sequelize db:drop --env=test yarn sequelize db:create --env=test - yarn sequelize db:migrate --env=test + NODE_ENV=test yarn sequelize db:migrate --env=test yarn test:watch destroy: diff --git a/app/components/ContentEditable.tsx b/app/components/ContentEditable.tsx index b423c94dec..6db6ab4361 100644 --- a/app/components/ContentEditable.tsx +++ b/app/components/ContentEditable.tsx @@ -9,6 +9,7 @@ type Props = Omit, "ref" | "onChange"> & { readOnly?: boolean; onClick?: React.MouseEventHandler; onChange?: (text: string) => void; + onFocus?: React.FocusEventHandler | undefined; onBlur?: React.FocusEventHandler | undefined; onInput?: React.FormEventHandler | undefined; onKeyDown?: React.KeyboardEventHandler | undefined; @@ -35,6 +36,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable( disabled, onChange, onInput, + onFocus, onBlur, onKeyDown, value, @@ -143,11 +145,13 @@ const ContentEditable = React.forwardRef(function _ContentEditable( ); return ( -
+
+ {children} {innerValue} - {children}
); }); diff --git a/app/components/DocumentBreadcrumb.tsx b/app/components/DocumentBreadcrumb.tsx index e260cc886d..21886b6314 100644 --- a/app/components/DocumentBreadcrumb.tsx +++ b/app/components/DocumentBreadcrumb.tsx @@ -15,6 +15,7 @@ import { templatesPath, trashPath, } from "~/utils/routeHelpers"; +import EmojiIcon from "./Icons/EmojiIcon"; type Props = { children?: React.ReactNode; @@ -105,7 +106,13 @@ const DocumentBreadcrumb: React.FC = ({ path.forEach((node: NavigationNode) => { output.push({ type: "route", - title: node.title, + title: node.emoji ? ( + <> + {node.title} + + ) : ( + node.title + ), to: node.url, }); }); diff --git a/app/components/DocumentCard.tsx b/app/components/DocumentCard.tsx index 1372bcfa17..23345d10f0 100644 --- a/app/components/DocumentCard.tsx +++ b/app/components/DocumentCard.tsx @@ -111,7 +111,7 @@ function DocumentCard(props: Props) { {document.emoji ? ( - + ) : ( diff --git a/app/components/DocumentExplorer.tsx b/app/components/DocumentExplorer.tsx index 06069dfd86..5d6802f268 100644 --- a/app/components/DocumentExplorer.tsx +++ b/app/components/DocumentExplorer.tsx @@ -15,7 +15,6 @@ import scrollIntoView from "smooth-scroll-into-view-if-needed"; import styled, { useTheme } from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { NavigationNode } from "@shared/types"; -import parseTitle from "@shared/utils/parseTitle"; import DocumentExplorerNode from "~/components/DocumentExplorerNode"; import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult"; import Flex from "~/components/Flex"; @@ -205,84 +204,86 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) { } }; - const ListItem = ({ - index, - data, - style, - }: { - index: number; - data: NavigationNode[]; - style: React.CSSProperties; - }) => { - const node = data[index]; - const isCollection = node.type === "collection"; - let icon, title, path; + const ListItem = observer( + ({ + index, + data, + style, + }: { + index: number; + data: NavigationNode[]; + style: React.CSSProperties; + }) => { + const node = data[index]; + const isCollection = node.type === "collection"; + let icon, title: string, emoji: string | undefined, path; - if (isCollection) { - const col = collections.get(node.collectionId as string); - icon = col && ( - - ); - title = node.title; - } else { - const doc = documents.get(node.id); - const { strippedTitle, emoji } = parseTitle(node.title); - title = strippedTitle; - - if (emoji) { - icon = ; - } else if (doc?.isStarred) { - icon = ; + if (isCollection) { + const col = collections.get(node.collectionId as string); + icon = col && ( + + ); + title = node.title; } else { - icon = ; + const doc = documents.get(node.id); + emoji = doc?.emoji ?? node.emoji; + title = doc?.title ?? node.title; + + if (emoji) { + icon = ; + } else if (doc?.isStarred) { + icon = ; + } else { + icon = ; + } + + path = ancestors(node) + .map((a) => a.title) + .join(" / "); } - path = ancestors(node) - .map((a) => parseTitle(a.title).strippedTitle) - .join(" / "); + return searchTerm ? ( + setActiveNode(index)} + onClick={() => toggleSelect(index)} + icon={icon} + title={title} + path={path} + /> + ) : ( + setActiveNode(index)} + onClick={() => toggleSelect(index)} + onDisclosureClick={(ev) => { + ev.stopPropagation(); + toggleCollapse(index); + }} + selected={isSelected(index)} + active={activeNode === index} + expanded={isExpanded(index)} + icon={icon} + title={title} + depth={node.depth as number} + hasChildren={hasChildren(index)} + ref={itemRefs[index]} + /> + ); } - - return searchTerm ? ( - setActiveNode(index)} - onClick={() => toggleSelect(index)} - icon={icon} - title={title} - path={path} - /> - ) : ( - setActiveNode(index)} - onClick={() => toggleSelect(index)} - onDisclosureClick={(ev) => { - ev.stopPropagation(); - toggleCollapse(index); - }} - selected={isSelected(index)} - active={activeNode === index} - expanded={isExpanded(index)} - icon={icon} - title={title} - depth={node.depth as number} - hasChildren={hasChildren(index)} - ref={itemRefs[index]} - /> - ); - }; + ); const focusSearchInput = () => { inputSearchRef.current?.focus(); diff --git a/app/components/DocumentListItem.tsx b/app/components/DocumentListItem.tsx index 009085a951..43d6b28a1f 100644 --- a/app/components/DocumentListItem.tsx +++ b/app/components/DocumentListItem.tsx @@ -24,6 +24,7 @@ import usePolicy from "~/hooks/usePolicy"; import DocumentMenu from "~/menus/DocumentMenu"; import { hover } from "~/styles"; import { newDocumentPath } from "~/utils/routeHelpers"; +import EmojiIcon from "./Icons/EmojiIcon"; type Props = { document: Document; @@ -92,6 +93,12 @@ function DocumentListItem( > + {document.emoji && ( + <> + +   + + )} ` + 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 new file mode 100644 index 0000000000..7f817b5a66 --- /dev/null +++ b/app/components/EmojiPicker/index.tsx @@ -0,0 +1,269 @@ +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", + "kr", + "nl", + "pl", + "pt", + "ru", + "sa", + "tr", + "uk", + "vi", + "zh", +]; + +/** + * React hook to derive emoji picker's theme from UI theme + * + * @returns {string} Theme to use for emoji picker + */ +function usePickerTheme(): string { + const { ui } = useStores(); + const { theme } = ui; + + if (theme === "system") { + return "auto"; + } + + return theme; +} + +type Props = { + /** The selected emoji, if any */ + value?: string | null; + /** Callback when an emoji is selected */ + onChange: (emoji: string | null) => void | Promise<void>; + /** Callback when the picker is opened */ + onOpen?: () => void; + /** Callback when the picker is closed */ + onClose?: () => void; + /** Callback when the picker is clicked outside of */ + onClickOutside: () => void; + /** Whether to auto focus the search input on open */ + autoFocus?: boolean; + /** Class name to apply to the trigger button */ + className?: string; +}; + +function EmojiPicker({ + value, + onOpen, + onClose, + onChange, + onClickOutside, + autoFocus, + className, +}: Props) { + const { t } = useTranslation(); + const pickerTheme = usePickerTheme(); + const theme = useTheme(); + const locale = useUserLocale(true) ?? "en"; + + const popover = usePopoverState({ + placement: "bottom-start", + modal: true, + unstable_offset: [0, 0], + }); + + const [emojisPerLine, setEmojisPerLine] = React.useState(9); + + const pickerRef = React.useRef<HTMLDivElement>(null); + + React.useEffect(() => { + if (popover.visible) { + onOpen?.(); + } else { + onClose?.(); + } + }, [popover.visible, onOpen, onClose]); + + React.useEffect(() => { + if (popover.visible && pickerRef.current) { + // 28 is picker's observed width when perLine is set to 0 + // and 36 is the default emojiButtonSize + // Ref: https://github.com/missive/emoji-mart#options--props + setEmojisPerLine(Math.floor((pickerRef.current.clientWidth - 28) / 36)); + } + }, [popover.visible]); + + const handleEmojiChange = React.useCallback( + async (emoji) => { + popover.hide(); + await onChange(emoji ? emoji.native : null); + }, + [popover, onChange] + ); + + const handleClick = React.useCallback( + (ev: React.MouseEvent) => { + ev.stopPropagation(); + if (popover.visible) { + popover.hide(); + } else { + popover.show(); + } + }, + [popover] + ); + + const handleClickOutside = React.useCallback(() => { + // It was observed that onClickOutside got triggered + // even when the picker wasn't open or opened at all. + // Hence, this guard here... + if (popover.visible) { + onClickOutside(); + } + }, [popover.visible, onClickOutside]); + + // Auto focus search input when picker is opened + React.useLayoutEffect(() => { + if (autoFocus && popover.visible) { + requestAnimationFrame(() => { + const searchInput = pickerRef.current + ?.querySelector("em-emoji-picker") + ?.shadowRoot?.querySelector( + "input[type=search]" + ) as HTMLInputElement | null; + searchInput?.focus(); + }); + } + }, [autoFocus, popover.visible]); + + return ( + <> + <PopoverDisclosure {...popover}> + {(props) => ( + <EmojiButton + {...props} + className={className} + onClick={handleClick} + icon={ + value ? ( + <Emoji size={32} align="center" justify="center"> + {value} + </Emoji> + ) : ( + <StyledSmileyIcon size={32} color={theme.textTertiary} /> + ) + } + neutral + borderOnHover + /> + )} + </PopoverDisclosure> + <PickerPopover + {...popover} + tabIndex={0} + // This prevents picker from closing when any of its + // children are focused, e.g, clicking on search bar or + // a click on skin tone button + onClick={(e) => e.stopPropagation()} + width={352} + aria-label={t("Emoji Picker")} + > + {popover.visible && ( + <> + {value && ( + <RemoveButton neutral onClick={() => handleEmojiChange(null)}> + {t("Remove")} + </RemoveButton> + )} + <PickerStyles ref={pickerRef}> + <Picker + // https://github.com/missive/emoji-mart/issues/800 + locale={ + locale === "ko" + ? "kr" + : supportedLocales.includes(locale) + ? locale + : "en" + } + data={data} + onEmojiSelect={handleEmojiChange} + theme={pickerTheme} + previewPosition="none" + perLine={emojisPerLine} + onClickOutside={handleClickOutside} + /> + </PickerStyles> + </> + )} + </PickerPopover> + </> + ); +} + +const StyledSmileyIcon = styled(SmileyIcon)` + flex-shrink: 0; + + @media print { + display: none; + } +`; + +const RemoveButton = styled(Button)` + margin-left: -12px; + margin-bottom: 8px; + border-radius: 6px; + height: 24px; + font-size: 13px; + + > :first-child { + min-height: unset; + line-height: unset; + } +`; + +const PickerPopover = styled(Popover)` + z-index: ${depths.popover}; + > :first-child { + padding-top: 8px; + padding-bottom: 0; + max-height: 488px; + overflow: unset; + } +`; + +const PickerStyles = styled.div` + margin-left: -24px; + margin-right: -24px; + em-emoji-picker { + --shadow: none; + --font-family: ${s("fontFamily")}; + --rgb-background: ${(props) => toRGB(props.theme.menuBackground)}; + --rgb-accent: ${(props) => toRGB(props.theme.accent)}; + --border-radius: 6px; + margin-left: auto; + margin-right: auto; + min-height: 443px; + } +`; + +export default EmojiPicker; diff --git a/app/components/Icons/EmojiIcon.tsx b/app/components/Icons/EmojiIcon.tsx index f1d05e53f4..f7b535baf2 100644 --- a/app/components/Icons/EmojiIcon.tsx +++ b/app/components/Icons/EmojiIcon.tsx @@ -29,5 +29,5 @@ const Span = styled.span<{ $size: number }>` width: ${(props) => props.$size}px; height: ${(props) => props.$size}px; text-indent: -0.15em; - font-size: 14px; + font-size: ${(props) => props.$size - 10}px; `; diff --git a/app/components/Sidebar/components/DocumentLink.tsx b/app/components/Sidebar/components/DocumentLink.tsx index 34502f7f8e..a6838759db 100644 --- a/app/components/Sidebar/components/DocumentLink.tsx +++ b/app/components/Sidebar/components/DocumentLink.tsx @@ -324,6 +324,7 @@ function InnerDocumentLink( starred: inStarredSection, }, }} + emoji={document?.emoji || node.emoji} label={ <EditableTitle title={title} diff --git a/app/components/Sidebar/components/SharedDocumentLink.tsx b/app/components/Sidebar/components/SharedDocumentLink.tsx index 69a50a3875..59b7507744 100644 --- a/app/components/Sidebar/components/SharedDocumentLink.tsx +++ b/app/components/Sidebar/components/SharedDocumentLink.tsx @@ -8,7 +8,6 @@ import Document from "~/models/Document"; import useStores from "~/hooks/useStores"; import { sharedDocumentPath } from "~/utils/routeHelpers"; import { descendants } from "~/utils/tree"; -import Disclosure from "./Disclosure"; import SidebarLink from "./SidebarLink"; type Props = { @@ -110,14 +109,10 @@ function DocumentLink( title: node.title, }, }} - label={ - <> - {hasChildDocuments && depth !== 0 && ( - <Disclosure expanded={expanded} onClick={handleDisclosureClick} /> - )} - {title} - </> - } + expanded={hasChildDocuments && depth !== 0 ? expanded : undefined} + onDisclosureClick={handleDisclosureClick} + emoji={node.emoji} + label={title} depth={depth} exact={false} scrollIntoViewIfNeeded={!document?.isStarred} diff --git a/app/components/Sidebar/components/SidebarLink.tsx b/app/components/Sidebar/components/SidebarLink.tsx index 2e4ecc5d4b..48ac4a1065 100644 --- a/app/components/Sidebar/components/SidebarLink.tsx +++ b/app/components/Sidebar/components/SidebarLink.tsx @@ -5,6 +5,7 @@ import breakpoint from "styled-components-breakpoint"; import { s } from "@shared/styles"; import { NavigationNode } from "@shared/types"; import EventBoundary from "~/components/EventBoundary"; +import EmojiIcon from "~/components/Icons/EmojiIcon"; import NudeButton from "~/components/NudeButton"; import useUnmount from "~/hooks/useUnmount"; import { undraggableOnDesktop } from "~/styles"; @@ -25,6 +26,7 @@ type Props = Omit<NavLinkProps, "to"> & { onClickIntent?: () => void; onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>; icon?: React.ReactNode; + emoji?: string | null; label?: React.ReactNode; menu?: React.ReactNode; showActions?: boolean; @@ -48,6 +50,7 @@ function SidebarLink( onClick, onClickIntent, to, + emoji, label, active, isActiveDrop, @@ -136,6 +139,7 @@ function SidebarLink( /> )} {icon && <IconWrapper>{icon}</IconWrapper>} + {emoji && <EmojiIcon emoji={emoji} />} <Label>{label}</Label> </Content> </Link> @@ -152,6 +156,7 @@ const Content = styled.span` ${Disclosure} { margin-top: 2px; + margin-left: 2px; } `; diff --git a/app/components/Sidebar/components/StarredLink.tsx b/app/components/Sidebar/components/StarredLink.tsx index c0b891216c..caa703eefb 100644 --- a/app/components/Sidebar/components/StarredLink.tsx +++ b/app/components/Sidebar/components/StarredLink.tsx @@ -8,7 +8,6 @@ import { useDrag, useDrop } from "react-dnd"; import { getEmptyImage } from "react-dnd-html5-backend"; import { useLocation } from "react-router-dom"; import styled, { useTheme } from "styled-components"; -import parseTitle from "@shared/utils/parseTitle"; import Star from "~/models/Star"; import Fade from "~/components/Fade"; import CollectionIcon from "~/components/Icons/CollectionIcon"; @@ -42,14 +41,10 @@ function useLabelAndIcon({ documentId, collectionId }: Star) { if (documentId) { const document = documents.get(documentId); if (document) { - const { emoji } = parseTitle(document?.title); - return { - label: emoji - ? document.title.replace(emoji, "") - : document.titleWithDefault, - icon: emoji ? ( - <EmojiIcon emoji={emoji} /> + label: document.titleWithDefault, + icon: document.emoji ? ( + <EmojiIcon emoji={document.emoji} /> ) : ( <StarredIcon color={theme.yellow} /> ), @@ -148,6 +143,10 @@ function StarredLink({ star }: Props) { return null; } + const { emoji } = document; + const label = emoji + ? document.title.replace(emoji, "") + : document.titleWithDefault; const collection = document.collectionId ? collections.get(document.collectionId) : undefined; diff --git a/app/components/Star.tsx b/app/components/Star.tsx index e379a9b2ad..bbc37b0ed7 100644 --- a/app/components/Star.tsx +++ b/app/components/Star.tsx @@ -1,6 +1,7 @@ import { observer } from "mobx-react"; import { StarredIcon, UnstarredIcon } from "outline-icons"; import * as React from "react"; +import { useTranslation } from "react-i18next"; import styled, { useTheme } from "styled-components"; import Collection from "~/models/Collection"; import Document from "~/models/Document"; @@ -14,12 +15,18 @@ import { hover } from "~/styles"; import NudeButton from "./NudeButton"; type Props = { + /** Target collection */ collection?: Collection; + /** Target document */ document?: Document; + /** Size of the star */ size?: number; + /** Color override for the star */ + color?: string; }; -function Star({ size, document, collection, ...rest }: Props) { +function Star({ size, document, collection, color, ...rest }: Props) { + const { t } = useTranslation(); const theme = useTheme(); const context = useActionContext({ activeDocumentId: document?.id, @@ -36,6 +43,10 @@ function Star({ size, document, collection, ...rest }: Props) { <NudeButton context={context} hideOnActionDisabled + tooltip={{ + tooltip: target.isStarred ? t("Unstar document") : t("Star document"), + delay: 500, + }} action={ collection ? collection.isStarred @@ -55,7 +66,7 @@ function Star({ size, document, collection, ...rest }: Props) { ) : ( <AnimatedStar size={size} - color={theme.textTertiary} + color={color ?? theme.textTertiary} as={UnstarredIcon} /> )} diff --git a/app/editor/components/EmojiMenu.tsx b/app/editor/components/EmojiMenu.tsx index bab89e9905..7e6a899fef 100644 --- a/app/editor/components/EmojiMenu.tsx +++ b/app/editor/components/EmojiMenu.tsx @@ -1,5 +1,7 @@ +import data, { type Emoji as TEmoji, EmojiMartData } from "@emoji-mart/data"; import FuzzySearch from "fuzzy-search"; -import gemojies from "gemoji"; +import capitalize from "lodash/capitalize"; +import snakeCase from "lodash/snakeCase"; import React from "react"; import EmojiMenuItem from "./EmojiMenuItem"; import SuggestionsMenu, { @@ -14,14 +16,14 @@ type Emoji = { attrs: { markup: string; "data-name": string }; }; -const searcher = new FuzzySearch<{ - names: string[]; - description: string; - emoji: string; -}>(gemojies, ["names"], { - caseSensitive: true, - sort: true, -}); +const searcher = new FuzzySearch<TEmoji>( + Object.values((data as EmojiMartData).emojis), + ["keywords"], + { + caseSensitive: true, + sort: true, + } +); type Props = Omit< SuggestionsMenuProps<Emoji>, @@ -34,14 +36,17 @@ const EmojiMenu = (props: Props) => { const items = React.useMemo(() => { const n = search.toLowerCase(); const result = searcher.search(n).map((item) => { - const description = item.description; - const name = item.names[0]; + // We snake_case the shortcode for backwards compatability with gemoji to + // avoid multiple formats being written into documents. + const shortcode = snakeCase(item.id); + const emoji = item.skins[0].native; + return { - ...item, name: "emoji", - title: name, - description, - attrs: { markup: name, "data-name": name }, + title: emoji, + description: capitalize(item.name.toLowerCase()), + emoji, + attrs: { markup: shortcode, "data-name": shortcode }, }; }); diff --git a/app/hooks/useEmojiWidth.ts b/app/hooks/useEmojiWidth.ts deleted file mode 100644 index 123056a2a9..0000000000 --- a/app/hooks/useEmojiWidth.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as React from "react"; - -type Options = { - fontSize?: string; - lineHeight?: string; -}; - -/** - * Measures the width of an emoji character - * - * @param emoji The emoji to measure - * @param options Options to pass to the measurement element - * @returns The width of the emoji in pixels - */ -export default function useEmojiWidth( - emoji: string | undefined, - { fontSize = "2.25em", lineHeight = "1.25" }: Options -) { - return React.useMemo(() => { - const element = window.document.createElement("span"); - if (!emoji) { - return 0; - } - - element.innerText = `${emoji}\u00A0`; - element.style.visibility = "hidden"; - element.style.position = "absolute"; - element.style.left = "-9999px"; - element.style.lineHeight = lineHeight; - element.style.fontSize = fontSize; - element.style.width = "max-content"; - window.document.body?.appendChild(element); - const width = window.getComputedStyle(element).width; - window.document.body?.removeChild(element); - return parseInt(width, 10); - }, [emoji, fontSize, lineHeight]); -} diff --git a/app/hooks/useUserLocale.ts b/app/hooks/useUserLocale.ts index 50836cdf2b..f8628d9fd9 100644 --- a/app/hooks/useUserLocale.ts +++ b/app/hooks/useUserLocale.ts @@ -1,11 +1,18 @@ import useStores from "./useStores"; -export default function useUserLocale() { +/** + * Returns the user's locale, or undefined if the user is not logged in. + * + * @param languageCode Whether to only return the language code + * @returns The user's locale, or undefined if the user is not logged in + */ +export default function useUserLocale(languageCode?: boolean) { const { auth } = useStores(); - if (!auth.user || !auth.user.language) { + if (!auth.user?.language) { return undefined; } - return auth.user.language; + const { language } = auth.user; + return languageCode ? language.split("_")[0] : language; } diff --git a/app/models/Document.ts b/app/models/Document.ts index 56a1b09ccb..1e75be5a05 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -4,7 +4,6 @@ import { action, autorun, computed, observable, set } from "mobx"; import { ExportContentType } from "@shared/types"; import type { NavigationNode } from "@shared/types"; import Storage from "@shared/utils/Storage"; -import parseTitle from "@shared/utils/parseTitle"; import { isRTL } from "@shared/utils/rtl"; import DocumentsStore from "~/stores/DocumentsStore"; import User from "~/models/User"; @@ -68,6 +67,13 @@ export default class Document extends ParanoidModel { @observable title: string; + /** + * An emoji to use as the document icon. + */ + @Field + @observable + emoji: string | undefined | null; + /** * Whether this is a template. */ @@ -127,12 +133,6 @@ export default class Document extends ParanoidModel { revision: number; - @computed - get emoji() { - const { emoji } = parseTitle(this.title); - return emoji; - } - /** * Returns the direction of the document text, either "rtl" or "ltr" */ diff --git a/app/models/Revision.ts b/app/models/Revision.ts index c1c7a7854f..c356bf17e7 100644 --- a/app/models/Revision.ts +++ b/app/models/Revision.ts @@ -14,6 +14,9 @@ class Revision extends BaseModel { /** Markdown string of the content when revision was created */ text: string; + /** The emoji of the document when the revision was created */ + emoji: string | null; + /** HTML string representing the revision as a diff from the previous version */ html: string; diff --git a/app/scenes/Collection.tsx b/app/scenes/Collection.tsx index 5914f0c355..d33e8ac300 100644 --- a/app/scenes/Collection.tsx +++ b/app/scenes/Collection.tsx @@ -26,7 +26,6 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList"; import PinnedDocuments from "~/components/PinnedDocuments"; import PlaceholderText from "~/components/PlaceholderText"; import Scene from "~/components/Scene"; -import Star, { AnimatedStar } from "~/components/Star"; import Tab from "~/components/Tab"; import Tabs from "~/components/Tabs"; import Tooltip from "~/components/Tooltip"; @@ -157,7 +156,7 @@ function CollectionScene() { <Empty collection={collection} /> ) : ( <> - <HeadingWithIcon $isStarred={collection.isStarred}> + <HeadingWithIcon> <HeadingIcon collection={collection} size={40} expanded /> {collection.name} {collection.isPrivate && ( @@ -170,7 +169,6 @@ function CollectionScene() { <Badge>{t("Private")}</Badge> </Tooltip> )} - <StarButton collection={collection} size={32} /> </HeadingWithIcon> <CollectionDescription collection={collection} /> @@ -285,42 +283,15 @@ function CollectionScene() { ); } -const StarButton = styled(Star)` - position: relative; - top: 0; - left: 10px; - overflow: hidden; - width: 24px; - - svg { - position: relative; - left: -4px; - } -`; - const Documents = styled.div` position: relative; background: ${s("background")}; `; -const HeadingWithIcon = styled(Heading)<{ $isStarred: boolean }>` +const HeadingWithIcon = styled(Heading)` display: flex; align-items: center; - ${AnimatedStar} { - opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)}; - } - - &:hover { - ${AnimatedStar} { - opacity: 0.5; - - &:hover { - opacity: 1; - } - } - } - ${breakpoint("tablet")` margin-left: -40px; `}; diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index ee0e7f10dd..eb996db8ff 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -354,7 +354,7 @@ class DocumentScene extends React.Component<Props> { this.isUploading = false; }; - onChange = (getEditorText: () => string) => { + handleChange = (getEditorText: () => string) => { const { document } = this.props; this.getEditorText = getEditorText; @@ -369,13 +369,19 @@ class DocumentScene extends React.Component<Props> { this.headings = headings; }; - onChangeTitle = action((value: string) => { + handleChangeTitle = action((value: string) => { this.title = value; this.props.document.title = value; this.updateIsDirty(); void this.autosave(); }); + handleChangeEmoji = action((value: string) => { + this.props.document.emoji = value; + this.updateIsDirty(); + void this.autosave(); + }); + goBack = () => { if (!this.props.readOnly) { this.props.history.push(this.props.document.url); @@ -482,7 +488,6 @@ class DocumentScene extends React.Component<Props> { <Flex auto={!readOnly} reverse> {revision ? ( <RevisionViewer - isDraft={document.isDraft} document={document} revision={revision} id={revision.id} @@ -506,8 +511,9 @@ class DocumentScene extends React.Component<Props> { onFileUploadStop={this.onFileUploadStop} onSearchLink={this.props.onSearchLink} onCreateLink={this.props.onCreateLink} - onChangeTitle={this.onChangeTitle} - onChange={this.onChange} + onChangeTitle={this.handleChangeTitle} + onChangeEmoji={this.handleChangeEmoji} + onChange={this.handleChange} onHeadingsChange={this.onHeadingsChange} onSave={this.onSave} onPublish={this.onPublish} diff --git a/app/scenes/Document/components/DocumentTitle.tsx b/app/scenes/Document/components/DocumentTitle.tsx new file mode 100644 index 0000000000..a0f0f22084 --- /dev/null +++ b/app/scenes/Document/components/DocumentTitle.tsx @@ -0,0 +1,353 @@ +import { observer } from "mobx-react"; +import { Slice } from "prosemirror-model"; +import { Selection } from "prosemirror-state"; +import { __parseFromClipboard } from "prosemirror-view"; +import * as React from "react"; +import styled from "styled-components"; +import breakpoint from "styled-components-breakpoint"; +import isMarkdown from "@shared/editor/lib/isMarkdown"; +import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize"; +import { extraArea, s } from "@shared/styles"; +import { light } from "@shared/styles/theme"; +import { + getCurrentDateAsString, + getCurrentDateTimeAsString, + getCurrentTimeAsString, +} from "@shared/utils/date"; +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 useBoolean from "~/hooks/useBoolean"; +import usePolicy from "~/hooks/usePolicy"; +import { isModKey } from "~/utils/keyboard"; + +const EmojiPicker = React.lazy(() => import("~/components/EmojiPicker")); + +type Props = { + /** ID of the associated document */ + documentId: string; + /** Document to display */ + title: string; + /** Emoji to display */ + emoji?: string | null; + /** 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 expects to move to the "next" input */ + onGoToNextInput?: (insertParagraph?: boolean) => void; + /** Callback called when the user expects to save (CMD+S) */ + onSave?: (options: { publish?: boolean; done?: boolean }) => void; + /** Callback called when focus leaves the input */ + onBlur?: React.FocusEventHandler<HTMLSpanElement>; +}; + +const lineHeight = "1.25"; +const fontSize = "2.25em"; + +const DocumentTitle = React.forwardRef(function _DocumentTitle( + { + documentId, + title, + emoji, + readOnly, + onChangeTitle, + onChangeEmoji, + onSave, + onGoToNextInput, + onBlur, + placeholder, + }: Props, + ref: React.RefObject<RefHandle> +) { + const [emojiPickerIsOpen, handleOpen, handleClose] = useBoolean(); + const { editor } = useDocumentContext(); + + const can = usePolicy(documentId); + + const handleClick = React.useCallback(() => { + ref.current?.focus(); + }, [ref]); + + const restoreFocus = React.useCallback(() => { + ref.current?.focusAtEnd(); + }, [ref]); + + const handleBlur = React.useCallback( + (ev: React.FocusEvent<HTMLSpanElement>) => { + // Do nothing and simply return if the related target is the parent + // or a sibling of the current target element(the <span> + // containing document title) + if ( + ev.currentTarget.parentElement === ev.relatedTarget || + (ev.relatedTarget && + ev.currentTarget.parentElement === ev.relatedTarget.parentElement) + ) { + return; + } + if (onBlur) { + onBlur(ev); + } + }, + [onBlur] + ); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.nativeEvent.isComposing) { + return; + } + if (event.key === "Enter") { + event.preventDefault(); + + if (isModKey(event)) { + onSave?.({ + done: true, + }); + return; + } + + onGoToNextInput?.(true); + return; + } + + if (event.key === "Tab" || event.key === "ArrowDown") { + event.preventDefault(); + onGoToNextInput?.(); + return; + } + + if (event.key === "p" && isModKey(event) && event.shiftKey) { + event.preventDefault(); + onSave?.({ + publish: true, + done: true, + }); + return; + } + + if (event.key === "s" && isModKey(event)) { + event.preventDefault(); + onSave?.({}); + return; + } + }, + [onGoToNextInput, onSave] + ); + + const handleChange = React.useCallback( + (value: string) => { + let title = value; + + if (/\/date\s$/.test(value)) { + title = getCurrentDateAsString(); + ref.current?.focusAtEnd(); + } else if (/\/time$/.test(value)) { + title = getCurrentTimeAsString(); + ref.current?.focusAtEnd(); + } else if (/\/datetime$/.test(value)) { + title = getCurrentDateTimeAsString(); + ref.current?.focusAtEnd(); + } + + onChangeTitle?.(title); + }, + [ref, onChangeTitle] + ); + + // Custom paste handling so that if a multiple lines are pasted we + // only take the first line and insert the rest directly into the editor. + const handlePaste = React.useCallback( + (event: React.ClipboardEvent) => { + event.preventDefault(); + + const text = event.clipboardData.getData("text/plain"); + const html = event.clipboardData.getData("text/html"); + const [firstLine, ...rest] = text.split(`\n`); + const content = rest.join(`\n`).trim(); + + window.document.execCommand( + "insertText", + false, + firstLine.replace(/^#+\s?/, "") + ); + + if (editor && content) { + const { view, pasteParser } = editor; + let slice; + + if (isMarkdown(text)) { + const paste = pasteParser.parse(normalizePastedMarkdown(content)); + if (paste) { + slice = paste.slice(0); + } + } else { + const defaultSlice = __parseFromClipboard( + view, + text, + html, + false, + view.state.selection.$from + ); + + // remove first node from slice + slice = defaultSlice.content.firstChild + ? new Slice( + defaultSlice.content.cut( + defaultSlice.content.firstChild.nodeSize + ), + defaultSlice.openStart, + defaultSlice.openEnd + ) + : defaultSlice; + } + + if (slice) { + view.dispatch( + view.state.tr + .setSelection(Selection.atStart(view.state.doc)) + .replaceSelection(slice) + ); + } + } + }, + [editor] + ); + + const handleEmojiChange = React.useCallback( + async (value: string | null) => { + // Restore focus on title + restoreFocus(); + if (emoji !== value) { + onChangeEmoji?.(value); + } + }, + [emoji, onChangeEmoji, restoreFocus] + ); + + const emojiIcon = <Emoji size={32}>{emoji}</Emoji>; + + return ( + <Title + onClick={handleClick} + onChange={handleChange} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + onBlur={handleBlur} + placeholder={placeholder} + value={title} + $emojiPickerIsOpen={emojiPickerIsOpen} + $containsEmoji={!!emoji} + autoFocus={!document.title} + maxLength={DocumentValidation.maxTitleLength} + readOnly={readOnly} + dir="auto" + ref={ref} + > + {can.update && !readOnly ? ( + <EmojiWrapper align="center" justify="center"> + <React.Suspense fallback={emojiIcon}> + <StyledEmojiPicker + value={emoji} + onChange={handleEmojiChange} + onOpen={handleOpen} + onClose={handleClose} + onClickOutside={restoreFocus} + autoFocus + /> + </React.Suspense> + </EmojiWrapper> + ) : emoji ? ( + <EmojiWrapper align="center" justify="center"> + {emojiIcon} + </EmojiWrapper> + ) : null} + + ); +}); + +const StyledEmojiPicker = styled(EmojiPicker)` + ${extraArea(8)} +`; + +const EmojiWrapper = styled(Flex)` + position: absolute; + top: 8px; + left: -40px; + height: 32px; + width: 32px; +`; + +type TitleProps = { + $containsEmoji: boolean; + $emojiPickerIsOpen: boolean; +}; + +const Title = styled(ContentEditable)` + position: relative; + line-height: ${lineHeight}; + margin-top: 1em; + margin-bottom: 0.5em; + margin-left: ${(props) => + props.$containsEmoji || props.$emojiPickerIsOpen ? "40px" : "0px"}; + font-size: ${fontSize}; + font-weight: 500; + border: 0; + padding: 0; + cursor: ${(props) => (props.readOnly ? "default" : "text")}; + + > span { + outline: none; + } + + &::placeholder { + color: ${s("placeholder")}; + -webkit-text-fill-color: ${s("placeholder")}; + } + + &:focus-within, + &:focus { + margin-left: 40px; + + ${EmojiButton} { + opacity: 1 !important; + } + } + + ${EmojiButton} { + opacity: ${(props: TitleProps) => + props.$containsEmoji ? "1 !important" : 0}; + } + + ${breakpoint("tablet")` + margin-left: 0; + + &:focus-within, + &:focus { + margin-left: 0; + } + + &:hover { + ${EmojiButton} { + opacity: 0.5; + + &:hover { + opacity: 1; + } + } + }`}; + + @media print { + color: ${light.text}; + -webkit-text-fill-color: ${light.text}; + background: none; + } +`; + +export default observer(DocumentTitle); diff --git a/app/scenes/Document/components/EditableTitle.tsx b/app/scenes/Document/components/EditableTitle.tsx deleted file mode 100644 index 7ff7de1318..0000000000 --- a/app/scenes/Document/components/EditableTitle.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { observer } from "mobx-react"; -import { Slice } from "prosemirror-model"; -import { Selection } from "prosemirror-state"; -import { __parseFromClipboard } from "prosemirror-view"; -import * as React from "react"; -import styled from "styled-components"; -import breakpoint from "styled-components-breakpoint"; -import isMarkdown from "@shared/editor/lib/isMarkdown"; -import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize"; -import { s } from "@shared/styles"; -import { light } from "@shared/styles/theme"; -import { - getCurrentDateAsString, - getCurrentDateTimeAsString, - getCurrentTimeAsString, -} from "@shared/utils/date"; -import { DocumentValidation } from "@shared/validations"; -import Document from "~/models/Document"; -import ContentEditable, { RefHandle } from "~/components/ContentEditable"; -import { useDocumentContext } from "~/components/DocumentContext"; -import Star, { AnimatedStar } from "~/components/Star"; -import useEmojiWidth from "~/hooks/useEmojiWidth"; -import { isModKey } from "~/utils/keyboard"; - -type Props = { - document: Document; - /** Placeholder to display when the document has no title */ - placeholder: string; - /** Should the title be editable, policies will also be considered separately */ - readOnly?: boolean; - /** Whether the title show the option to star, policies will also be considered separately (defaults to true) */ - starrable?: boolean; - /** Callback called on any edits to text */ - onChange: (text: string) => 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) */ - onSave?: (options: { publish?: boolean; done?: boolean }) => void; - /** Callback called when focus leaves the input */ - onBlur?: React.FocusEventHandler; -}; - -const lineHeight = "1.25"; -const fontSize = "2.25em"; - -const EditableTitle = React.forwardRef( - ( - { - document, - readOnly, - onChange, - onSave, - onGoToNextInput, - onBlur, - starrable, - placeholder, - }: Props, - ref: React.RefObject - ) => { - const { editor } = useDocumentContext(); - const handleClick = React.useCallback(() => { - ref.current?.focus(); - }, [ref]); - - const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - if (event.nativeEvent.isComposing) { - return; - } - if (event.key === "Enter") { - event.preventDefault(); - - if (isModKey(event)) { - onSave?.({ - done: true, - }); - return; - } - - onGoToNextInput(true); - return; - } - - if (event.key === "Tab" || event.key === "ArrowDown") { - event.preventDefault(); - onGoToNextInput(); - return; - } - - if (event.key === "p" && isModKey(event) && event.shiftKey) { - event.preventDefault(); - onSave?.({ - publish: true, - done: true, - }); - return; - } - - if (event.key === "s" && isModKey(event)) { - event.preventDefault(); - onSave?.({}); - return; - } - }, - [onGoToNextInput, onSave] - ); - - const handleChange = React.useCallback( - (text: string) => { - if (/\/date\s$/.test(text)) { - onChange(getCurrentDateAsString()); - ref.current?.focusAtEnd(); - } else if (/\/time$/.test(text)) { - onChange(getCurrentTimeAsString()); - ref.current?.focusAtEnd(); - } else if (/\/datetime$/.test(text)) { - onChange(getCurrentDateTimeAsString()); - ref.current?.focusAtEnd(); - } else { - onChange(text); - } - }, - [ref, onChange] - ); - - // Custom paste handling so that if a multiple lines are pasted we - // only take the first line and insert the rest directly into the editor. - const handlePaste = React.useCallback( - (event: React.ClipboardEvent) => { - event.preventDefault(); - - const text = event.clipboardData.getData("text/plain"); - const html = event.clipboardData.getData("text/html"); - const [firstLine, ...rest] = text.split(`\n`); - const content = rest.join(`\n`).trim(); - - window.document.execCommand( - "insertText", - false, - firstLine.replace(/^#+\s?/, "") - ); - - if (editor && content) { - const { view, pasteParser } = editor; - let slice; - - if (isMarkdown(text)) { - const paste = pasteParser.parse(normalizePastedMarkdown(content)); - if (paste) { - slice = paste.slice(0); - } - } else { - const defaultSlice = __parseFromClipboard( - view, - text, - html, - false, - view.state.selection.$from - ); - - // remove first node from slice - slice = defaultSlice.content.firstChild - ? new Slice( - defaultSlice.content.cut( - defaultSlice.content.firstChild.nodeSize - ), - defaultSlice.openStart, - defaultSlice.openEnd - ) - : defaultSlice; - } - - if (slice) { - view.dispatch( - view.state.tr - .setSelection(Selection.atStart(view.state.doc)) - .replaceSelection(slice) - ); - } - } - }, - [editor] - ); - - const emojiWidth = useEmojiWidth(document.emoji, { - fontSize, - lineHeight, - }); - - const value = - !document.title && readOnly ? document.titleWithDefault : document.title; - - return ( - - {starrable !== false && <StarButton document={document} size={32} />} - - ); - } -); - -const StarButton = styled(Star)` - position: relative; - top: 4px; - left: 10px; - overflow: hidden; - width: 24px; - - svg { - position: relative; - left: -4px; - } -`; - -type TitleProps = { - $isStarred: boolean; - $emojiWidth: number; -}; - -const Title = styled(ContentEditable)` - line-height: ${lineHeight}; - margin-top: 1em; - margin-bottom: 0.5em; - font-size: ${fontSize}; - font-weight: 500; - border: 0; - padding: 0; - cursor: ${(props) => (props.readOnly ? "default" : "text")}; - - > span { - outline: none; - } - - &::placeholder { - color: ${s("placeholder")}; - -webkit-text-fill-color: ${s("placeholder")}; - } - - ${breakpoint("tablet")` - margin-left: ${(props: TitleProps) => -props.$emojiWidth}px; - `}; - - ${AnimatedStar} { - opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)}; - } - - &:hover { - ${AnimatedStar} { - opacity: 0.5; - - &:hover { - opacity: 1; - } - } - } - - @media print { - color: ${light.text}; - -webkit-text-fill-color: ${light.text}; - background: none; - } -`; - -export default observer(EditableTitle); diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 1a29ce2d4a..d0a8e101c4 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -22,12 +22,13 @@ import { import { useDocumentContext } from "../../../components/DocumentContext"; import MultiplayerEditor from "./AsyncMultiplayerEditor"; import DocumentMeta from "./DocumentMeta"; -import EditableTitle from "./EditableTitle"; +import DocumentTitle from "./DocumentTitle"; const extensions = withComments(richExtensions); type Props = Omit & { - onChangeTitle: (text: string) => void; + onChangeTitle: (title: string) => void; + onChangeEmoji: (emoji: string | null) => void; id: string; document: Document; isDraft: boolean; @@ -56,6 +57,7 @@ function DocumentEditor(props: Props, ref: React.RefObject) { const { document, onChangeTitle, + onChangeEmoji, isDraft, shareId, readOnly, @@ -151,14 +153,20 @@ function DocumentEditor(props: Props, ref: React.RefObject) { return ( - {!shareId && ( diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index 369c9331b3..5107d0c156 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -10,7 +10,7 @@ import { import * as React from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; -import styled from "styled-components"; +import styled, { useTheme } from "styled-components"; import { NavigationNode } from "@shared/types"; import { Theme } from "~/stores/UiStore"; import Document from "~/models/Document"; @@ -21,6 +21,8 @@ import Button from "~/components/Button"; import Collaborators from "~/components/Collaborators"; import DocumentBreadcrumb from "~/components/DocumentBreadcrumb"; import Header from "~/components/Header"; +import EmojiIcon from "~/components/Icons/EmojiIcon"; +import Star from "~/components/Star"; import Tooltip from "~/components/Tooltip"; import { publishDocument } from "~/actions/definitions/documents"; import { restoreRevision } from "~/actions/definitions/revisions"; @@ -81,6 +83,7 @@ function DocumentHeader({ }: Props) { const { t } = useTranslation(); const { ui, auth } = useStores(); + const theme = useTheme(); const { resolvedTheme } = ui; const { team } = auth; const isMobile = useMobile(); @@ -199,11 +202,18 @@ function DocumentHeader({ isMobile ? ( ) : ( - {toc} + + {toc} + ) } title={ <> + {document.emoji && ( + <> + {" "} + + )} {document.title}{" "} {document.isArchived && ( {t("Archived")} diff --git a/app/scenes/Document/components/PublicBreadcrumb.tsx b/app/scenes/Document/components/PublicBreadcrumb.tsx index 5e623fe0d1..8068016269 100644 --- a/app/scenes/Document/components/PublicBreadcrumb.tsx +++ b/app/scenes/Document/components/PublicBreadcrumb.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { NavigationNode } from "@shared/types"; import Breadcrumb from "~/components/Breadcrumb"; +import EmojiIcon from "~/components/Icons/EmojiIcon"; import { MenuInternalLink } from "~/types"; import { sharedDocumentPath } from "~/utils/routeHelpers"; @@ -52,6 +53,13 @@ const PublicBreadcrumb: React.FC = ({ .slice(0, -1) .map((item) => ({ ...item, + title: item.emoji ? ( + <> + {item.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 a5f571fbe7..66a5306c20 100644 --- a/app/scenes/Document/components/ReferenceListItem.tsx +++ b/app/scenes/Document/components/ReferenceListItem.tsx @@ -5,7 +5,6 @@ import { Link } from "react-router-dom"; import styled from "styled-components"; import { s, ellipsis } from "@shared/styles"; import { NavigationNode } from "@shared/types"; -import parseTitle from "@shared/utils/parseTitle"; import Document from "~/models/Document"; import Flex from "~/components/Flex"; import EmojiIcon from "~/components/Icons/EmojiIcon"; @@ -59,7 +58,7 @@ function ReferenceListItem({ shareId, ...rest }: Props) { - const { emoji } = parseTitle(document.title); + const { emoji } = document; return ( & { + /** The ID of the revision */ id: string; + /** The current document */ document: Document; + /** The revision to display */ revision: Revision; - isDraft: boolean; children?: React.ReactNode; }; @@ -24,7 +27,12 @@ function RevisionViewer(props: Props) { return ( -

{revision.title}

+ { async update( params: { id: string; - title: string; + title?: string; + emoji?: string | null; text?: string; fullWidth?: boolean; templateId?: string; diff --git a/app/types.ts b/app/types.ts index 8f617a2daa..f1500384ea 100644 --- a/app/types.ts +++ b/app/types.ts @@ -141,6 +141,20 @@ export type FetchOptions = { force?: boolean; }; +export type NavigationNode = { + id: string; + title: string; + emoji?: string | null; + url: string; + children: NavigationNode[]; + isDraft?: boolean; +}; + +export type CollectionSort = { + field: string; + direction: "asc" | "desc"; +}; + // Pagination response in an API call export type Pagination = { limit: number; diff --git a/app/typings/index.d.ts b/app/typings/index.d.ts index ebd29ff0c5..be85a7b0c3 100644 --- a/app/typings/index.d.ts +++ b/app/typings/index.d.ts @@ -1,5 +1,9 @@ declare module "autotrack/autotrack.js"; +declare module "emoji-mart"; + +declare module "@emoji-mart/react"; + declare module "string-replace-to-array"; declare module "sequelize-encrypted"; @@ -16,5 +20,6 @@ declare module "*.png" { declare namespace JSX { interface IntrinsicElements { "zapier-app-directory": any; + "em-emoji": any; } } diff --git a/package.json b/package.json index a849f50e53..01435efe13 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,8 @@ "@dnd-kit/core": "^6.0.5", "@dnd-kit/modifiers": "^6.0.0", "@dnd-kit/sortable": "^7.0.1", + "@emoji-mart/data": "^1.0.6", + "@emoji-mart/react": "^1.1.1", "@getoutline/y-prosemirror": "^1.0.18", "@hocuspocus/extension-throttle": "1.1.2", "@hocuspocus/provider": "1.1.2", @@ -94,6 +96,7 @@ "date-fns": "^2.30.0", "dd-trace": "^3.33.0", "dotenv": "^4.0.0", + "emoji-mart": "^5.5.2", "email-providers": "^1.14.0", "emoji-regex": "^10.2.1", "es6-error": "^4.1.1", @@ -104,7 +107,6 @@ "framer-motion": "^4.1.17", "fs-extra": "^11.1.1", "fuzzy-search": "^3.2.1", - "gemoji": "6.x", "glob": "^8.1.0", "http-errors": "2.0.0", "i18next": "^22.5.1", diff --git a/server/commands/documentUpdater.ts b/server/commands/documentUpdater.ts index d1aff14e1f..6baea5c7a5 100644 --- a/server/commands/documentUpdater.ts +++ b/server/commands/documentUpdater.ts @@ -9,6 +9,8 @@ type Props = { document: Document; /** The new title */ title?: string; + /** The document emoji */ + emoji?: string | null; /** The new text content */ text?: string; /** Whether the editing session is complete */ @@ -44,6 +46,7 @@ export default async function documentUpdater({ user, document, title, + emoji, text, editorVersion, templateId, @@ -62,6 +65,9 @@ export default async function documentUpdater({ if (title !== undefined) { document.title = title.trim(); } + if (emoji !== undefined) { + document.emoji = emoji; + } if (editorVersion) { document.editorVersion = editorVersion; } diff --git a/server/migrations/20230815063830-add-emoji-to-revisions.js b/server/migrations/20230815063830-add-emoji-to-revisions.js new file mode 100644 index 0000000000..524b85bb29 --- /dev/null +++ b/server/migrations/20230815063830-add-emoji-to-revisions.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.addColumn("revisions", "emoji", { + type: Sequelize.STRING, + allowNull: true, + }); + }, + + async down (queryInterface) { + await queryInterface.removeColumn("revisions", "emoji"); + } +}; diff --git a/server/migrations/20230815063834-migrate-emoji-in-document-title.js b/server/migrations/20230815063834-migrate-emoji-in-document-title.js new file mode 100644 index 0000000000..7d84feb464 --- /dev/null +++ b/server/migrations/20230815063834-migrate-emoji-in-document-title.js @@ -0,0 +1,28 @@ +"use strict"; + +const { execSync } = require("child_process"); +const path = require("path"); + +module.exports = { + async up() { + if ( + process.env.NODE_ENV === "test" || + process.env.DEPLOYMENT === "hosted" + ) { + return; + } + + const scriptName = path.basename(__filename); + const scriptPath = path.join( + process.cwd(), + "build", + `server/scripts/${scriptName}` + ); + + execSync(`node ${scriptPath}`, { stdio: "inherit" }); + }, + + async down() { + // noop + }, +}; diff --git a/server/migrations/20230827234031-migrate-emoji-in-revision-title.js b/server/migrations/20230827234031-migrate-emoji-in-revision-title.js new file mode 100644 index 0000000000..7d84feb464 --- /dev/null +++ b/server/migrations/20230827234031-migrate-emoji-in-revision-title.js @@ -0,0 +1,28 @@ +"use strict"; + +const { execSync } = require("child_process"); +const path = require("path"); + +module.exports = { + async up() { + if ( + process.env.NODE_ENV === "test" || + process.env.DEPLOYMENT === "hosted" + ) { + return; + } + + const scriptName = path.basename(__filename); + const scriptPath = path.join( + process.cwd(), + "build", + `server/scripts/${scriptName}` + ); + + execSync(`node ${scriptPath}`, { stdio: "inherit" }); + }, + + async down() { + // noop + }, +}; diff --git a/server/models/Document.ts b/server/models/Document.ts index 99e5ea7079..d8ac4e7362 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -1,4 +1,5 @@ import compact from "lodash/compact"; +import isNil from "lodash/isNil"; import uniq from "lodash/uniq"; import randomstring from "randomstring"; import type { SaveOptions } from "sequelize"; @@ -33,7 +34,6 @@ import { import isUUID from "validator/lib/isUUID"; import type { NavigationNode } from "@shared/types"; import getTasks from "@shared/utils/getTasks"; -import parseTitle from "@shared/utils/parseTitle"; import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers"; import { DocumentValidation } from "@shared/validations"; import slugify from "@server/utils/slugify"; @@ -261,7 +261,7 @@ class Document extends ParanoidModel { // hooks @BeforeSave - static async updateTitleInCollectionStructure( + static async updateCollectionStructure( model: Document, { transaction }: SaveOptions ) { @@ -271,7 +271,7 @@ class Document extends ParanoidModel { model.archivedAt || model.template || !model.publishedAt || - !model.changed("title") || + !(model.changed("title") || model.changed("emoji")) || !model.collectionId ) { return; @@ -330,10 +330,6 @@ class Document extends ParanoidModel { @BeforeUpdate static processUpdate(model: Document) { - const { emoji } = parseTitle(model.title); - // emoji in the title is split out for easier display - model.emoji = emoji || null; - // ensure documents have a title model.title = model.title || ""; @@ -795,6 +791,7 @@ class Document extends ParanoidModel { id: this.id, title: this.title, url: this.url, + emoji: isNil(this.emoji) ? undefined : this.emoji, children, }; }; diff --git a/server/models/Revision.ts b/server/models/Revision.ts index d0bfad44ff..ef5210d8ff 100644 --- a/server/models/Revision.ts +++ b/server/models/Revision.ts @@ -49,6 +49,13 @@ class Revision extends IdModel { @Column(DataType.TEXT) text: string; + @Length({ + max: 1, + msg: `Emoji must be a single character`, + }) + @Column + emoji: string | null; + // associations @BelongsTo(() => Document, "documentId") @@ -65,6 +72,14 @@ class Revision extends IdModel { @Column(DataType.UUID) userId: string; + // static methods + + /** + * Find the latest revision for a given document + * + * @param documentId The document id to find the latest revision for + * @returns A Promise that resolves to a Revision model + */ static findLatest(documentId: string) { return this.findOne({ where: { @@ -74,10 +89,17 @@ class Revision extends IdModel { }); } + /** + * Build a Revision model from a Document model + * + * @param document The document to build from + * @returns A Revision model + */ static buildFromDocument(document: Document) { return this.build({ title: document.title, text: document.text, + emoji: document.emoji, userId: document.lastModifiedById, editorVersion: document.editorVersion, version: document.version, @@ -88,6 +110,13 @@ class Revision extends IdModel { }); } + /** + * Create a Revision model from a Document model and save it to the database + * + * @param document The document to create from + * @param options Options passed to the save method + * @returns A Promise that resolves when saved + */ static createFromDocument( document: Document, options?: SaveOptions diff --git a/server/presenters/document.ts b/server/presenters/document.ts index 49f2c9abb9..d39e127d16 100644 --- a/server/presenters/document.ts +++ b/server/presenters/document.ts @@ -27,6 +27,7 @@ async function presentDocument( url: document.url, urlId: document.urlId, title: document.title, + emoji: document.emoji, text, tasks: document.tasks, createdAt: document.createdAt, diff --git a/server/presenters/revision.ts b/server/presenters/revision.ts index f57bdd0d98..6c6505f705 100644 --- a/server/presenters/revision.ts +++ b/server/presenters/revision.ts @@ -1,13 +1,18 @@ +import parseTitle from "@shared/utils/parseTitle"; import { traceFunction } from "@server/logging/tracing"; import { Revision } from "@server/models"; import presentUser from "./user"; async function presentRevision(revision: Revision, diff?: string) { + // TODO: Remove this fallback once all revisions have been migrated + const { emoji, strippedTitle } = parseTitle(revision.title); + return { id: revision.id, documentId: revision.documentId, - title: revision.title, + title: strippedTitle, text: revision.text, + emoji: revision.emoji ?? emoji, html: diff, createdAt: revision.createdAt, createdBy: presentUser(revision.user), diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index ba63cae699..87059e5bf4 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -2492,6 +2492,7 @@ describe("#documents.update", () => { const document = await buildDraftDocument({ teamId: team.id, }); + const res = await server.post("/api/documents.update", { body: { token: user.getJwtToken(), @@ -2503,6 +2504,7 @@ describe("#documents.update", () => { }); const body = await res.json(); expect(res.status).toEqual(400); + expect(body.message).toBe( "collectionId is required to publish a draft without collection" ); @@ -2515,7 +2517,6 @@ describe("#documents.update", () => { text: "text", teamId: team.id, }); - const res = await server.post("/api/documents.update", { body: { token: user.getJwtToken(), @@ -2551,6 +2552,36 @@ describe("#documents.update", () => { expect(res.status).toEqual(403); }); + it("should fail to update an invalid emoji value", async () => { + const { user, document } = await seed(); + + const res = await server.post("/api/documents.update", { + body: { + token: user.getJwtToken(), + id: document.id, + emoji: ":)", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + + expect(body.message).toBe("emoji: Invalid"); + }); + + it("should successfully update the emoji", async () => { + const { user, document } = await seed(); + const res = await server.post("/api/documents.update", { + body: { + token: user.getJwtToken(), + id: document.id, + emoji: "πŸ˜‚", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.emoji).toBe("πŸ˜‚"); + }); + it("should not add template to collection structure when publishing", async () => { const user = await buildUser(); const collection = await buildCollection({ diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index be62abd0bc..900abe247d 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -861,6 +861,7 @@ router.post( lastModifiedById: user.id, createdById: user.id, template: true, + emoji: original.emoji, title: original.title, text: original.text, }); diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index 5932682d5b..7473213832 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -1,3 +1,4 @@ +import emojiRegex from "emoji-regex"; import isEmpty from "lodash/isEmpty"; import isUUID from "validator/lib/isUUID"; import { z } from "zod"; @@ -186,6 +187,9 @@ export const DocumentsUpdateSchema = BaseSchema.extend({ /** Doc text to be updated */ text: z.string().optional(), + /** Emoji displayed alongside doc title */ + emoji: z.string().regex(emojiRegex()).nullish(), + /** Boolean to denote if the doc should occupy full width */ fullWidth: z.boolean().optional(), diff --git a/server/scripts/20230815063834-migrate-emoji-in-document-title.test.ts b/server/scripts/20230815063834-migrate-emoji-in-document-title.test.ts new file mode 100644 index 0000000000..4fb6a2cb04 --- /dev/null +++ b/server/scripts/20230815063834-migrate-emoji-in-document-title.test.ts @@ -0,0 +1,120 @@ +import { Document } from "@server/models"; +import { buildDocument, buildDraftDocument } from "@server/test/factories"; +import { setupTestDatabase } from "@server/test/support"; +import script from "./20230815063834-migrate-emoji-in-document-title"; + +setupTestDatabase(); + +describe("#work", () => { + it("should correctly update title and emoji for a draft document", async () => { + const document = await buildDraftDocument({ + title: "😡 Title draft", + }); + expect(document.publishedAt).toBeNull(); + expect(document.emoji).toBeNull(); + + await script(); + const draft = await Document.unscoped().findByPk(document.id); + expect(draft).not.toBeNull(); + expect(draft?.title).toEqual("Title draft"); + expect(draft?.emoji).toEqual("😡"); + }); + + it("should correctly update title and emoji for a published document", async () => { + const document = await buildDocument({ + title: "πŸ‘±πŸ½β€β™€οΈ Title published", + }); + expect(document.publishedAt).toBeTruthy(); + expect(document.emoji).toBeNull(); + + await script(); + const published = await Document.unscoped().findByPk(document.id); + expect(published).not.toBeNull(); + expect(published?.title).toEqual("Title published"); + expect(published?.emoji).toEqual("πŸ‘±πŸ½β€β™€οΈ"); + }); + + it("should correctly update title and emoji for an archived document", async () => { + const document = await buildDocument({ + title: "πŸ‡ Title archived", + }); + await document.archive(document.createdById); + expect(document.archivedAt).toBeTruthy(); + expect(document.emoji).toBeNull(); + + await script(); + const archived = await Document.unscoped().findByPk(document.id); + expect(archived).not.toBeNull(); + expect(archived?.title).toEqual("Title archived"); + expect(archived?.emoji).toEqual("πŸ‡"); + }); + + it("should correctly update title and emoji for a template", async () => { + const document = await buildDocument({ + title: "🐹 Title template", + template: true, + }); + expect(document.template).toBe(true); + expect(document.emoji).toBeNull(); + + await script(); + const template = await Document.unscoped().findByPk(document.id); + expect(template).not.toBeNull(); + expect(template?.title).toEqual("Title template"); + expect(template?.emoji).toEqual("🐹"); + }); + + it("should correctly update title and emoji for a deleted document", async () => { + const document = await buildDocument({ + title: "πŸš΅πŸΌβ€β™‚οΈ Title deleted", + }); + await document.destroy(); + expect(document.deletedAt).toBeTruthy(); + expect(document.emoji).toBeNull(); + + await script(); + const deleted = await Document.unscoped().findByPk(document.id, { + paranoid: false, + }); + expect(deleted).not.toBeNull(); + expect(deleted?.title).toEqual("Title deleted"); + expect(deleted?.emoji).toEqual("πŸš΅πŸΌβ€β™‚οΈ"); + }); + + it("should correctly update title emoji when there are leading spaces", async () => { + const document = await buildDocument({ + title: " 🀨 Title with spaces", + }); + expect(document.emoji).toBeNull(); + + await script(); + + const doc = await Document.unscoped().findByPk(document.id); + expect(doc).not.toBeNull(); + expect(doc?.title).toEqual("Title with spaces"); + expect(doc?.emoji).toEqual("🀨"); + }); + + it("should correctly paginate and update title emojis", async () => { + const buildManyDocuments = []; + for (let i = 1; i <= 10; i++) { + buildManyDocuments.push(buildDocument({ title: "πŸš΅πŸΌβ€β™‚οΈ Title" })); + } + + const manyDocuments = await Promise.all(buildManyDocuments); + + for (const document of manyDocuments) { + expect(document.title).toEqual("πŸš΅πŸΌβ€β™‚οΈ Title"); + expect(document.emoji).toBeNull(); + } + + await script(false, 2); + + const documents = await Document.unscoped().findAll(); + + for (const document of documents) { + expect(document.title).toEqual("Title"); + expect(document.emoji).toEqual("πŸš΅πŸΌβ€β™‚οΈ"); + } + }); +}); diff --git a/server/scripts/20230815063834-migrate-emoji-in-document-title.ts b/server/scripts/20230815063834-migrate-emoji-in-document-title.ts new file mode 100644 index 0000000000..3590ae478b --- /dev/null +++ b/server/scripts/20230815063834-migrate-emoji-in-document-title.ts @@ -0,0 +1,69 @@ +import "./bootstrap"; +import { Transaction, Op } from "sequelize"; +import parseTitle from "@shared/utils/parseTitle"; +import { Document } from "@server/models"; +import { sequelize } from "@server/storage/database"; + +let page = parseInt(process.argv[2], 10); +page = Number.isNaN(page) ? 0 : page; + +export default async function main(exit = false, limit = 1000) { + const work = async (page: number): Promise => { + console.log(`Backfill document emoji from title… page ${page}`); + let documents: Document[] = []; + await sequelize.transaction(async (transaction) => { + documents = await Document.unscoped().findAll({ + attributes: { + exclude: ["state"], + }, + where: { + version: { + [Op.ne]: null, + }, + }, + limit, + offset: page * limit, + order: [["createdAt", "ASC"]], + paranoid: false, + lock: Transaction.LOCK.UPDATE, + transaction, + }); + + for (const document of documents) { + try { + const { emoji, strippedTitle } = parseTitle(document.title); + if (emoji) { + document.emoji = emoji; + document.title = strippedTitle; + + if (document.changed()) { + console.log(`Migrating ${document.id}…`); + + await document.save({ + silent: true, + transaction, + }); + } + } + } catch (err) { + console.error(`Failed at ${document.id}:`, err); + continue; + } + } + }); + return documents.length === limit ? work(page + 1) : undefined; + }; + + await work(page); + + console.log("Backfill complete"); + + if (exit) { + process.exit(0); + } +} + +// In the test suite we import the script rather than run via node CLI +if (process.env.NODE_ENV !== "test") { + void main(true); +} diff --git a/server/scripts/20230827234031-migrate-emoji-in-revision-title.ts b/server/scripts/20230827234031-migrate-emoji-in-revision-title.ts new file mode 100644 index 0000000000..5aa5082618 --- /dev/null +++ b/server/scripts/20230827234031-migrate-emoji-in-revision-title.ts @@ -0,0 +1,64 @@ +import "./bootstrap"; +import { Transaction } from "sequelize"; +import parseTitle from "@shared/utils/parseTitle"; +import { Revision } from "@server/models"; +import { sequelize } from "@server/storage/database"; + +let page = parseInt(process.argv[2], 10); +page = Number.isNaN(page) ? 0 : page; + +export default async function main(exit = false, limit = 1000) { + const work = async (page: number): Promise => { + console.log(`Backfill revision emoji from title… page ${page}`); + let revisions: Revision[] = []; + await sequelize.transaction(async (transaction) => { + revisions = await Revision.unscoped().findAll({ + attributes: { + exclude: ["text"], + }, + limit, + offset: page * limit, + order: [["createdAt", "ASC"]], + paranoid: false, + lock: Transaction.LOCK.UPDATE, + transaction, + }); + + for (const revision of revisions) { + try { + const { emoji, strippedTitle } = parseTitle(revision.title); + if (emoji) { + revision.emoji = emoji; + revision.title = strippedTitle; + + if (revision.changed()) { + console.log(`Migrating ${revision.id}…`); + + await revision.save({ + silent: true, + transaction, + }); + } + } + } catch (err) { + console.error(`Failed at ${revision.id}:`, err); + continue; + } + } + }); + return revisions.length === limit ? work(page + 1) : undefined; + }; + + await work(page); + + console.log("Backfill complete"); + + if (exit) { + process.exit(0); + } +} + +// In the test suite we import the script rather than run via node CLI +if (process.env.NODE_ENV !== "test") { + void main(true); +} diff --git a/server/test/factories.ts b/server/test/factories.ts index 42f1cc7efc..4cded88a1f 100644 --- a/server/test/factories.ts +++ b/server/test/factories.ts @@ -377,6 +377,7 @@ export async function buildDocument( publishedAt: isNull(overrides.collectionId) ? null : new Date(), lastModifiedById: overrides.userId, createdById: overrides.userId, + editorVersion: 2, ...overrides, }, { diff --git a/shared/editor/lib/emoji.ts b/shared/editor/lib/emoji.ts new file mode 100644 index 0000000000..b063da76db --- /dev/null +++ b/shared/editor/lib/emoji.ts @@ -0,0 +1,14 @@ +import data, { type EmojiMartData } from "@emoji-mart/data"; +import snakeCase from "lodash/snakeCase"; + +/** + * A map of emoji shortcode to emoji character. The shortcode is snake cased + * for backwards compatibility with those already encoded into documents. + */ +export const nameToEmoji = Object.values((data as EmojiMartData).emojis).reduce( + (acc, emoji) => { + acc[snakeCase(emoji.id)] = emoji.skins[0].native; + return acc; + }, + {} +); diff --git a/shared/editor/nodes/Emoji.tsx b/shared/editor/nodes/Emoji.tsx index 82153419b4..c9c4fd96f1 100644 --- a/shared/editor/nodes/Emoji.tsx +++ b/shared/editor/nodes/Emoji.tsx @@ -1,4 +1,3 @@ -import nameToEmoji from "gemoji/name-to-emoji.json"; import Token from "markdown-it/lib/token"; import { NodeSpec, @@ -9,6 +8,7 @@ import { import { Command, TextSelection } from "prosemirror-state"; import { Primitive } from "utility-types"; import Suggestion from "../extensions/Suggestion"; +import { nameToEmoji } from "../lib/emoji"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; import { SuggestionsMenuType } from "../plugins/Suggestions"; import emojiRule from "../rules/emoji"; diff --git a/shared/editor/rules/emoji.ts b/shared/editor/rules/emoji.ts index b7705404fd..3b6ba370a5 100644 --- a/shared/editor/rules/emoji.ts +++ b/shared/editor/rules/emoji.ts @@ -1,6 +1,6 @@ -import nameToEmoji from "gemoji/name-to-emoji.json"; import MarkdownIt from "markdown-it"; import emojiPlugin from "markdown-it-emoji"; +import { nameToEmoji } from "../lib/emoji"; export default function emoji(md: MarkdownIt) { // Ideally this would be an empty object, but due to a bug in markdown-it-emoji diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index a7e05a2396..ee829aec0a 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -168,6 +168,8 @@ "Currently editing": "Currently editing", "Currently viewing": "Currently viewing", "Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago", + "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.", @@ -242,6 +244,8 @@ "{{ releasesBehind }} versions behind_plural": "{{ releasesBehind }} versions behind", "Return to App": "Back to App", "Installation": "Installation", + "Unstar document": "Unstar document", + "Star document": "Star document", "No results": "No results", "Previous page": "Previous page", "Next page": "Next page", @@ -358,7 +362,6 @@ "Show path to document": "Show path to document", "Path to document": "Path to document", "Group member options": "Group member options", - "Remove": "Remove", "Export collection": "Export collection", "Sort in sidebar": "Sort in sidebar", "Alphabetical sort": "Alphabetical sort", diff --git a/shared/styles/index.ts b/shared/styles/index.ts index 4b79ec5c02..83221819f3 100644 --- a/shared/styles/index.ts +++ b/shared/styles/index.ts @@ -37,3 +37,20 @@ export const hideScrollbars = () => ` display: none; } `; + +/** + * Mixin on any component with relative positioning to add additional hidden clickable/hoverable area + * + * @param pixels + * @returns + */ +export const extraArea = (pixels: number): string => ` + &::before { + position: absolute; + content: ""; + top: -${pixels}px; + right: -${pixels}px; + left: -${pixels}px; + bottom: -${pixels}px; + } +`; diff --git a/shared/types.ts b/shared/types.ts index 7061a26d69..3612cb2d1a 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -159,6 +159,7 @@ export type NavigationNode = { id: string; title: string; url: string; + emoji?: string; children: NavigationNode[]; isDraft?: boolean; collectionId?: string; diff --git a/shared/typings/gemoji.d.ts b/shared/typings/gemoji.d.ts deleted file mode 100644 index 4db190daf5..0000000000 --- a/shared/typings/gemoji.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "gemoji"; diff --git a/shared/utils/color.ts b/shared/utils/color.ts index af094445cd..5d0344fab3 100644 --- a/shared/utils/color.ts +++ b/shared/utils/color.ts @@ -1,5 +1,5 @@ import md5 from "crypto-js/md5"; -import { darken } from "polished"; +import { darken, parseToRgb } from "polished"; import theme from "../styles/theme"; export const palette = [ @@ -26,3 +26,12 @@ export const stringToColor = (input: string) => { const inputAsNumber = parseInt(md5(input).toString(), 16); return palette[inputAsNumber % palette.length]; }; + +/** + * Converts a color to string of RGB values separated by commas + * + * @param color - A color string + * @returns A string of RGB values separated by commas + */ +export const toRGB = (color: string) => + Object.values(parseToRgb(color)).join(", "); diff --git a/shared/utils/parseTitle.ts b/shared/utils/parseTitle.ts index e3cb4ecd2d..23ecb556b6 100644 --- a/shared/utils/parseTitle.ts +++ b/shared/utils/parseTitle.ts @@ -10,7 +10,7 @@ export default function parseTitle(text = "") { // find and extract first emoji const matches = regex.exec(title); const firstEmoji = matches ? matches[0] : null; - const startsWithEmoji = firstEmoji && title.startsWith(`${firstEmoji} `); + const startsWithEmoji = firstEmoji && title.startsWith(firstEmoji); const emoji = startsWithEmoji ? firstEmoji : undefined; // title with first leading emoji stripped diff --git a/yarn.lock b/yarn.lock index b2b9e66e1c..aa527ea3f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1307,6 +1307,16 @@ dependencies: tslib "^2.0.0" +"@emoji-mart/data@^1.0.6": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.1.2.tgz#777c976f8f143df47cbb23a7077c9ca9fe5fc513" + integrity sha512-1HP8BxD2azjqWJvxIaWAMyTySeZY0Osr83ukYjltPVkNXeJvTz7yDrPLBtnrD5uqJ3tg4CcLuuBW09wahqL/fg== + +"@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 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" @@ -5937,6 +5947,11 @@ emittery@^0.13.1: resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== +emoji-mart@^5.5.2: + version "5.5.2" + resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.5.2.tgz#3ddbaf053139cf4aa217650078bc1c50ca8381af" + integrity sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A== + emoji-regex@*, emoji-regex@^10.2.1: version "10.2.1" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.2.1.tgz#a41c330d957191efd3d9dfe6e1e8e1e9ab048b3f" @@ -7103,11 +7118,6 @@ fuzzy-search@^3.2.1: resolved "https://registry.yarnpkg.com/fuzzy-search/-/fuzzy-search-3.2.1.tgz#65d5faad6bc633aee86f1898b7788dfe312ac6c9" integrity sha512-vAcPiyomt1ioKAsAL2uxSABHJ4Ju/e4UeDM+g1OlR0vV4YhLGMNsdLNvZTpEDY4JCSt0E4hASCNM5t2ETtsbyg== -gemoji@6.x: - version "6.1.0" - resolved "https://registry.yarnpkg.com/gemoji/-/gemoji-6.1.0.tgz#268fbb0c81d1a8c32a4bcc39bdfdd66080ba7ce9" - integrity sha512-MOlX3doQ1fsfzxQX8Y+u6bC5Ssc1pBUBIPVyrS69EzKt+5LIZAOm0G5XGVNhwXFgkBF3r+Yk88ONyrFHo8iNFA== - gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"