Document emoji picker (#4338)

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Apoorv Mishra
2023-09-03 18:41:14 +05:30
committed by GitHub
parent 0054b7152e
commit 1c7bb65c7a
57 changed files with 1367 additions and 510 deletions

View File

@@ -11,14 +11,14 @@ test:
docker-compose up -d redis postgres s3 docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test yarn sequelize db:drop --env=test
yarn sequelize db:create --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 yarn test
watch: watch:
docker-compose up -d redis postgres s3 docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test yarn sequelize db:drop --env=test
yarn sequelize db:create --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 yarn test:watch
destroy: destroy:

View File

@@ -9,6 +9,7 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
readOnly?: boolean; readOnly?: boolean;
onClick?: React.MouseEventHandler<HTMLDivElement>; onClick?: React.MouseEventHandler<HTMLDivElement>;
onChange?: (text: string) => void; onChange?: (text: string) => void;
onFocus?: React.FocusEventHandler<HTMLSpanElement> | undefined;
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined; onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined; onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined; onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
@@ -35,6 +36,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
disabled, disabled,
onChange, onChange,
onInput, onInput,
onFocus,
onBlur, onBlur,
onKeyDown, onKeyDown,
value, value,
@@ -143,11 +145,13 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
); );
return ( return (
<div className={className} dir={dir} onClick={onClick}> <div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
{children}
<Content <Content
ref={contentRef} ref={contentRef}
contentEditable={!disabled && !readOnly} contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)} onInput={wrappedEvent(onInput)}
onFocus={wrappedEvent(onFocus)}
onBlur={wrappedEvent(onBlur)} onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)} onKeyDown={wrappedEvent(onKeyDown)}
onPaste={handlePaste} onPaste={handlePaste}
@@ -158,7 +162,6 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
> >
{innerValue} {innerValue}
</Content> </Content>
{children}
</div> </div>
); );
}); });

View File

@@ -15,6 +15,7 @@ import {
templatesPath, templatesPath,
trashPath, trashPath,
} from "~/utils/routeHelpers"; } from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = { type Props = {
children?: React.ReactNode; children?: React.ReactNode;
@@ -105,7 +106,13 @@ const DocumentBreadcrumb: React.FC<Props> = ({
path.forEach((node: NavigationNode) => { path.forEach((node: NavigationNode) => {
output.push({ output.push({
type: "route", type: "route",
title: node.title, title: node.emoji ? (
<>
<EmojiIcon emoji={node.emoji} /> {node.title}
</>
) : (
node.title
),
to: node.url, to: node.url,
}); });
}); });

View File

@@ -111,7 +111,7 @@ function DocumentCard(props: Props) {
{document.emoji ? ( {document.emoji ? (
<Squircle color={theme.slateLight}> <Squircle color={theme.slateLight}>
<EmojiIcon emoji={document.emoji} size={26} /> <EmojiIcon emoji={document.emoji} size={24} />
</Squircle> </Squircle>
) : ( ) : (
<Squircle color={collection?.color}> <Squircle color={collection?.color}>

View File

@@ -15,7 +15,6 @@ import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import { NavigationNode } from "@shared/types"; import { NavigationNode } from "@shared/types";
import parseTitle from "@shared/utils/parseTitle";
import DocumentExplorerNode from "~/components/DocumentExplorerNode"; import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult"; import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
@@ -205,84 +204,86 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
} }
}; };
const ListItem = ({ const ListItem = observer(
index, ({
data, index,
style, data,
}: { style,
index: number; }: {
data: NavigationNode[]; index: number;
style: React.CSSProperties; data: NavigationNode[];
}) => { style: React.CSSProperties;
const node = data[index]; }) => {
const isCollection = node.type === "collection"; const node = data[index];
let icon, title, path; const isCollection = node.type === "collection";
let icon, title: string, emoji: string | undefined, path;
if (isCollection) { if (isCollection) {
const col = collections.get(node.collectionId as string); const col = collections.get(node.collectionId as string);
icon = col && ( icon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} /> <CollectionIcon collection={col} expanded={isExpanded(index)} />
); );
title = node.title; title = node.title;
} else {
const doc = documents.get(node.id);
const { strippedTitle, emoji } = parseTitle(node.title);
title = strippedTitle;
if (emoji) {
icon = <EmojiIcon emoji={emoji} />;
} else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />;
} else { } else {
icon = <DocumentIcon color={theme.textSecondary} />; const doc = documents.get(node.id);
emoji = doc?.emoji ?? node.emoji;
title = doc?.title ?? node.title;
if (emoji) {
icon = <EmojiIcon emoji={emoji} />;
} else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />;
} else {
icon = <DocumentIcon color={theme.textSecondary} />;
}
path = ancestors(node)
.map((a) => a.title)
.join(" / ");
} }
path = ancestors(node) return searchTerm ? (
.map((a) => parseTitle(a.title).strippedTitle) <DocumentExplorerSearchResult
.join(" / "); selected={isSelected(index)}
active={activeNode === index}
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
icon={icon}
title={title}
path={path}
/>
) : (
<DocumentExplorerNode
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => 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 ? (
<DocumentExplorerSearchResult
selected={isSelected(index)}
active={activeNode === index}
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
icon={icon}
title={title}
path={path}
/>
) : (
<DocumentExplorerNode
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => 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 = () => { const focusSearchInput = () => {
inputSearchRef.current?.focus(); inputSearchRef.current?.focus();

View File

@@ -24,6 +24,7 @@ import usePolicy from "~/hooks/usePolicy";
import DocumentMenu from "~/menus/DocumentMenu"; import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles"; import { hover } from "~/styles";
import { newDocumentPath } from "~/utils/routeHelpers"; import { newDocumentPath } from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = { type Props = {
document: Document; document: Document;
@@ -92,6 +93,12 @@ function DocumentListItem(
> >
<Content> <Content>
<Heading dir={document.dir}> <Heading dir={document.dir}>
{document.emoji && (
<>
<EmojiIcon emoji={document.emoji} size={24} />
&nbsp;
</>
)}
<Title <Title
text={document.titleWithDefault} text={document.titleWithDefault}
highlight={highlight} highlight={highlight}

View File

@@ -0,0 +1,23 @@
import styled from "styled-components";
import Button from "~/components/Button";
import { hover } from "~/styles";
import Flex from "../Flex";
export const EmojiButton = styled(Button)`
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
&: ${hover},
&:active,
&[aria-expanded= "true"] {
opacity: 1 !important;
}
`;
export const Emoji = styled(Flex)<{ size?: number }>`
line-height: 1.6;
${(props) => (props.size ? `font-size: ${props.size}px` : "")}
`;

View File

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

View File

@@ -29,5 +29,5 @@ const Span = styled.span<{ $size: number }>`
width: ${(props) => props.$size}px; width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px; height: ${(props) => props.$size}px;
text-indent: -0.15em; text-indent: -0.15em;
font-size: 14px; font-size: ${(props) => props.$size - 10}px;
`; `;

View File

@@ -324,6 +324,7 @@ function InnerDocumentLink(
starred: inStarredSection, starred: inStarredSection,
}, },
}} }}
emoji={document?.emoji || node.emoji}
label={ label={
<EditableTitle <EditableTitle
title={title} title={title}

View File

@@ -8,7 +8,6 @@ import Document from "~/models/Document";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { sharedDocumentPath } from "~/utils/routeHelpers"; import { sharedDocumentPath } from "~/utils/routeHelpers";
import { descendants } from "~/utils/tree"; import { descendants } from "~/utils/tree";
import Disclosure from "./Disclosure";
import SidebarLink from "./SidebarLink"; import SidebarLink from "./SidebarLink";
type Props = { type Props = {
@@ -110,14 +109,10 @@ function DocumentLink(
title: node.title, title: node.title,
}, },
}} }}
label={ expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
<> onDisclosureClick={handleDisclosureClick}
{hasChildDocuments && depth !== 0 && ( emoji={node.emoji}
<Disclosure expanded={expanded} onClick={handleDisclosureClick} /> label={title}
)}
{title}
</>
}
depth={depth} depth={depth}
exact={false} exact={false}
scrollIntoViewIfNeeded={!document?.isStarred} scrollIntoViewIfNeeded={!document?.isStarred}

View File

@@ -5,6 +5,7 @@ import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import { NavigationNode } from "@shared/types"; import { NavigationNode } from "@shared/types";
import EventBoundary from "~/components/EventBoundary"; import EventBoundary from "~/components/EventBoundary";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import useUnmount from "~/hooks/useUnmount"; import useUnmount from "~/hooks/useUnmount";
import { undraggableOnDesktop } from "~/styles"; import { undraggableOnDesktop } from "~/styles";
@@ -25,6 +26,7 @@ type Props = Omit<NavLinkProps, "to"> & {
onClickIntent?: () => void; onClickIntent?: () => void;
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>; onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
icon?: React.ReactNode; icon?: React.ReactNode;
emoji?: string | null;
label?: React.ReactNode; label?: React.ReactNode;
menu?: React.ReactNode; menu?: React.ReactNode;
showActions?: boolean; showActions?: boolean;
@@ -48,6 +50,7 @@ function SidebarLink(
onClick, onClick,
onClickIntent, onClickIntent,
to, to,
emoji,
label, label,
active, active,
isActiveDrop, isActiveDrop,
@@ -136,6 +139,7 @@ function SidebarLink(
/> />
)} )}
{icon && <IconWrapper>{icon}</IconWrapper>} {icon && <IconWrapper>{icon}</IconWrapper>}
{emoji && <EmojiIcon emoji={emoji} />}
<Label>{label}</Label> <Label>{label}</Label>
</Content> </Content>
</Link> </Link>
@@ -152,6 +156,7 @@ const Content = styled.span`
${Disclosure} { ${Disclosure} {
margin-top: 2px; margin-top: 2px;
margin-left: 2px;
} }
`; `;

View File

@@ -8,7 +8,6 @@ import { useDrag, useDrop } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend"; import { getEmptyImage } from "react-dnd-html5-backend";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
import parseTitle from "@shared/utils/parseTitle";
import Star from "~/models/Star"; import Star from "~/models/Star";
import Fade from "~/components/Fade"; import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon"; import CollectionIcon from "~/components/Icons/CollectionIcon";
@@ -42,14 +41,10 @@ function useLabelAndIcon({ documentId, collectionId }: Star) {
if (documentId) { if (documentId) {
const document = documents.get(documentId); const document = documents.get(documentId);
if (document) { if (document) {
const { emoji } = parseTitle(document?.title);
return { return {
label: emoji label: document.titleWithDefault,
? document.title.replace(emoji, "") icon: document.emoji ? (
: document.titleWithDefault, <EmojiIcon emoji={document.emoji} />
icon: emoji ? (
<EmojiIcon emoji={emoji} />
) : ( ) : (
<StarredIcon color={theme.yellow} /> <StarredIcon color={theme.yellow} />
), ),
@@ -148,6 +143,10 @@ function StarredLink({ star }: Props) {
return null; return null;
} }
const { emoji } = document;
const label = emoji
? document.title.replace(emoji, "")
: document.titleWithDefault;
const collection = document.collectionId const collection = document.collectionId
? collections.get(document.collectionId) ? collections.get(document.collectionId)
: undefined; : undefined;

View File

@@ -1,6 +1,7 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { StarredIcon, UnstarredIcon } from "outline-icons"; import { StarredIcon, UnstarredIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Document from "~/models/Document"; import Document from "~/models/Document";
@@ -14,12 +15,18 @@ import { hover } from "~/styles";
import NudeButton from "./NudeButton"; import NudeButton from "./NudeButton";
type Props = { type Props = {
/** Target collection */
collection?: Collection; collection?: Collection;
/** Target document */
document?: Document; document?: Document;
/** Size of the star */
size?: number; 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 theme = useTheme();
const context = useActionContext({ const context = useActionContext({
activeDocumentId: document?.id, activeDocumentId: document?.id,
@@ -36,6 +43,10 @@ function Star({ size, document, collection, ...rest }: Props) {
<NudeButton <NudeButton
context={context} context={context}
hideOnActionDisabled hideOnActionDisabled
tooltip={{
tooltip: target.isStarred ? t("Unstar document") : t("Star document"),
delay: 500,
}}
action={ action={
collection collection
? collection.isStarred ? collection.isStarred
@@ -55,7 +66,7 @@ function Star({ size, document, collection, ...rest }: Props) {
) : ( ) : (
<AnimatedStar <AnimatedStar
size={size} size={size}
color={theme.textTertiary} color={color ?? theme.textTertiary}
as={UnstarredIcon} as={UnstarredIcon}
/> />
)} )}

View File

@@ -1,5 +1,7 @@
import data, { type Emoji as TEmoji, EmojiMartData } from "@emoji-mart/data";
import FuzzySearch from "fuzzy-search"; import FuzzySearch from "fuzzy-search";
import gemojies from "gemoji"; import capitalize from "lodash/capitalize";
import snakeCase from "lodash/snakeCase";
import React from "react"; import React from "react";
import EmojiMenuItem from "./EmojiMenuItem"; import EmojiMenuItem from "./EmojiMenuItem";
import SuggestionsMenu, { import SuggestionsMenu, {
@@ -14,14 +16,14 @@ type Emoji = {
attrs: { markup: string; "data-name": string }; attrs: { markup: string; "data-name": string };
}; };
const searcher = new FuzzySearch<{ const searcher = new FuzzySearch<TEmoji>(
names: string[]; Object.values((data as EmojiMartData).emojis),
description: string; ["keywords"],
emoji: string; {
}>(gemojies, ["names"], { caseSensitive: true,
caseSensitive: true, sort: true,
sort: true, }
}); );
type Props = Omit< type Props = Omit<
SuggestionsMenuProps<Emoji>, SuggestionsMenuProps<Emoji>,
@@ -34,14 +36,17 @@ const EmojiMenu = (props: Props) => {
const items = React.useMemo(() => { const items = React.useMemo(() => {
const n = search.toLowerCase(); const n = search.toLowerCase();
const result = searcher.search(n).map((item) => { const result = searcher.search(n).map((item) => {
const description = item.description; // We snake_case the shortcode for backwards compatability with gemoji to
const name = item.names[0]; // avoid multiple formats being written into documents.
const shortcode = snakeCase(item.id);
const emoji = item.skins[0].native;
return { return {
...item,
name: "emoji", name: "emoji",
title: name, title: emoji,
description, description: capitalize(item.name.toLowerCase()),
attrs: { markup: name, "data-name": name }, emoji,
attrs: { markup: shortcode, "data-name": shortcode },
}; };
}); });

View File

@@ -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]);
}

View File

@@ -1,11 +1,18 @@
import useStores from "./useStores"; 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(); const { auth } = useStores();
if (!auth.user || !auth.user.language) { if (!auth.user?.language) {
return undefined; return undefined;
} }
return auth.user.language; const { language } = auth.user;
return languageCode ? language.split("_")[0] : language;
} }

View File

@@ -4,7 +4,6 @@ import { action, autorun, computed, observable, set } from "mobx";
import { ExportContentType } from "@shared/types"; import { ExportContentType } from "@shared/types";
import type { NavigationNode } from "@shared/types"; import type { NavigationNode } from "@shared/types";
import Storage from "@shared/utils/Storage"; import Storage from "@shared/utils/Storage";
import parseTitle from "@shared/utils/parseTitle";
import { isRTL } from "@shared/utils/rtl"; import { isRTL } from "@shared/utils/rtl";
import DocumentsStore from "~/stores/DocumentsStore"; import DocumentsStore from "~/stores/DocumentsStore";
import User from "~/models/User"; import User from "~/models/User";
@@ -68,6 +67,13 @@ export default class Document extends ParanoidModel {
@observable @observable
title: string; title: string;
/**
* An emoji to use as the document icon.
*/
@Field
@observable
emoji: string | undefined | null;
/** /**
* Whether this is a template. * Whether this is a template.
*/ */
@@ -127,12 +133,6 @@ export default class Document extends ParanoidModel {
revision: number; revision: number;
@computed
get emoji() {
const { emoji } = parseTitle(this.title);
return emoji;
}
/** /**
* Returns the direction of the document text, either "rtl" or "ltr" * Returns the direction of the document text, either "rtl" or "ltr"
*/ */

View File

@@ -14,6 +14,9 @@ class Revision extends BaseModel {
/** Markdown string of the content when revision was created */ /** Markdown string of the content when revision was created */
text: string; 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 representing the revision as a diff from the previous version */
html: string; html: string;

View File

@@ -26,7 +26,6 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments"; import PinnedDocuments from "~/components/PinnedDocuments";
import PlaceholderText from "~/components/PlaceholderText"; import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene"; import Scene from "~/components/Scene";
import Star, { AnimatedStar } from "~/components/Star";
import Tab from "~/components/Tab"; import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs"; import Tabs from "~/components/Tabs";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
@@ -157,7 +156,7 @@ function CollectionScene() {
<Empty collection={collection} /> <Empty collection={collection} />
) : ( ) : (
<> <>
<HeadingWithIcon $isStarred={collection.isStarred}> <HeadingWithIcon>
<HeadingIcon collection={collection} size={40} expanded /> <HeadingIcon collection={collection} size={40} expanded />
{collection.name} {collection.name}
{collection.isPrivate && ( {collection.isPrivate && (
@@ -170,7 +169,6 @@ function CollectionScene() {
<Badge>{t("Private")}</Badge> <Badge>{t("Private")}</Badge>
</Tooltip> </Tooltip>
)} )}
<StarButton collection={collection} size={32} />
</HeadingWithIcon> </HeadingWithIcon>
<CollectionDescription collection={collection} /> <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` const Documents = styled.div`
position: relative; position: relative;
background: ${s("background")}; background: ${s("background")};
`; `;
const HeadingWithIcon = styled(Heading)<{ $isStarred: boolean }>` const HeadingWithIcon = styled(Heading)`
display: flex; display: flex;
align-items: center; align-items: center;
${AnimatedStar} {
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
}
&:hover {
${AnimatedStar} {
opacity: 0.5;
&:hover {
opacity: 1;
}
}
}
${breakpoint("tablet")` ${breakpoint("tablet")`
margin-left: -40px; margin-left: -40px;
`}; `};

View File

@@ -354,7 +354,7 @@ class DocumentScene extends React.Component<Props> {
this.isUploading = false; this.isUploading = false;
}; };
onChange = (getEditorText: () => string) => { handleChange = (getEditorText: () => string) => {
const { document } = this.props; const { document } = this.props;
this.getEditorText = getEditorText; this.getEditorText = getEditorText;
@@ -369,13 +369,19 @@ class DocumentScene extends React.Component<Props> {
this.headings = headings; this.headings = headings;
}; };
onChangeTitle = action((value: string) => { handleChangeTitle = action((value: string) => {
this.title = value; this.title = value;
this.props.document.title = value; this.props.document.title = value;
this.updateIsDirty(); this.updateIsDirty();
void this.autosave(); void this.autosave();
}); });
handleChangeEmoji = action((value: string) => {
this.props.document.emoji = value;
this.updateIsDirty();
void this.autosave();
});
goBack = () => { goBack = () => {
if (!this.props.readOnly) { if (!this.props.readOnly) {
this.props.history.push(this.props.document.url); this.props.history.push(this.props.document.url);
@@ -482,7 +488,6 @@ class DocumentScene extends React.Component<Props> {
<Flex auto={!readOnly} reverse> <Flex auto={!readOnly} reverse>
{revision ? ( {revision ? (
<RevisionViewer <RevisionViewer
isDraft={document.isDraft}
document={document} document={document}
revision={revision} revision={revision}
id={revision.id} id={revision.id}
@@ -506,8 +511,9 @@ class DocumentScene extends React.Component<Props> {
onFileUploadStop={this.onFileUploadStop} onFileUploadStop={this.onFileUploadStop}
onSearchLink={this.props.onSearchLink} onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink} onCreateLink={this.props.onCreateLink}
onChangeTitle={this.onChangeTitle} onChangeTitle={this.handleChangeTitle}
onChange={this.onChange} onChangeEmoji={this.handleChangeEmoji}
onChange={this.handleChange}
onHeadingsChange={this.onHeadingsChange} onHeadingsChange={this.onHeadingsChange}
onSave={this.onSave} onSave={this.onSave}
onPublish={this.onPublish} onPublish={this.onPublish}

View File

@@ -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}
</Title>
);
});
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)<TitleProps>`
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);

View File

@@ -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<HTMLSpanElement>;
};
const lineHeight = "1.25";
const fontSize = "2.25em";
const EditableTitle = React.forwardRef(
(
{
document,
readOnly,
onChange,
onSave,
onGoToNextInput,
onBlur,
starrable,
placeholder,
}: Props,
ref: React.RefObject<RefHandle>
) => {
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 (
<Title
onClick={handleClick}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={onBlur}
placeholder={placeholder}
value={value}
$emojiWidth={emojiWidth}
$isStarred={document.isStarred}
autoFocus={!document.title}
maxLength={DocumentValidation.maxTitleLength}
readOnly={readOnly}
dir="auto"
ref={ref}
>
{starrable !== false && <StarButton document={document} size={32} />}
</Title>
);
}
);
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)<TitleProps>`
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);

View File

@@ -22,12 +22,13 @@ import {
import { useDocumentContext } from "../../../components/DocumentContext"; import { useDocumentContext } from "../../../components/DocumentContext";
import MultiplayerEditor from "./AsyncMultiplayerEditor"; import MultiplayerEditor from "./AsyncMultiplayerEditor";
import DocumentMeta from "./DocumentMeta"; import DocumentMeta from "./DocumentMeta";
import EditableTitle from "./EditableTitle"; import DocumentTitle from "./DocumentTitle";
const extensions = withComments(richExtensions); const extensions = withComments(richExtensions);
type Props = Omit<EditorProps, "extensions" | "editorStyle"> & { type Props = Omit<EditorProps, "extensions" | "editorStyle"> & {
onChangeTitle: (text: string) => void; onChangeTitle: (title: string) => void;
onChangeEmoji: (emoji: string | null) => void;
id: string; id: string;
document: Document; document: Document;
isDraft: boolean; isDraft: boolean;
@@ -56,6 +57,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
const { const {
document, document,
onChangeTitle, onChangeTitle,
onChangeEmoji,
isDraft, isDraft,
shareId, shareId,
readOnly, readOnly,
@@ -151,14 +153,20 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
return ( return (
<Flex auto column> <Flex auto column>
<EditableTitle <DocumentTitle
ref={titleRef} ref={titleRef}
readOnly={readOnly} readOnly={readOnly}
document={document} documentId={document.id}
title={
!document.title && readOnly
? document.titleWithDefault
: document.title
}
emoji={document.emoji}
onChangeTitle={onChangeTitle}
onChangeEmoji={onChangeEmoji}
onGoToNextInput={handleGoToNextInput} onGoToNextInput={handleGoToNextInput}
onChange={onChangeTitle}
onBlur={handleBlur} onBlur={handleBlur}
starrable={!shareId}
placeholder={t("Untitled")} placeholder={t("Untitled")}
/> />
{!shareId && ( {!shareId && (

View File

@@ -10,7 +10,7 @@ import {
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import styled from "styled-components"; import styled, { useTheme } from "styled-components";
import { NavigationNode } from "@shared/types"; import { NavigationNode } from "@shared/types";
import { Theme } from "~/stores/UiStore"; import { Theme } from "~/stores/UiStore";
import Document from "~/models/Document"; import Document from "~/models/Document";
@@ -21,6 +21,8 @@ import Button from "~/components/Button";
import Collaborators from "~/components/Collaborators"; import Collaborators from "~/components/Collaborators";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb"; import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Header from "~/components/Header"; import Header from "~/components/Header";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import Star from "~/components/Star";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
import { publishDocument } from "~/actions/definitions/documents"; import { publishDocument } from "~/actions/definitions/documents";
import { restoreRevision } from "~/actions/definitions/revisions"; import { restoreRevision } from "~/actions/definitions/revisions";
@@ -81,6 +83,7 @@ function DocumentHeader({
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { ui, auth } = useStores(); const { ui, auth } = useStores();
const theme = useTheme();
const { resolvedTheme } = ui; const { resolvedTheme } = ui;
const { team } = auth; const { team } = auth;
const isMobile = useMobile(); const isMobile = useMobile();
@@ -199,11 +202,18 @@ function DocumentHeader({
isMobile ? ( isMobile ? (
<TableOfContentsMenu headings={headings} /> <TableOfContentsMenu headings={headings} />
) : ( ) : (
<DocumentBreadcrumb document={document}>{toc}</DocumentBreadcrumb> <DocumentBreadcrumb document={document}>
{toc} <Star document={document} color={theme.textSecondary} />
</DocumentBreadcrumb>
) )
} }
title={ title={
<> <>
{document.emoji && (
<>
<EmojiIcon size={24} emoji={document.emoji} />{" "}
</>
)}
{document.title}{" "} {document.title}{" "}
{document.isArchived && ( {document.isArchived && (
<ArchivedBadge>{t("Archived")}</ArchivedBadge> <ArchivedBadge>{t("Archived")}</ArchivedBadge>

View File

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

View File

@@ -5,7 +5,6 @@ import { Link } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
import { s, ellipsis } from "@shared/styles"; import { s, ellipsis } from "@shared/styles";
import { NavigationNode } from "@shared/types"; import { NavigationNode } from "@shared/types";
import parseTitle from "@shared/utils/parseTitle";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import EmojiIcon from "~/components/Icons/EmojiIcon"; import EmojiIcon from "~/components/Icons/EmojiIcon";
@@ -59,7 +58,7 @@ function ReferenceListItem({
shareId, shareId,
...rest ...rest
}: Props) { }: Props) {
const { emoji } = parseTitle(document.title); const { emoji } = document;
return ( return (
<DocumentLink <DocumentLink

View File

@@ -7,12 +7,15 @@ import { Props as EditorProps } from "~/components/Editor";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import { documentPath } from "~/utils/routeHelpers"; import { documentPath } from "~/utils/routeHelpers";
import { Meta as DocumentMeta } from "./DocumentMeta"; import { Meta as DocumentMeta } from "./DocumentMeta";
import DocumentTitle from "./DocumentTitle";
type Props = Omit<EditorProps, "extensions"> & { type Props = Omit<EditorProps, "extensions"> & {
/** The ID of the revision */
id: string; id: string;
/** The current document */
document: Document; document: Document;
/** The revision to display */
revision: Revision; revision: Revision;
isDraft: boolean;
children?: React.ReactNode; children?: React.ReactNode;
}; };
@@ -24,7 +27,12 @@ function RevisionViewer(props: Props) {
return ( return (
<Flex auto column> <Flex auto column>
<h1 dir={revision.dir}>{revision.title}</h1> <DocumentTitle
documentId={revision.documentId}
title={revision.title}
emoji={revision.emoji}
readOnly
/>
<DocumentMeta <DocumentMeta
document={document} document={document}
revision={revision} revision={revision}

View File

@@ -661,7 +661,8 @@ export default class DocumentsStore extends BaseStore<Document> {
async update( async update(
params: { params: {
id: string; id: string;
title: string; title?: string;
emoji?: string | null;
text?: string; text?: string;
fullWidth?: boolean; fullWidth?: boolean;
templateId?: string; templateId?: string;

View File

@@ -141,6 +141,20 @@ export type FetchOptions = {
force?: boolean; 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 // Pagination response in an API call
export type Pagination = { export type Pagination = {
limit: number; limit: number;

View File

@@ -1,5 +1,9 @@
declare module "autotrack/autotrack.js"; declare module "autotrack/autotrack.js";
declare module "emoji-mart";
declare module "@emoji-mart/react";
declare module "string-replace-to-array"; declare module "string-replace-to-array";
declare module "sequelize-encrypted"; declare module "sequelize-encrypted";
@@ -16,5 +20,6 @@ declare module "*.png" {
declare namespace JSX { declare namespace JSX {
interface IntrinsicElements { interface IntrinsicElements {
"zapier-app-directory": any; "zapier-app-directory": any;
"em-emoji": any;
} }
} }

View File

@@ -59,6 +59,8 @@
"@dnd-kit/core": "^6.0.5", "@dnd-kit/core": "^6.0.5",
"@dnd-kit/modifiers": "^6.0.0", "@dnd-kit/modifiers": "^6.0.0",
"@dnd-kit/sortable": "^7.0.1", "@dnd-kit/sortable": "^7.0.1",
"@emoji-mart/data": "^1.0.6",
"@emoji-mart/react": "^1.1.1",
"@getoutline/y-prosemirror": "^1.0.18", "@getoutline/y-prosemirror": "^1.0.18",
"@hocuspocus/extension-throttle": "1.1.2", "@hocuspocus/extension-throttle": "1.1.2",
"@hocuspocus/provider": "1.1.2", "@hocuspocus/provider": "1.1.2",
@@ -94,6 +96,7 @@
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"dd-trace": "^3.33.0", "dd-trace": "^3.33.0",
"dotenv": "^4.0.0", "dotenv": "^4.0.0",
"emoji-mart": "^5.5.2",
"email-providers": "^1.14.0", "email-providers": "^1.14.0",
"emoji-regex": "^10.2.1", "emoji-regex": "^10.2.1",
"es6-error": "^4.1.1", "es6-error": "^4.1.1",
@@ -104,7 +107,6 @@
"framer-motion": "^4.1.17", "framer-motion": "^4.1.17",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
"fuzzy-search": "^3.2.1", "fuzzy-search": "^3.2.1",
"gemoji": "6.x",
"glob": "^8.1.0", "glob": "^8.1.0",
"http-errors": "2.0.0", "http-errors": "2.0.0",
"i18next": "^22.5.1", "i18next": "^22.5.1",

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import compact from "lodash/compact"; import compact from "lodash/compact";
import isNil from "lodash/isNil";
import uniq from "lodash/uniq"; import uniq from "lodash/uniq";
import randomstring from "randomstring"; import randomstring from "randomstring";
import type { SaveOptions } from "sequelize"; import type { SaveOptions } from "sequelize";
@@ -33,7 +34,6 @@ import {
import isUUID from "validator/lib/isUUID"; import isUUID from "validator/lib/isUUID";
import type { NavigationNode } from "@shared/types"; import type { NavigationNode } from "@shared/types";
import getTasks from "@shared/utils/getTasks"; import getTasks from "@shared/utils/getTasks";
import parseTitle from "@shared/utils/parseTitle";
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers"; import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
import { DocumentValidation } from "@shared/validations"; import { DocumentValidation } from "@shared/validations";
import slugify from "@server/utils/slugify"; import slugify from "@server/utils/slugify";
@@ -261,7 +261,7 @@ class Document extends ParanoidModel {
// hooks // hooks
@BeforeSave @BeforeSave
static async updateTitleInCollectionStructure( static async updateCollectionStructure(
model: Document, model: Document,
{ transaction }: SaveOptions<Document> { transaction }: SaveOptions<Document>
) { ) {
@@ -271,7 +271,7 @@ class Document extends ParanoidModel {
model.archivedAt || model.archivedAt ||
model.template || model.template ||
!model.publishedAt || !model.publishedAt ||
!model.changed("title") || !(model.changed("title") || model.changed("emoji")) ||
!model.collectionId !model.collectionId
) { ) {
return; return;
@@ -330,10 +330,6 @@ class Document extends ParanoidModel {
@BeforeUpdate @BeforeUpdate
static processUpdate(model: Document) { 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 // ensure documents have a title
model.title = model.title || ""; model.title = model.title || "";
@@ -795,6 +791,7 @@ class Document extends ParanoidModel {
id: this.id, id: this.id,
title: this.title, title: this.title,
url: this.url, url: this.url,
emoji: isNil(this.emoji) ? undefined : this.emoji,
children, children,
}; };
}; };

View File

@@ -49,6 +49,13 @@ class Revision extends IdModel {
@Column(DataType.TEXT) @Column(DataType.TEXT)
text: string; text: string;
@Length({
max: 1,
msg: `Emoji must be a single character`,
})
@Column
emoji: string | null;
// associations // associations
@BelongsTo(() => Document, "documentId") @BelongsTo(() => Document, "documentId")
@@ -65,6 +72,14 @@ class Revision extends IdModel {
@Column(DataType.UUID) @Column(DataType.UUID)
userId: string; 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) { static findLatest(documentId: string) {
return this.findOne({ return this.findOne({
where: { 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) { static buildFromDocument(document: Document) {
return this.build({ return this.build({
title: document.title, title: document.title,
text: document.text, text: document.text,
emoji: document.emoji,
userId: document.lastModifiedById, userId: document.lastModifiedById,
editorVersion: document.editorVersion, editorVersion: document.editorVersion,
version: document.version, 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( static createFromDocument(
document: Document, document: Document,
options?: SaveOptions<Revision> options?: SaveOptions<Revision>

View File

@@ -27,6 +27,7 @@ async function presentDocument(
url: document.url, url: document.url,
urlId: document.urlId, urlId: document.urlId,
title: document.title, title: document.title,
emoji: document.emoji,
text, text,
tasks: document.tasks, tasks: document.tasks,
createdAt: document.createdAt, createdAt: document.createdAt,

View File

@@ -1,13 +1,18 @@
import parseTitle from "@shared/utils/parseTitle";
import { traceFunction } from "@server/logging/tracing"; import { traceFunction } from "@server/logging/tracing";
import { Revision } from "@server/models"; import { Revision } from "@server/models";
import presentUser from "./user"; import presentUser from "./user";
async function presentRevision(revision: Revision, diff?: string) { async function presentRevision(revision: Revision, diff?: string) {
// TODO: Remove this fallback once all revisions have been migrated
const { emoji, strippedTitle } = parseTitle(revision.title);
return { return {
id: revision.id, id: revision.id,
documentId: revision.documentId, documentId: revision.documentId,
title: revision.title, title: strippedTitle,
text: revision.text, text: revision.text,
emoji: revision.emoji ?? emoji,
html: diff, html: diff,
createdAt: revision.createdAt, createdAt: revision.createdAt,
createdBy: presentUser(revision.user), createdBy: presentUser(revision.user),

View File

@@ -2492,6 +2492,7 @@ describe("#documents.update", () => {
const document = await buildDraftDocument({ const document = await buildDraftDocument({
teamId: team.id, teamId: team.id,
}); });
const res = await server.post("/api/documents.update", { const res = await server.post("/api/documents.update", {
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),
@@ -2503,6 +2504,7 @@ describe("#documents.update", () => {
}); });
const body = await res.json(); const body = await res.json();
expect(res.status).toEqual(400); expect(res.status).toEqual(400);
expect(body.message).toBe( expect(body.message).toBe(
"collectionId is required to publish a draft without collection" "collectionId is required to publish a draft without collection"
); );
@@ -2515,7 +2517,6 @@ describe("#documents.update", () => {
text: "text", text: "text",
teamId: team.id, teamId: team.id,
}); });
const res = await server.post("/api/documents.update", { const res = await server.post("/api/documents.update", {
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),
@@ -2551,6 +2552,36 @@ describe("#documents.update", () => {
expect(res.status).toEqual(403); 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 () => { it("should not add template to collection structure when publishing", async () => {
const user = await buildUser(); const user = await buildUser();
const collection = await buildCollection({ const collection = await buildCollection({

View File

@@ -861,6 +861,7 @@ router.post(
lastModifiedById: user.id, lastModifiedById: user.id,
createdById: user.id, createdById: user.id,
template: true, template: true,
emoji: original.emoji,
title: original.title, title: original.title,
text: original.text, text: original.text,
}); });

View File

@@ -1,3 +1,4 @@
import emojiRegex from "emoji-regex";
import isEmpty from "lodash/isEmpty"; import isEmpty from "lodash/isEmpty";
import isUUID from "validator/lib/isUUID"; import isUUID from "validator/lib/isUUID";
import { z } from "zod"; import { z } from "zod";
@@ -186,6 +187,9 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
/** Doc text to be updated */ /** Doc text to be updated */
text: z.string().optional(), 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 */ /** Boolean to denote if the doc should occupy full width */
fullWidth: z.boolean().optional(), fullWidth: z.boolean().optional(),

View File

@@ -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("🚵🏼‍♂️");
}
});
});

View File

@@ -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<void> => {
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);
}

View File

@@ -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<void> => {
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);
}

View File

@@ -377,6 +377,7 @@ export async function buildDocument(
publishedAt: isNull(overrides.collectionId) ? null : new Date(), publishedAt: isNull(overrides.collectionId) ? null : new Date(),
lastModifiedById: overrides.userId, lastModifiedById: overrides.userId,
createdById: overrides.userId, createdById: overrides.userId,
editorVersion: 2,
...overrides, ...overrides,
}, },
{ {

View File

@@ -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;
},
{}
);

View File

@@ -1,4 +1,3 @@
import nameToEmoji from "gemoji/name-to-emoji.json";
import Token from "markdown-it/lib/token"; import Token from "markdown-it/lib/token";
import { import {
NodeSpec, NodeSpec,
@@ -9,6 +8,7 @@ import {
import { Command, TextSelection } from "prosemirror-state"; import { Command, TextSelection } from "prosemirror-state";
import { Primitive } from "utility-types"; import { Primitive } from "utility-types";
import Suggestion from "../extensions/Suggestion"; import Suggestion from "../extensions/Suggestion";
import { nameToEmoji } from "../lib/emoji";
import { MarkdownSerializerState } from "../lib/markdown/serializer"; import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { SuggestionsMenuType } from "../plugins/Suggestions"; import { SuggestionsMenuType } from "../plugins/Suggestions";
import emojiRule from "../rules/emoji"; import emojiRule from "../rules/emoji";

View File

@@ -1,6 +1,6 @@
import nameToEmoji from "gemoji/name-to-emoji.json";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import emojiPlugin from "markdown-it-emoji"; import emojiPlugin from "markdown-it-emoji";
import { nameToEmoji } from "../lib/emoji";
export default function emoji(md: MarkdownIt) { export default function emoji(md: MarkdownIt) {
// Ideally this would be an empty object, but due to a bug in markdown-it-emoji // Ideally this would be an empty object, but due to a bug in markdown-it-emoji

View File

@@ -168,6 +168,8 @@
"Currently editing": "Currently editing", "Currently editing": "Currently editing",
"Currently viewing": "Currently viewing", "Currently viewing": "Currently viewing",
"Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago", "Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago",
"Emoji Picker": "Emoji Picker",
"Remove": "Remove",
"Module failed to load": "Module failed to load", "Module failed to load": "Module failed to load",
"Loading Failed": "Loading Failed", "Loading Failed": "Loading Failed",
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.", "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.",
@@ -242,6 +244,8 @@
"{{ releasesBehind }} versions behind_plural": "{{ releasesBehind }} versions behind", "{{ releasesBehind }} versions behind_plural": "{{ releasesBehind }} versions behind",
"Return to App": "Back to App", "Return to App": "Back to App",
"Installation": "Installation", "Installation": "Installation",
"Unstar document": "Unstar document",
"Star document": "Star document",
"No results": "No results", "No results": "No results",
"Previous page": "Previous page", "Previous page": "Previous page",
"Next page": "Next page", "Next page": "Next page",
@@ -358,7 +362,6 @@
"Show path to document": "Show path to document", "Show path to document": "Show path to document",
"Path to document": "Path to document", "Path to document": "Path to document",
"Group member options": "Group member options", "Group member options": "Group member options",
"Remove": "Remove",
"Export collection": "Export collection", "Export collection": "Export collection",
"Sort in sidebar": "Sort in sidebar", "Sort in sidebar": "Sort in sidebar",
"Alphabetical sort": "Alphabetical sort", "Alphabetical sort": "Alphabetical sort",

View File

@@ -37,3 +37,20 @@ export const hideScrollbars = () => `
display: none; 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;
}
`;

View File

@@ -159,6 +159,7 @@ export type NavigationNode = {
id: string; id: string;
title: string; title: string;
url: string; url: string;
emoji?: string;
children: NavigationNode[]; children: NavigationNode[];
isDraft?: boolean; isDraft?: boolean;
collectionId?: string; collectionId?: string;

View File

@@ -1 +0,0 @@
declare module "gemoji";

View File

@@ -1,5 +1,5 @@
import md5 from "crypto-js/md5"; import md5 from "crypto-js/md5";
import { darken } from "polished"; import { darken, parseToRgb } from "polished";
import theme from "../styles/theme"; import theme from "../styles/theme";
export const palette = [ export const palette = [
@@ -26,3 +26,12 @@ export const stringToColor = (input: string) => {
const inputAsNumber = parseInt(md5(input).toString(), 16); const inputAsNumber = parseInt(md5(input).toString(), 16);
return palette[inputAsNumber % palette.length]; 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(", ");

View File

@@ -10,7 +10,7 @@ export default function parseTitle(text = "") {
// find and extract first emoji // find and extract first emoji
const matches = regex.exec(title); const matches = regex.exec(title);
const firstEmoji = matches ? matches[0] : null; const firstEmoji = matches ? matches[0] : null;
const startsWithEmoji = firstEmoji && title.startsWith(`${firstEmoji} `); const startsWithEmoji = firstEmoji && title.startsWith(firstEmoji);
const emoji = startsWithEmoji ? firstEmoji : undefined; const emoji = startsWithEmoji ? firstEmoji : undefined;
// title with first leading emoji stripped // title with first leading emoji stripped

View File

@@ -1307,6 +1307,16 @@
dependencies: dependencies:
tslib "^2.0.0" 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": "@emotion/is-prop-valid@^0.8.2":
version "0.8.8" version "0.8.8"
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
@@ -5937,6 +5947,11 @@ emittery@^0.13.1:
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== 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: emoji-regex@*, emoji-regex@^10.2.1:
version "10.2.1" version "10.2.1"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.2.1.tgz#a41c330d957191efd3d9dfe6e1e8e1e9ab048b3f" 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" resolved "https://registry.yarnpkg.com/fuzzy-search/-/fuzzy-search-3.2.1.tgz#65d5faad6bc633aee86f1898b7788dfe312ac6c9"
integrity sha512-vAcPiyomt1ioKAsAL2uxSABHJ4Ju/e4UeDM+g1OlR0vV4YhLGMNsdLNvZTpEDY4JCSt0E4hASCNM5t2ETtsbyg== 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: gensync@^1.0.0-beta.2:
version "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" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"