Document emoji picker (#4338)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
@@ -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;
|
||||
`};
|
||||
|
||||
@@ -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}
|
||||
|
||||
353
app/scenes/Document/components/DocumentTitle.tsx
Normal file
353
app/scenes/Document/components/DocumentTitle.tsx
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user