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

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