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
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:

View File

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

View File

@@ -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<Props> = ({
path.forEach((node: NavigationNode) => {
output.push({
type: "route",
title: node.title,
title: node.emoji ? (
<>
<EmojiIcon emoji={node.emoji} /> {node.title}
</>
) : (
node.title
),
to: node.url,
});
});

View File

@@ -111,7 +111,7 @@ function DocumentCard(props: Props) {
{document.emoji ? (
<Squircle color={theme.slateLight}>
<EmojiIcon emoji={document.emoji} size={26} />
<EmojiIcon emoji={document.emoji} size={24} />
</Squircle>
) : (
<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 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 && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
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} />;
if (isCollection) {
const col = collections.get(node.collectionId as string);
icon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
title = node.title;
} 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)
.map((a) => parseTitle(a.title).strippedTitle)
.join(" / ");
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]}
/>
);
}
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 = () => {
inputSearchRef.current?.focus();

View File

@@ -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(
>
<Content>
<Heading dir={document.dir}>
{document.emoji && (
<>
<EmojiIcon emoji={document.emoji} size={24} />
&nbsp;
</>
)}
<Title
text={document.titleWithDefault}
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;
height: ${(props) => props.$size}px;
text-indent: -0.15em;
font-size: 14px;
font-size: ${(props) => props.$size - 10}px;
`;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}
/>
)}

View File

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

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

View File

@@ -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"
*/

View File

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

View File

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

View File

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

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

View File

@@ -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 ? (
<TableOfContentsMenu headings={headings} />
) : (
<DocumentBreadcrumb document={document}>{toc}</DocumentBreadcrumb>
<DocumentBreadcrumb document={document}>
{toc} <Star document={document} color={theme.textSecondary} />
</DocumentBreadcrumb>
)
}
title={
<>
{document.emoji && (
<>
<EmojiIcon size={24} emoji={document.emoji} />{" "}
</>
)}
{document.title}{" "}
{document.isArchived && (
<ArchivedBadge>{t("Archived")}</ArchivedBadge>

View File

@@ -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<Props> = ({
.slice(0, -1)
.map((item) => ({
...item,
title: item.emoji ? (
<>
<EmojiIcon emoji={item.emoji} /> {item.title}
</>
) : (
item.title
),
type: "route",
to: sharedDocumentPath(shareId, item.url),
})),

View File

@@ -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 (
<DocumentLink

View File

@@ -7,12 +7,15 @@ import { Props as EditorProps } from "~/components/Editor";
import Flex from "~/components/Flex";
import { documentPath } from "~/utils/routeHelpers";
import { Meta as DocumentMeta } from "./DocumentMeta";
import DocumentTitle from "./DocumentTitle";
type Props = Omit<EditorProps, "extensions"> & {
/** 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 (
<Flex auto column>
<h1 dir={revision.dir}>{revision.title}</h1>
<DocumentTitle
documentId={revision.documentId}
title={revision.title}
emoji={revision.emoji}
readOnly
/>
<DocumentMeta
document={document}
revision={revision}

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

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

View File

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

View File

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

View File

@@ -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),

View File

@@ -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({

View File

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

View File

@@ -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(),

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(),
lastModifiedById: overrides.userId,
createdById: overrides.userId,
editorVersion: 2,
...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 {
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";

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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