Document emoji picker (#4338)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
4
Makefile
4
Makefile
@@ -11,14 +11,14 @@ test:
|
|||||||
docker-compose up -d redis postgres s3
|
docker-compose up -d redis postgres s3
|
||||||
yarn sequelize db:drop --env=test
|
yarn sequelize db:drop --env=test
|
||||||
yarn sequelize db:create --env=test
|
yarn sequelize db:create --env=test
|
||||||
yarn sequelize db:migrate --env=test
|
NODE_ENV=test yarn sequelize db:migrate --env=test
|
||||||
yarn test
|
yarn test
|
||||||
|
|
||||||
watch:
|
watch:
|
||||||
docker-compose up -d redis postgres s3
|
docker-compose up -d redis postgres s3
|
||||||
yarn sequelize db:drop --env=test
|
yarn sequelize db:drop --env=test
|
||||||
yarn sequelize db:create --env=test
|
yarn sequelize db:create --env=test
|
||||||
yarn sequelize db:migrate --env=test
|
NODE_ENV=test yarn sequelize db:migrate --env=test
|
||||||
yarn test:watch
|
yarn test:watch
|
||||||
|
|
||||||
destroy:
|
destroy:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||||
onChange?: (text: string) => void;
|
onChange?: (text: string) => void;
|
||||||
|
onFocus?: React.FocusEventHandler<HTMLSpanElement> | undefined;
|
||||||
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
|
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
|
||||||
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
|
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
|
||||||
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
|
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
|
||||||
@@ -35,6 +36,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
|||||||
disabled,
|
disabled,
|
||||||
onChange,
|
onChange,
|
||||||
onInput,
|
onInput,
|
||||||
|
onFocus,
|
||||||
onBlur,
|
onBlur,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
value,
|
value,
|
||||||
@@ -143,11 +145,13 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} dir={dir} onClick={onClick}>
|
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
|
||||||
|
{children}
|
||||||
<Content
|
<Content
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
contentEditable={!disabled && !readOnly}
|
contentEditable={!disabled && !readOnly}
|
||||||
onInput={wrappedEvent(onInput)}
|
onInput={wrappedEvent(onInput)}
|
||||||
|
onFocus={wrappedEvent(onFocus)}
|
||||||
onBlur={wrappedEvent(onBlur)}
|
onBlur={wrappedEvent(onBlur)}
|
||||||
onKeyDown={wrappedEvent(onKeyDown)}
|
onKeyDown={wrappedEvent(onKeyDown)}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
@@ -158,7 +162,6 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
|||||||
>
|
>
|
||||||
{innerValue}
|
{innerValue}
|
||||||
</Content>
|
</Content>
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
templatesPath,
|
templatesPath,
|
||||||
trashPath,
|
trashPath,
|
||||||
} from "~/utils/routeHelpers";
|
} from "~/utils/routeHelpers";
|
||||||
|
import EmojiIcon from "./Icons/EmojiIcon";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -105,7 +106,13 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
|||||||
path.forEach((node: NavigationNode) => {
|
path.forEach((node: NavigationNode) => {
|
||||||
output.push({
|
output.push({
|
||||||
type: "route",
|
type: "route",
|
||||||
title: node.title,
|
title: node.emoji ? (
|
||||||
|
<>
|
||||||
|
<EmojiIcon emoji={node.emoji} /> {node.title}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
node.title
|
||||||
|
),
|
||||||
to: node.url,
|
to: node.url,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ function DocumentCard(props: Props) {
|
|||||||
|
|
||||||
{document.emoji ? (
|
{document.emoji ? (
|
||||||
<Squircle color={theme.slateLight}>
|
<Squircle color={theme.slateLight}>
|
||||||
<EmojiIcon emoji={document.emoji} size={26} />
|
<EmojiIcon emoji={document.emoji} size={24} />
|
||||||
</Squircle>
|
</Squircle>
|
||||||
) : (
|
) : (
|
||||||
<Squircle color={collection?.color}>
|
<Squircle color={collection?.color}>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
|||||||
import styled, { useTheme } from "styled-components";
|
import styled, { useTheme } from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import { NavigationNode } from "@shared/types";
|
import { NavigationNode } from "@shared/types";
|
||||||
import parseTitle from "@shared/utils/parseTitle";
|
|
||||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
@@ -205,84 +204,86 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ListItem = ({
|
const ListItem = observer(
|
||||||
index,
|
({
|
||||||
data,
|
index,
|
||||||
style,
|
data,
|
||||||
}: {
|
style,
|
||||||
index: number;
|
}: {
|
||||||
data: NavigationNode[];
|
index: number;
|
||||||
style: React.CSSProperties;
|
data: NavigationNode[];
|
||||||
}) => {
|
style: React.CSSProperties;
|
||||||
const node = data[index];
|
}) => {
|
||||||
const isCollection = node.type === "collection";
|
const node = data[index];
|
||||||
let icon, title, path;
|
const isCollection = node.type === "collection";
|
||||||
|
let icon, title: string, emoji: string | undefined, path;
|
||||||
|
|
||||||
if (isCollection) {
|
if (isCollection) {
|
||||||
const col = collections.get(node.collectionId as string);
|
const col = collections.get(node.collectionId as string);
|
||||||
icon = col && (
|
icon = col && (
|
||||||
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
||||||
);
|
);
|
||||||
title = node.title;
|
title = node.title;
|
||||||
} else {
|
|
||||||
const doc = documents.get(node.id);
|
|
||||||
const { strippedTitle, emoji } = parseTitle(node.title);
|
|
||||||
title = strippedTitle;
|
|
||||||
|
|
||||||
if (emoji) {
|
|
||||||
icon = <EmojiIcon emoji={emoji} />;
|
|
||||||
} else if (doc?.isStarred) {
|
|
||||||
icon = <StarredIcon color={theme.yellow} />;
|
|
||||||
} else {
|
} else {
|
||||||
icon = <DocumentIcon color={theme.textSecondary} />;
|
const doc = documents.get(node.id);
|
||||||
|
emoji = doc?.emoji ?? node.emoji;
|
||||||
|
title = doc?.title ?? node.title;
|
||||||
|
|
||||||
|
if (emoji) {
|
||||||
|
icon = <EmojiIcon emoji={emoji} />;
|
||||||
|
} else if (doc?.isStarred) {
|
||||||
|
icon = <StarredIcon color={theme.yellow} />;
|
||||||
|
} else {
|
||||||
|
icon = <DocumentIcon color={theme.textSecondary} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
path = ancestors(node)
|
||||||
|
.map((a) => a.title)
|
||||||
|
.join(" / ");
|
||||||
}
|
}
|
||||||
|
|
||||||
path = ancestors(node)
|
return searchTerm ? (
|
||||||
.map((a) => parseTitle(a.title).strippedTitle)
|
<DocumentExplorerSearchResult
|
||||||
.join(" / ");
|
selected={isSelected(index)}
|
||||||
|
active={activeNode === index}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
top: (style.top as number) + VERTICAL_PADDING,
|
||||||
|
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||||
|
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||||
|
}}
|
||||||
|
onPointerMove={() => setActiveNode(index)}
|
||||||
|
onClick={() => toggleSelect(index)}
|
||||||
|
icon={icon}
|
||||||
|
title={title}
|
||||||
|
path={path}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DocumentExplorerNode
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
top: (style.top as number) + VERTICAL_PADDING,
|
||||||
|
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||||
|
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||||
|
}}
|
||||||
|
onPointerMove={() => setActiveNode(index)}
|
||||||
|
onClick={() => toggleSelect(index)}
|
||||||
|
onDisclosureClick={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
toggleCollapse(index);
|
||||||
|
}}
|
||||||
|
selected={isSelected(index)}
|
||||||
|
active={activeNode === index}
|
||||||
|
expanded={isExpanded(index)}
|
||||||
|
icon={icon}
|
||||||
|
title={title}
|
||||||
|
depth={node.depth as number}
|
||||||
|
hasChildren={hasChildren(index)}
|
||||||
|
ref={itemRefs[index]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
return searchTerm ? (
|
|
||||||
<DocumentExplorerSearchResult
|
|
||||||
selected={isSelected(index)}
|
|
||||||
active={activeNode === index}
|
|
||||||
style={{
|
|
||||||
...style,
|
|
||||||
top: (style.top as number) + VERTICAL_PADDING,
|
|
||||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
|
||||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
|
||||||
}}
|
|
||||||
onPointerMove={() => setActiveNode(index)}
|
|
||||||
onClick={() => toggleSelect(index)}
|
|
||||||
icon={icon}
|
|
||||||
title={title}
|
|
||||||
path={path}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DocumentExplorerNode
|
|
||||||
style={{
|
|
||||||
...style,
|
|
||||||
top: (style.top as number) + VERTICAL_PADDING,
|
|
||||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
|
||||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
|
||||||
}}
|
|
||||||
onPointerMove={() => setActiveNode(index)}
|
|
||||||
onClick={() => toggleSelect(index)}
|
|
||||||
onDisclosureClick={(ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
toggleCollapse(index);
|
|
||||||
}}
|
|
||||||
selected={isSelected(index)}
|
|
||||||
active={activeNode === index}
|
|
||||||
expanded={isExpanded(index)}
|
|
||||||
icon={icon}
|
|
||||||
title={title}
|
|
||||||
depth={node.depth as number}
|
|
||||||
hasChildren={hasChildren(index)}
|
|
||||||
ref={itemRefs[index]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const focusSearchInput = () => {
|
const focusSearchInput = () => {
|
||||||
inputSearchRef.current?.focus();
|
inputSearchRef.current?.focus();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import usePolicy from "~/hooks/usePolicy";
|
|||||||
import DocumentMenu from "~/menus/DocumentMenu";
|
import DocumentMenu from "~/menus/DocumentMenu";
|
||||||
import { hover } from "~/styles";
|
import { hover } from "~/styles";
|
||||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||||
|
import EmojiIcon from "./Icons/EmojiIcon";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
document: Document;
|
document: Document;
|
||||||
@@ -92,6 +93,12 @@ function DocumentListItem(
|
|||||||
>
|
>
|
||||||
<Content>
|
<Content>
|
||||||
<Heading dir={document.dir}>
|
<Heading dir={document.dir}>
|
||||||
|
{document.emoji && (
|
||||||
|
<>
|
||||||
|
<EmojiIcon emoji={document.emoji} size={24} />
|
||||||
|
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Title
|
<Title
|
||||||
text={document.titleWithDefault}
|
text={document.titleWithDefault}
|
||||||
highlight={highlight}
|
highlight={highlight}
|
||||||
|
|||||||
23
app/components/EmojiPicker/components.tsx
Normal file
23
app/components/EmojiPicker/components.tsx
Normal 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` : "")}
|
||||||
|
`;
|
||||||
269
app/components/EmojiPicker/index.tsx
Normal file
269
app/components/EmojiPicker/index.tsx
Normal 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;
|
||||||
@@ -29,5 +29,5 @@ const Span = styled.span<{ $size: number }>`
|
|||||||
width: ${(props) => props.$size}px;
|
width: ${(props) => props.$size}px;
|
||||||
height: ${(props) => props.$size}px;
|
height: ${(props) => props.$size}px;
|
||||||
text-indent: -0.15em;
|
text-indent: -0.15em;
|
||||||
font-size: 14px;
|
font-size: ${(props) => props.$size - 10}px;
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ function InnerDocumentLink(
|
|||||||
starred: inStarredSection,
|
starred: inStarredSection,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
emoji={document?.emoji || node.emoji}
|
||||||
label={
|
label={
|
||||||
<EditableTitle
|
<EditableTitle
|
||||||
title={title}
|
title={title}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import Document from "~/models/Document";
|
|||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||||
import { descendants } from "~/utils/tree";
|
import { descendants } from "~/utils/tree";
|
||||||
import Disclosure from "./Disclosure";
|
|
||||||
import SidebarLink from "./SidebarLink";
|
import SidebarLink from "./SidebarLink";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -110,14 +109,10 @@ function DocumentLink(
|
|||||||
title: node.title,
|
title: node.title,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
label={
|
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
|
||||||
<>
|
onDisclosureClick={handleDisclosureClick}
|
||||||
{hasChildDocuments && depth !== 0 && (
|
emoji={node.emoji}
|
||||||
<Disclosure expanded={expanded} onClick={handleDisclosureClick} />
|
label={title}
|
||||||
)}
|
|
||||||
{title}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
depth={depth}
|
depth={depth}
|
||||||
exact={false}
|
exact={false}
|
||||||
scrollIntoViewIfNeeded={!document?.isStarred}
|
scrollIntoViewIfNeeded={!document?.isStarred}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import breakpoint from "styled-components-breakpoint";
|
|||||||
import { s } from "@shared/styles";
|
import { s } from "@shared/styles";
|
||||||
import { NavigationNode } from "@shared/types";
|
import { NavigationNode } from "@shared/types";
|
||||||
import EventBoundary from "~/components/EventBoundary";
|
import EventBoundary from "~/components/EventBoundary";
|
||||||
|
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
import useUnmount from "~/hooks/useUnmount";
|
import useUnmount from "~/hooks/useUnmount";
|
||||||
import { undraggableOnDesktop } from "~/styles";
|
import { undraggableOnDesktop } from "~/styles";
|
||||||
@@ -25,6 +26,7 @@ type Props = Omit<NavLinkProps, "to"> & {
|
|||||||
onClickIntent?: () => void;
|
onClickIntent?: () => void;
|
||||||
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
|
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
|
emoji?: string | null;
|
||||||
label?: React.ReactNode;
|
label?: React.ReactNode;
|
||||||
menu?: React.ReactNode;
|
menu?: React.ReactNode;
|
||||||
showActions?: boolean;
|
showActions?: boolean;
|
||||||
@@ -48,6 +50,7 @@ function SidebarLink(
|
|||||||
onClick,
|
onClick,
|
||||||
onClickIntent,
|
onClickIntent,
|
||||||
to,
|
to,
|
||||||
|
emoji,
|
||||||
label,
|
label,
|
||||||
active,
|
active,
|
||||||
isActiveDrop,
|
isActiveDrop,
|
||||||
@@ -136,6 +139,7 @@ function SidebarLink(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||||
|
{emoji && <EmojiIcon emoji={emoji} />}
|
||||||
<Label>{label}</Label>
|
<Label>{label}</Label>
|
||||||
</Content>
|
</Content>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -152,6 +156,7 @@ const Content = styled.span`
|
|||||||
|
|
||||||
${Disclosure} {
|
${Disclosure} {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
|
margin-left: 2px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useDrag, useDrop } from "react-dnd";
|
|||||||
import { getEmptyImage } from "react-dnd-html5-backend";
|
import { getEmptyImage } from "react-dnd-html5-backend";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import styled, { useTheme } from "styled-components";
|
import styled, { useTheme } from "styled-components";
|
||||||
import parseTitle from "@shared/utils/parseTitle";
|
|
||||||
import Star from "~/models/Star";
|
import Star from "~/models/Star";
|
||||||
import Fade from "~/components/Fade";
|
import Fade from "~/components/Fade";
|
||||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||||
@@ -42,14 +41,10 @@ function useLabelAndIcon({ documentId, collectionId }: Star) {
|
|||||||
if (documentId) {
|
if (documentId) {
|
||||||
const document = documents.get(documentId);
|
const document = documents.get(documentId);
|
||||||
if (document) {
|
if (document) {
|
||||||
const { emoji } = parseTitle(document?.title);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: emoji
|
label: document.titleWithDefault,
|
||||||
? document.title.replace(emoji, "")
|
icon: document.emoji ? (
|
||||||
: document.titleWithDefault,
|
<EmojiIcon emoji={document.emoji} />
|
||||||
icon: emoji ? (
|
|
||||||
<EmojiIcon emoji={emoji} />
|
|
||||||
) : (
|
) : (
|
||||||
<StarredIcon color={theme.yellow} />
|
<StarredIcon color={theme.yellow} />
|
||||||
),
|
),
|
||||||
@@ -148,6 +143,10 @@ function StarredLink({ star }: Props) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { emoji } = document;
|
||||||
|
const label = emoji
|
||||||
|
? document.title.replace(emoji, "")
|
||||||
|
: document.titleWithDefault;
|
||||||
const collection = document.collectionId
|
const collection = document.collectionId
|
||||||
? collections.get(document.collectionId)
|
? collections.get(document.collectionId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { StarredIcon, UnstarredIcon } from "outline-icons";
|
import { StarredIcon, UnstarredIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import styled, { useTheme } from "styled-components";
|
import styled, { useTheme } from "styled-components";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
@@ -14,12 +15,18 @@ import { hover } from "~/styles";
|
|||||||
import NudeButton from "./NudeButton";
|
import NudeButton from "./NudeButton";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
/** Target collection */
|
||||||
collection?: Collection;
|
collection?: Collection;
|
||||||
|
/** Target document */
|
||||||
document?: Document;
|
document?: Document;
|
||||||
|
/** Size of the star */
|
||||||
size?: number;
|
size?: number;
|
||||||
|
/** Color override for the star */
|
||||||
|
color?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function Star({ size, document, collection, ...rest }: Props) {
|
function Star({ size, document, collection, color, ...rest }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const context = useActionContext({
|
const context = useActionContext({
|
||||||
activeDocumentId: document?.id,
|
activeDocumentId: document?.id,
|
||||||
@@ -36,6 +43,10 @@ function Star({ size, document, collection, ...rest }: Props) {
|
|||||||
<NudeButton
|
<NudeButton
|
||||||
context={context}
|
context={context}
|
||||||
hideOnActionDisabled
|
hideOnActionDisabled
|
||||||
|
tooltip={{
|
||||||
|
tooltip: target.isStarred ? t("Unstar document") : t("Star document"),
|
||||||
|
delay: 500,
|
||||||
|
}}
|
||||||
action={
|
action={
|
||||||
collection
|
collection
|
||||||
? collection.isStarred
|
? collection.isStarred
|
||||||
@@ -55,7 +66,7 @@ function Star({ size, document, collection, ...rest }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<AnimatedStar
|
<AnimatedStar
|
||||||
size={size}
|
size={size}
|
||||||
color={theme.textTertiary}
|
color={color ?? theme.textTertiary}
|
||||||
as={UnstarredIcon}
|
as={UnstarredIcon}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import data, { type Emoji as TEmoji, EmojiMartData } from "@emoji-mart/data";
|
||||||
import FuzzySearch from "fuzzy-search";
|
import FuzzySearch from "fuzzy-search";
|
||||||
import gemojies from "gemoji";
|
import capitalize from "lodash/capitalize";
|
||||||
|
import snakeCase from "lodash/snakeCase";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import EmojiMenuItem from "./EmojiMenuItem";
|
import EmojiMenuItem from "./EmojiMenuItem";
|
||||||
import SuggestionsMenu, {
|
import SuggestionsMenu, {
|
||||||
@@ -14,14 +16,14 @@ type Emoji = {
|
|||||||
attrs: { markup: string; "data-name": string };
|
attrs: { markup: string; "data-name": string };
|
||||||
};
|
};
|
||||||
|
|
||||||
const searcher = new FuzzySearch<{
|
const searcher = new FuzzySearch<TEmoji>(
|
||||||
names: string[];
|
Object.values((data as EmojiMartData).emojis),
|
||||||
description: string;
|
["keywords"],
|
||||||
emoji: string;
|
{
|
||||||
}>(gemojies, ["names"], {
|
caseSensitive: true,
|
||||||
caseSensitive: true,
|
sort: true,
|
||||||
sort: true,
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
type Props = Omit<
|
type Props = Omit<
|
||||||
SuggestionsMenuProps<Emoji>,
|
SuggestionsMenuProps<Emoji>,
|
||||||
@@ -34,14 +36,17 @@ const EmojiMenu = (props: Props) => {
|
|||||||
const items = React.useMemo(() => {
|
const items = React.useMemo(() => {
|
||||||
const n = search.toLowerCase();
|
const n = search.toLowerCase();
|
||||||
const result = searcher.search(n).map((item) => {
|
const result = searcher.search(n).map((item) => {
|
||||||
const description = item.description;
|
// We snake_case the shortcode for backwards compatability with gemoji to
|
||||||
const name = item.names[0];
|
// avoid multiple formats being written into documents.
|
||||||
|
const shortcode = snakeCase(item.id);
|
||||||
|
const emoji = item.skins[0].native;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
|
||||||
name: "emoji",
|
name: "emoji",
|
||||||
title: name,
|
title: emoji,
|
||||||
description,
|
description: capitalize(item.name.toLowerCase()),
|
||||||
attrs: { markup: name, "data-name": name },
|
emoji,
|
||||||
|
attrs: { markup: shortcode, "data-name": shortcode },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import useStores from "./useStores";
|
import useStores from "./useStores";
|
||||||
|
|
||||||
export default function useUserLocale() {
|
/**
|
||||||
|
* Returns the user's locale, or undefined if the user is not logged in.
|
||||||
|
*
|
||||||
|
* @param languageCode Whether to only return the language code
|
||||||
|
* @returns The user's locale, or undefined if the user is not logged in
|
||||||
|
*/
|
||||||
|
export default function useUserLocale(languageCode?: boolean) {
|
||||||
const { auth } = useStores();
|
const { auth } = useStores();
|
||||||
|
|
||||||
if (!auth.user || !auth.user.language) {
|
if (!auth.user?.language) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return auth.user.language;
|
const { language } = auth.user;
|
||||||
|
return languageCode ? language.split("_")[0] : language;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { action, autorun, computed, observable, set } from "mobx";
|
|||||||
import { ExportContentType } from "@shared/types";
|
import { ExportContentType } from "@shared/types";
|
||||||
import type { NavigationNode } from "@shared/types";
|
import type { NavigationNode } from "@shared/types";
|
||||||
import Storage from "@shared/utils/Storage";
|
import Storage from "@shared/utils/Storage";
|
||||||
import parseTitle from "@shared/utils/parseTitle";
|
|
||||||
import { isRTL } from "@shared/utils/rtl";
|
import { isRTL } from "@shared/utils/rtl";
|
||||||
import DocumentsStore from "~/stores/DocumentsStore";
|
import DocumentsStore from "~/stores/DocumentsStore";
|
||||||
import User from "~/models/User";
|
import User from "~/models/User";
|
||||||
@@ -68,6 +67,13 @@ export default class Document extends ParanoidModel {
|
|||||||
@observable
|
@observable
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An emoji to use as the document icon.
|
||||||
|
*/
|
||||||
|
@Field
|
||||||
|
@observable
|
||||||
|
emoji: string | undefined | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this is a template.
|
* Whether this is a template.
|
||||||
*/
|
*/
|
||||||
@@ -127,12 +133,6 @@ export default class Document extends ParanoidModel {
|
|||||||
|
|
||||||
revision: number;
|
revision: number;
|
||||||
|
|
||||||
@computed
|
|
||||||
get emoji() {
|
|
||||||
const { emoji } = parseTitle(this.title);
|
|
||||||
return emoji;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the direction of the document text, either "rtl" or "ltr"
|
* Returns the direction of the document text, either "rtl" or "ltr"
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ class Revision extends BaseModel {
|
|||||||
/** Markdown string of the content when revision was created */
|
/** Markdown string of the content when revision was created */
|
||||||
text: string;
|
text: string;
|
||||||
|
|
||||||
|
/** The emoji of the document when the revision was created */
|
||||||
|
emoji: string | null;
|
||||||
|
|
||||||
/** HTML string representing the revision as a diff from the previous version */
|
/** HTML string representing the revision as a diff from the previous version */
|
||||||
html: string;
|
html: string;
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
|
|||||||
import PinnedDocuments from "~/components/PinnedDocuments";
|
import PinnedDocuments from "~/components/PinnedDocuments";
|
||||||
import PlaceholderText from "~/components/PlaceholderText";
|
import PlaceholderText from "~/components/PlaceholderText";
|
||||||
import Scene from "~/components/Scene";
|
import Scene from "~/components/Scene";
|
||||||
import Star, { AnimatedStar } from "~/components/Star";
|
|
||||||
import Tab from "~/components/Tab";
|
import Tab from "~/components/Tab";
|
||||||
import Tabs from "~/components/Tabs";
|
import Tabs from "~/components/Tabs";
|
||||||
import Tooltip from "~/components/Tooltip";
|
import Tooltip from "~/components/Tooltip";
|
||||||
@@ -157,7 +156,7 @@ function CollectionScene() {
|
|||||||
<Empty collection={collection} />
|
<Empty collection={collection} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<HeadingWithIcon $isStarred={collection.isStarred}>
|
<HeadingWithIcon>
|
||||||
<HeadingIcon collection={collection} size={40} expanded />
|
<HeadingIcon collection={collection} size={40} expanded />
|
||||||
{collection.name}
|
{collection.name}
|
||||||
{collection.isPrivate && (
|
{collection.isPrivate && (
|
||||||
@@ -170,7 +169,6 @@ function CollectionScene() {
|
|||||||
<Badge>{t("Private")}</Badge>
|
<Badge>{t("Private")}</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<StarButton collection={collection} size={32} />
|
|
||||||
</HeadingWithIcon>
|
</HeadingWithIcon>
|
||||||
<CollectionDescription collection={collection} />
|
<CollectionDescription collection={collection} />
|
||||||
|
|
||||||
@@ -285,42 +283,15 @@ function CollectionScene() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const StarButton = styled(Star)`
|
|
||||||
position: relative;
|
|
||||||
top: 0;
|
|
||||||
left: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 24px;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
position: relative;
|
|
||||||
left: -4px;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Documents = styled.div`
|
const Documents = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
background: ${s("background")};
|
background: ${s("background")};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const HeadingWithIcon = styled(Heading)<{ $isStarred: boolean }>`
|
const HeadingWithIcon = styled(Heading)`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
${AnimatedStar} {
|
|
||||||
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
${AnimatedStar} {
|
|
||||||
opacity: 0.5;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
${breakpoint("tablet")`
|
||||||
margin-left: -40px;
|
margin-left: -40px;
|
||||||
`};
|
`};
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
this.isUploading = false;
|
this.isUploading = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
onChange = (getEditorText: () => string) => {
|
handleChange = (getEditorText: () => string) => {
|
||||||
const { document } = this.props;
|
const { document } = this.props;
|
||||||
this.getEditorText = getEditorText;
|
this.getEditorText = getEditorText;
|
||||||
|
|
||||||
@@ -369,13 +369,19 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
this.headings = headings;
|
this.headings = headings;
|
||||||
};
|
};
|
||||||
|
|
||||||
onChangeTitle = action((value: string) => {
|
handleChangeTitle = action((value: string) => {
|
||||||
this.title = value;
|
this.title = value;
|
||||||
this.props.document.title = value;
|
this.props.document.title = value;
|
||||||
this.updateIsDirty();
|
this.updateIsDirty();
|
||||||
void this.autosave();
|
void this.autosave();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
handleChangeEmoji = action((value: string) => {
|
||||||
|
this.props.document.emoji = value;
|
||||||
|
this.updateIsDirty();
|
||||||
|
void this.autosave();
|
||||||
|
});
|
||||||
|
|
||||||
goBack = () => {
|
goBack = () => {
|
||||||
if (!this.props.readOnly) {
|
if (!this.props.readOnly) {
|
||||||
this.props.history.push(this.props.document.url);
|
this.props.history.push(this.props.document.url);
|
||||||
@@ -482,7 +488,6 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
<Flex auto={!readOnly} reverse>
|
<Flex auto={!readOnly} reverse>
|
||||||
{revision ? (
|
{revision ? (
|
||||||
<RevisionViewer
|
<RevisionViewer
|
||||||
isDraft={document.isDraft}
|
|
||||||
document={document}
|
document={document}
|
||||||
revision={revision}
|
revision={revision}
|
||||||
id={revision.id}
|
id={revision.id}
|
||||||
@@ -506,8 +511,9 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
onFileUploadStop={this.onFileUploadStop}
|
onFileUploadStop={this.onFileUploadStop}
|
||||||
onSearchLink={this.props.onSearchLink}
|
onSearchLink={this.props.onSearchLink}
|
||||||
onCreateLink={this.props.onCreateLink}
|
onCreateLink={this.props.onCreateLink}
|
||||||
onChangeTitle={this.onChangeTitle}
|
onChangeTitle={this.handleChangeTitle}
|
||||||
onChange={this.onChange}
|
onChangeEmoji={this.handleChangeEmoji}
|
||||||
|
onChange={this.handleChange}
|
||||||
onHeadingsChange={this.onHeadingsChange}
|
onHeadingsChange={this.onHeadingsChange}
|
||||||
onSave={this.onSave}
|
onSave={this.onSave}
|
||||||
onPublish={this.onPublish}
|
onPublish={this.onPublish}
|
||||||
|
|||||||
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 { useDocumentContext } from "../../../components/DocumentContext";
|
||||||
import MultiplayerEditor from "./AsyncMultiplayerEditor";
|
import MultiplayerEditor from "./AsyncMultiplayerEditor";
|
||||||
import DocumentMeta from "./DocumentMeta";
|
import DocumentMeta from "./DocumentMeta";
|
||||||
import EditableTitle from "./EditableTitle";
|
import DocumentTitle from "./DocumentTitle";
|
||||||
|
|
||||||
const extensions = withComments(richExtensions);
|
const extensions = withComments(richExtensions);
|
||||||
|
|
||||||
type Props = Omit<EditorProps, "extensions" | "editorStyle"> & {
|
type Props = Omit<EditorProps, "extensions" | "editorStyle"> & {
|
||||||
onChangeTitle: (text: string) => void;
|
onChangeTitle: (title: string) => void;
|
||||||
|
onChangeEmoji: (emoji: string | null) => void;
|
||||||
id: string;
|
id: string;
|
||||||
document: Document;
|
document: Document;
|
||||||
isDraft: boolean;
|
isDraft: boolean;
|
||||||
@@ -56,6 +57,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
|||||||
const {
|
const {
|
||||||
document,
|
document,
|
||||||
onChangeTitle,
|
onChangeTitle,
|
||||||
|
onChangeEmoji,
|
||||||
isDraft,
|
isDraft,
|
||||||
shareId,
|
shareId,
|
||||||
readOnly,
|
readOnly,
|
||||||
@@ -151,14 +153,20 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex auto column>
|
<Flex auto column>
|
||||||
<EditableTitle
|
<DocumentTitle
|
||||||
ref={titleRef}
|
ref={titleRef}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
document={document}
|
documentId={document.id}
|
||||||
|
title={
|
||||||
|
!document.title && readOnly
|
||||||
|
? document.titleWithDefault
|
||||||
|
: document.title
|
||||||
|
}
|
||||||
|
emoji={document.emoji}
|
||||||
|
onChangeTitle={onChangeTitle}
|
||||||
|
onChangeEmoji={onChangeEmoji}
|
||||||
onGoToNextInput={handleGoToNextInput}
|
onGoToNextInput={handleGoToNextInput}
|
||||||
onChange={onChangeTitle}
|
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
starrable={!shareId}
|
|
||||||
placeholder={t("Untitled")}
|
placeholder={t("Untitled")}
|
||||||
/>
|
/>
|
||||||
{!shareId && (
|
{!shareId && (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled, { useTheme } from "styled-components";
|
||||||
import { NavigationNode } from "@shared/types";
|
import { NavigationNode } from "@shared/types";
|
||||||
import { Theme } from "~/stores/UiStore";
|
import { Theme } from "~/stores/UiStore";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
@@ -21,6 +21,8 @@ import Button from "~/components/Button";
|
|||||||
import Collaborators from "~/components/Collaborators";
|
import Collaborators from "~/components/Collaborators";
|
||||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||||
import Header from "~/components/Header";
|
import Header from "~/components/Header";
|
||||||
|
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||||
|
import Star from "~/components/Star";
|
||||||
import Tooltip from "~/components/Tooltip";
|
import Tooltip from "~/components/Tooltip";
|
||||||
import { publishDocument } from "~/actions/definitions/documents";
|
import { publishDocument } from "~/actions/definitions/documents";
|
||||||
import { restoreRevision } from "~/actions/definitions/revisions";
|
import { restoreRevision } from "~/actions/definitions/revisions";
|
||||||
@@ -81,6 +83,7 @@ function DocumentHeader({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { ui, auth } = useStores();
|
const { ui, auth } = useStores();
|
||||||
|
const theme = useTheme();
|
||||||
const { resolvedTheme } = ui;
|
const { resolvedTheme } = ui;
|
||||||
const { team } = auth;
|
const { team } = auth;
|
||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
@@ -199,11 +202,18 @@ function DocumentHeader({
|
|||||||
isMobile ? (
|
isMobile ? (
|
||||||
<TableOfContentsMenu headings={headings} />
|
<TableOfContentsMenu headings={headings} />
|
||||||
) : (
|
) : (
|
||||||
<DocumentBreadcrumb document={document}>{toc}</DocumentBreadcrumb>
|
<DocumentBreadcrumb document={document}>
|
||||||
|
{toc} <Star document={document} color={theme.textSecondary} />
|
||||||
|
</DocumentBreadcrumb>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
<>
|
<>
|
||||||
|
{document.emoji && (
|
||||||
|
<>
|
||||||
|
<EmojiIcon size={24} emoji={document.emoji} />{" "}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{document.title}{" "}
|
{document.title}{" "}
|
||||||
{document.isArchived && (
|
{document.isArchived && (
|
||||||
<ArchivedBadge>{t("Archived")}</ArchivedBadge>
|
<ArchivedBadge>{t("Archived")}</ArchivedBadge>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { NavigationNode } from "@shared/types";
|
import { NavigationNode } from "@shared/types";
|
||||||
import Breadcrumb from "~/components/Breadcrumb";
|
import Breadcrumb from "~/components/Breadcrumb";
|
||||||
|
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||||
import { MenuInternalLink } from "~/types";
|
import { MenuInternalLink } from "~/types";
|
||||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||||
|
|
||||||
@@ -52,6 +53,13 @@ const PublicBreadcrumb: React.FC<Props> = ({
|
|||||||
.slice(0, -1)
|
.slice(0, -1)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
title: item.emoji ? (
|
||||||
|
<>
|
||||||
|
<EmojiIcon emoji={item.emoji} /> {item.title}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
item.title
|
||||||
|
),
|
||||||
type: "route",
|
type: "route",
|
||||||
to: sharedDocumentPath(shareId, item.url),
|
to: sharedDocumentPath(shareId, item.url),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Link } from "react-router-dom";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { s, ellipsis } from "@shared/styles";
|
import { s, ellipsis } from "@shared/styles";
|
||||||
import { NavigationNode } from "@shared/types";
|
import { NavigationNode } from "@shared/types";
|
||||||
import parseTitle from "@shared/utils/parseTitle";
|
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||||
@@ -59,7 +58,7 @@ function ReferenceListItem({
|
|||||||
shareId,
|
shareId,
|
||||||
...rest
|
...rest
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { emoji } = parseTitle(document.title);
|
const { emoji } = document;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentLink
|
<DocumentLink
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import { Props as EditorProps } from "~/components/Editor";
|
|||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import { documentPath } from "~/utils/routeHelpers";
|
import { documentPath } from "~/utils/routeHelpers";
|
||||||
import { Meta as DocumentMeta } from "./DocumentMeta";
|
import { Meta as DocumentMeta } from "./DocumentMeta";
|
||||||
|
import DocumentTitle from "./DocumentTitle";
|
||||||
|
|
||||||
type Props = Omit<EditorProps, "extensions"> & {
|
type Props = Omit<EditorProps, "extensions"> & {
|
||||||
|
/** The ID of the revision */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** The current document */
|
||||||
document: Document;
|
document: Document;
|
||||||
|
/** The revision to display */
|
||||||
revision: Revision;
|
revision: Revision;
|
||||||
isDraft: boolean;
|
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,7 +27,12 @@ function RevisionViewer(props: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex auto column>
|
<Flex auto column>
|
||||||
<h1 dir={revision.dir}>{revision.title}</h1>
|
<DocumentTitle
|
||||||
|
documentId={revision.documentId}
|
||||||
|
title={revision.title}
|
||||||
|
emoji={revision.emoji}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
<DocumentMeta
|
<DocumentMeta
|
||||||
document={document}
|
document={document}
|
||||||
revision={revision}
|
revision={revision}
|
||||||
|
|||||||
@@ -661,7 +661,8 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
async update(
|
async update(
|
||||||
params: {
|
params: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title?: string;
|
||||||
|
emoji?: string | null;
|
||||||
text?: string;
|
text?: string;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
templateId?: string;
|
templateId?: string;
|
||||||
|
|||||||
14
app/types.ts
14
app/types.ts
@@ -141,6 +141,20 @@ export type FetchOptions = {
|
|||||||
force?: boolean;
|
force?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NavigationNode = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
emoji?: string | null;
|
||||||
|
url: string;
|
||||||
|
children: NavigationNode[];
|
||||||
|
isDraft?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CollectionSort = {
|
||||||
|
field: string;
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
};
|
||||||
|
|
||||||
// Pagination response in an API call
|
// Pagination response in an API call
|
||||||
export type Pagination = {
|
export type Pagination = {
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|||||||
5
app/typings/index.d.ts
vendored
5
app/typings/index.d.ts
vendored
@@ -1,5 +1,9 @@
|
|||||||
declare module "autotrack/autotrack.js";
|
declare module "autotrack/autotrack.js";
|
||||||
|
|
||||||
|
declare module "emoji-mart";
|
||||||
|
|
||||||
|
declare module "@emoji-mart/react";
|
||||||
|
|
||||||
declare module "string-replace-to-array";
|
declare module "string-replace-to-array";
|
||||||
|
|
||||||
declare module "sequelize-encrypted";
|
declare module "sequelize-encrypted";
|
||||||
@@ -16,5 +20,6 @@ declare module "*.png" {
|
|||||||
declare namespace JSX {
|
declare namespace JSX {
|
||||||
interface IntrinsicElements {
|
interface IntrinsicElements {
|
||||||
"zapier-app-directory": any;
|
"zapier-app-directory": any;
|
||||||
|
"em-emoji": any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@
|
|||||||
"@dnd-kit/core": "^6.0.5",
|
"@dnd-kit/core": "^6.0.5",
|
||||||
"@dnd-kit/modifiers": "^6.0.0",
|
"@dnd-kit/modifiers": "^6.0.0",
|
||||||
"@dnd-kit/sortable": "^7.0.1",
|
"@dnd-kit/sortable": "^7.0.1",
|
||||||
|
"@emoji-mart/data": "^1.0.6",
|
||||||
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@getoutline/y-prosemirror": "^1.0.18",
|
"@getoutline/y-prosemirror": "^1.0.18",
|
||||||
"@hocuspocus/extension-throttle": "1.1.2",
|
"@hocuspocus/extension-throttle": "1.1.2",
|
||||||
"@hocuspocus/provider": "1.1.2",
|
"@hocuspocus/provider": "1.1.2",
|
||||||
@@ -94,6 +96,7 @@
|
|||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"dd-trace": "^3.33.0",
|
"dd-trace": "^3.33.0",
|
||||||
"dotenv": "^4.0.0",
|
"dotenv": "^4.0.0",
|
||||||
|
"emoji-mart": "^5.5.2",
|
||||||
"email-providers": "^1.14.0",
|
"email-providers": "^1.14.0",
|
||||||
"emoji-regex": "^10.2.1",
|
"emoji-regex": "^10.2.1",
|
||||||
"es6-error": "^4.1.1",
|
"es6-error": "^4.1.1",
|
||||||
@@ -104,7 +107,6 @@
|
|||||||
"framer-motion": "^4.1.17",
|
"framer-motion": "^4.1.17",
|
||||||
"fs-extra": "^11.1.1",
|
"fs-extra": "^11.1.1",
|
||||||
"fuzzy-search": "^3.2.1",
|
"fuzzy-search": "^3.2.1",
|
||||||
"gemoji": "6.x",
|
|
||||||
"glob": "^8.1.0",
|
"glob": "^8.1.0",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "2.0.0",
|
||||||
"i18next": "^22.5.1",
|
"i18next": "^22.5.1",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type Props = {
|
|||||||
document: Document;
|
document: Document;
|
||||||
/** The new title */
|
/** The new title */
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** The document emoji */
|
||||||
|
emoji?: string | null;
|
||||||
/** The new text content */
|
/** The new text content */
|
||||||
text?: string;
|
text?: string;
|
||||||
/** Whether the editing session is complete */
|
/** Whether the editing session is complete */
|
||||||
@@ -44,6 +46,7 @@ export default async function documentUpdater({
|
|||||||
user,
|
user,
|
||||||
document,
|
document,
|
||||||
title,
|
title,
|
||||||
|
emoji,
|
||||||
text,
|
text,
|
||||||
editorVersion,
|
editorVersion,
|
||||||
templateId,
|
templateId,
|
||||||
@@ -62,6 +65,9 @@ export default async function documentUpdater({
|
|||||||
if (title !== undefined) {
|
if (title !== undefined) {
|
||||||
document.title = title.trim();
|
document.title = title.trim();
|
||||||
}
|
}
|
||||||
|
if (emoji !== undefined) {
|
||||||
|
document.emoji = emoji;
|
||||||
|
}
|
||||||
if (editorVersion) {
|
if (editorVersion) {
|
||||||
document.editorVersion = editorVersion;
|
document.editorVersion = editorVersion;
|
||||||
}
|
}
|
||||||
|
|||||||
14
server/migrations/20230815063830-add-emoji-to-revisions.js
Normal file
14
server/migrations/20230815063830-add-emoji-to-revisions.js
Normal 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");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import compact from "lodash/compact";
|
import compact from "lodash/compact";
|
||||||
|
import isNil from "lodash/isNil";
|
||||||
import uniq from "lodash/uniq";
|
import uniq from "lodash/uniq";
|
||||||
import randomstring from "randomstring";
|
import randomstring from "randomstring";
|
||||||
import type { SaveOptions } from "sequelize";
|
import type { SaveOptions } from "sequelize";
|
||||||
@@ -33,7 +34,6 @@ import {
|
|||||||
import isUUID from "validator/lib/isUUID";
|
import isUUID from "validator/lib/isUUID";
|
||||||
import type { NavigationNode } from "@shared/types";
|
import type { NavigationNode } from "@shared/types";
|
||||||
import getTasks from "@shared/utils/getTasks";
|
import getTasks from "@shared/utils/getTasks";
|
||||||
import parseTitle from "@shared/utils/parseTitle";
|
|
||||||
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
|
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
|
||||||
import { DocumentValidation } from "@shared/validations";
|
import { DocumentValidation } from "@shared/validations";
|
||||||
import slugify from "@server/utils/slugify";
|
import slugify from "@server/utils/slugify";
|
||||||
@@ -261,7 +261,7 @@ class Document extends ParanoidModel {
|
|||||||
// hooks
|
// hooks
|
||||||
|
|
||||||
@BeforeSave
|
@BeforeSave
|
||||||
static async updateTitleInCollectionStructure(
|
static async updateCollectionStructure(
|
||||||
model: Document,
|
model: Document,
|
||||||
{ transaction }: SaveOptions<Document>
|
{ transaction }: SaveOptions<Document>
|
||||||
) {
|
) {
|
||||||
@@ -271,7 +271,7 @@ class Document extends ParanoidModel {
|
|||||||
model.archivedAt ||
|
model.archivedAt ||
|
||||||
model.template ||
|
model.template ||
|
||||||
!model.publishedAt ||
|
!model.publishedAt ||
|
||||||
!model.changed("title") ||
|
!(model.changed("title") || model.changed("emoji")) ||
|
||||||
!model.collectionId
|
!model.collectionId
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -330,10 +330,6 @@ class Document extends ParanoidModel {
|
|||||||
|
|
||||||
@BeforeUpdate
|
@BeforeUpdate
|
||||||
static processUpdate(model: Document) {
|
static processUpdate(model: Document) {
|
||||||
const { emoji } = parseTitle(model.title);
|
|
||||||
// emoji in the title is split out for easier display
|
|
||||||
model.emoji = emoji || null;
|
|
||||||
|
|
||||||
// ensure documents have a title
|
// ensure documents have a title
|
||||||
model.title = model.title || "";
|
model.title = model.title || "";
|
||||||
|
|
||||||
@@ -795,6 +791,7 @@ class Document extends ParanoidModel {
|
|||||||
id: this.id,
|
id: this.id,
|
||||||
title: this.title,
|
title: this.title,
|
||||||
url: this.url,
|
url: this.url,
|
||||||
|
emoji: isNil(this.emoji) ? undefined : this.emoji,
|
||||||
children,
|
children,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ class Revision extends IdModel {
|
|||||||
@Column(DataType.TEXT)
|
@Column(DataType.TEXT)
|
||||||
text: string;
|
text: string;
|
||||||
|
|
||||||
|
@Length({
|
||||||
|
max: 1,
|
||||||
|
msg: `Emoji must be a single character`,
|
||||||
|
})
|
||||||
|
@Column
|
||||||
|
emoji: string | null;
|
||||||
|
|
||||||
// associations
|
// associations
|
||||||
|
|
||||||
@BelongsTo(() => Document, "documentId")
|
@BelongsTo(() => Document, "documentId")
|
||||||
@@ -65,6 +72,14 @@ class Revision extends IdModel {
|
|||||||
@Column(DataType.UUID)
|
@Column(DataType.UUID)
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
||||||
|
// static methods
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the latest revision for a given document
|
||||||
|
*
|
||||||
|
* @param documentId The document id to find the latest revision for
|
||||||
|
* @returns A Promise that resolves to a Revision model
|
||||||
|
*/
|
||||||
static findLatest(documentId: string) {
|
static findLatest(documentId: string) {
|
||||||
return this.findOne({
|
return this.findOne({
|
||||||
where: {
|
where: {
|
||||||
@@ -74,10 +89,17 @@ class Revision extends IdModel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Revision model from a Document model
|
||||||
|
*
|
||||||
|
* @param document The document to build from
|
||||||
|
* @returns A Revision model
|
||||||
|
*/
|
||||||
static buildFromDocument(document: Document) {
|
static buildFromDocument(document: Document) {
|
||||||
return this.build({
|
return this.build({
|
||||||
title: document.title,
|
title: document.title,
|
||||||
text: document.text,
|
text: document.text,
|
||||||
|
emoji: document.emoji,
|
||||||
userId: document.lastModifiedById,
|
userId: document.lastModifiedById,
|
||||||
editorVersion: document.editorVersion,
|
editorVersion: document.editorVersion,
|
||||||
version: document.version,
|
version: document.version,
|
||||||
@@ -88,6 +110,13 @@ class Revision extends IdModel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Revision model from a Document model and save it to the database
|
||||||
|
*
|
||||||
|
* @param document The document to create from
|
||||||
|
* @param options Options passed to the save method
|
||||||
|
* @returns A Promise that resolves when saved
|
||||||
|
*/
|
||||||
static createFromDocument(
|
static createFromDocument(
|
||||||
document: Document,
|
document: Document,
|
||||||
options?: SaveOptions<Revision>
|
options?: SaveOptions<Revision>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ async function presentDocument(
|
|||||||
url: document.url,
|
url: document.url,
|
||||||
urlId: document.urlId,
|
urlId: document.urlId,
|
||||||
title: document.title,
|
title: document.title,
|
||||||
|
emoji: document.emoji,
|
||||||
text,
|
text,
|
||||||
tasks: document.tasks,
|
tasks: document.tasks,
|
||||||
createdAt: document.createdAt,
|
createdAt: document.createdAt,
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
|
import parseTitle from "@shared/utils/parseTitle";
|
||||||
import { traceFunction } from "@server/logging/tracing";
|
import { traceFunction } from "@server/logging/tracing";
|
||||||
import { Revision } from "@server/models";
|
import { Revision } from "@server/models";
|
||||||
import presentUser from "./user";
|
import presentUser from "./user";
|
||||||
|
|
||||||
async function presentRevision(revision: Revision, diff?: string) {
|
async function presentRevision(revision: Revision, diff?: string) {
|
||||||
|
// TODO: Remove this fallback once all revisions have been migrated
|
||||||
|
const { emoji, strippedTitle } = parseTitle(revision.title);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: revision.id,
|
id: revision.id,
|
||||||
documentId: revision.documentId,
|
documentId: revision.documentId,
|
||||||
title: revision.title,
|
title: strippedTitle,
|
||||||
text: revision.text,
|
text: revision.text,
|
||||||
|
emoji: revision.emoji ?? emoji,
|
||||||
html: diff,
|
html: diff,
|
||||||
createdAt: revision.createdAt,
|
createdAt: revision.createdAt,
|
||||||
createdBy: presentUser(revision.user),
|
createdBy: presentUser(revision.user),
|
||||||
|
|||||||
@@ -2492,6 +2492,7 @@ describe("#documents.update", () => {
|
|||||||
const document = await buildDraftDocument({
|
const document = await buildDraftDocument({
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await server.post("/api/documents.update", {
|
const res = await server.post("/api/documents.update", {
|
||||||
body: {
|
body: {
|
||||||
token: user.getJwtToken(),
|
token: user.getJwtToken(),
|
||||||
@@ -2503,6 +2504,7 @@ describe("#documents.update", () => {
|
|||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(res.status).toEqual(400);
|
expect(res.status).toEqual(400);
|
||||||
|
|
||||||
expect(body.message).toBe(
|
expect(body.message).toBe(
|
||||||
"collectionId is required to publish a draft without collection"
|
"collectionId is required to publish a draft without collection"
|
||||||
);
|
);
|
||||||
@@ -2515,7 +2517,6 @@ describe("#documents.update", () => {
|
|||||||
text: "text",
|
text: "text",
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await server.post("/api/documents.update", {
|
const res = await server.post("/api/documents.update", {
|
||||||
body: {
|
body: {
|
||||||
token: user.getJwtToken(),
|
token: user.getJwtToken(),
|
||||||
@@ -2551,6 +2552,36 @@ describe("#documents.update", () => {
|
|||||||
expect(res.status).toEqual(403);
|
expect(res.status).toEqual(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should fail to update an invalid emoji value", async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
|
||||||
|
const res = await server.post("/api/documents.update", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: document.id,
|
||||||
|
emoji: ":)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
|
||||||
|
expect(body.message).toBe("emoji: Invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should successfully update the emoji", async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
const res = await server.post("/api/documents.update", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: document.id,
|
||||||
|
emoji: "😂",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.emoji).toBe("😂");
|
||||||
|
});
|
||||||
|
|
||||||
it("should not add template to collection structure when publishing", async () => {
|
it("should not add template to collection structure when publishing", async () => {
|
||||||
const user = await buildUser();
|
const user = await buildUser();
|
||||||
const collection = await buildCollection({
|
const collection = await buildCollection({
|
||||||
|
|||||||
@@ -861,6 +861,7 @@ router.post(
|
|||||||
lastModifiedById: user.id,
|
lastModifiedById: user.id,
|
||||||
createdById: user.id,
|
createdById: user.id,
|
||||||
template: true,
|
template: true,
|
||||||
|
emoji: original.emoji,
|
||||||
title: original.title,
|
title: original.title,
|
||||||
text: original.text,
|
text: original.text,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import emojiRegex from "emoji-regex";
|
||||||
import isEmpty from "lodash/isEmpty";
|
import isEmpty from "lodash/isEmpty";
|
||||||
import isUUID from "validator/lib/isUUID";
|
import isUUID from "validator/lib/isUUID";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -186,6 +187,9 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
|
|||||||
/** Doc text to be updated */
|
/** Doc text to be updated */
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
|
|
||||||
|
/** Emoji displayed alongside doc title */
|
||||||
|
emoji: z.string().regex(emojiRegex()).nullish(),
|
||||||
|
|
||||||
/** Boolean to denote if the doc should occupy full width */
|
/** Boolean to denote if the doc should occupy full width */
|
||||||
fullWidth: z.boolean().optional(),
|
fullWidth: z.boolean().optional(),
|
||||||
|
|
||||||
|
|||||||
@@ -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("🚵🏼♂️");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -377,6 +377,7 @@ export async function buildDocument(
|
|||||||
publishedAt: isNull(overrides.collectionId) ? null : new Date(),
|
publishedAt: isNull(overrides.collectionId) ? null : new Date(),
|
||||||
lastModifiedById: overrides.userId,
|
lastModifiedById: overrides.userId,
|
||||||
createdById: overrides.userId,
|
createdById: overrides.userId,
|
||||||
|
editorVersion: 2,
|
||||||
...overrides,
|
...overrides,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
14
shared/editor/lib/emoji.ts
Normal file
14
shared/editor/lib/emoji.ts
Normal 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;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import nameToEmoji from "gemoji/name-to-emoji.json";
|
|
||||||
import Token from "markdown-it/lib/token";
|
import Token from "markdown-it/lib/token";
|
||||||
import {
|
import {
|
||||||
NodeSpec,
|
NodeSpec,
|
||||||
@@ -9,6 +8,7 @@ import {
|
|||||||
import { Command, TextSelection } from "prosemirror-state";
|
import { Command, TextSelection } from "prosemirror-state";
|
||||||
import { Primitive } from "utility-types";
|
import { Primitive } from "utility-types";
|
||||||
import Suggestion from "../extensions/Suggestion";
|
import Suggestion from "../extensions/Suggestion";
|
||||||
|
import { nameToEmoji } from "../lib/emoji";
|
||||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||||
import { SuggestionsMenuType } from "../plugins/Suggestions";
|
import { SuggestionsMenuType } from "../plugins/Suggestions";
|
||||||
import emojiRule from "../rules/emoji";
|
import emojiRule from "../rules/emoji";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import nameToEmoji from "gemoji/name-to-emoji.json";
|
|
||||||
import MarkdownIt from "markdown-it";
|
import MarkdownIt from "markdown-it";
|
||||||
import emojiPlugin from "markdown-it-emoji";
|
import emojiPlugin from "markdown-it-emoji";
|
||||||
|
import { nameToEmoji } from "../lib/emoji";
|
||||||
|
|
||||||
export default function emoji(md: MarkdownIt) {
|
export default function emoji(md: MarkdownIt) {
|
||||||
// Ideally this would be an empty object, but due to a bug in markdown-it-emoji
|
// Ideally this would be an empty object, but due to a bug in markdown-it-emoji
|
||||||
|
|||||||
@@ -168,6 +168,8 @@
|
|||||||
"Currently editing": "Currently editing",
|
"Currently editing": "Currently editing",
|
||||||
"Currently viewing": "Currently viewing",
|
"Currently viewing": "Currently viewing",
|
||||||
"Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago",
|
"Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago",
|
||||||
|
"Emoji Picker": "Emoji Picker",
|
||||||
|
"Remove": "Remove",
|
||||||
"Module failed to load": "Module failed to load",
|
"Module failed to load": "Module failed to load",
|
||||||
"Loading Failed": "Loading Failed",
|
"Loading Failed": "Loading Failed",
|
||||||
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.",
|
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.",
|
||||||
@@ -242,6 +244,8 @@
|
|||||||
"{{ releasesBehind }} versions behind_plural": "{{ releasesBehind }} versions behind",
|
"{{ releasesBehind }} versions behind_plural": "{{ releasesBehind }} versions behind",
|
||||||
"Return to App": "Back to App",
|
"Return to App": "Back to App",
|
||||||
"Installation": "Installation",
|
"Installation": "Installation",
|
||||||
|
"Unstar document": "Unstar document",
|
||||||
|
"Star document": "Star document",
|
||||||
"No results": "No results",
|
"No results": "No results",
|
||||||
"Previous page": "Previous page",
|
"Previous page": "Previous page",
|
||||||
"Next page": "Next page",
|
"Next page": "Next page",
|
||||||
@@ -358,7 +362,6 @@
|
|||||||
"Show path to document": "Show path to document",
|
"Show path to document": "Show path to document",
|
||||||
"Path to document": "Path to document",
|
"Path to document": "Path to document",
|
||||||
"Group member options": "Group member options",
|
"Group member options": "Group member options",
|
||||||
"Remove": "Remove",
|
|
||||||
"Export collection": "Export collection",
|
"Export collection": "Export collection",
|
||||||
"Sort in sidebar": "Sort in sidebar",
|
"Sort in sidebar": "Sort in sidebar",
|
||||||
"Alphabetical sort": "Alphabetical sort",
|
"Alphabetical sort": "Alphabetical sort",
|
||||||
|
|||||||
@@ -37,3 +37,20 @@ export const hideScrollbars = () => `
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mixin on any component with relative positioning to add additional hidden clickable/hoverable area
|
||||||
|
*
|
||||||
|
* @param pixels
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const extraArea = (pixels: number): string => `
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
top: -${pixels}px;
|
||||||
|
right: -${pixels}px;
|
||||||
|
left: -${pixels}px;
|
||||||
|
bottom: -${pixels}px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export type NavigationNode = {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
emoji?: string;
|
||||||
children: NavigationNode[];
|
children: NavigationNode[];
|
||||||
isDraft?: boolean;
|
isDraft?: boolean;
|
||||||
collectionId?: string;
|
collectionId?: string;
|
||||||
|
|||||||
1
shared/typings/gemoji.d.ts
vendored
1
shared/typings/gemoji.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
declare module "gemoji";
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import md5 from "crypto-js/md5";
|
import md5 from "crypto-js/md5";
|
||||||
import { darken } from "polished";
|
import { darken, parseToRgb } from "polished";
|
||||||
import theme from "../styles/theme";
|
import theme from "../styles/theme";
|
||||||
|
|
||||||
export const palette = [
|
export const palette = [
|
||||||
@@ -26,3 +26,12 @@ export const stringToColor = (input: string) => {
|
|||||||
const inputAsNumber = parseInt(md5(input).toString(), 16);
|
const inputAsNumber = parseInt(md5(input).toString(), 16);
|
||||||
return palette[inputAsNumber % palette.length];
|
return palette[inputAsNumber % palette.length];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a color to string of RGB values separated by commas
|
||||||
|
*
|
||||||
|
* @param color - A color string
|
||||||
|
* @returns A string of RGB values separated by commas
|
||||||
|
*/
|
||||||
|
export const toRGB = (color: string) =>
|
||||||
|
Object.values(parseToRgb(color)).join(", ");
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function parseTitle(text = "") {
|
|||||||
// find and extract first emoji
|
// find and extract first emoji
|
||||||
const matches = regex.exec(title);
|
const matches = regex.exec(title);
|
||||||
const firstEmoji = matches ? matches[0] : null;
|
const firstEmoji = matches ? matches[0] : null;
|
||||||
const startsWithEmoji = firstEmoji && title.startsWith(`${firstEmoji} `);
|
const startsWithEmoji = firstEmoji && title.startsWith(firstEmoji);
|
||||||
const emoji = startsWithEmoji ? firstEmoji : undefined;
|
const emoji = startsWithEmoji ? firstEmoji : undefined;
|
||||||
|
|
||||||
// title with first leading emoji stripped
|
// title with first leading emoji stripped
|
||||||
|
|||||||
20
yarn.lock
20
yarn.lock
@@ -1307,6 +1307,16 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
|
"@emoji-mart/data@^1.0.6":
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.1.2.tgz#777c976f8f143df47cbb23a7077c9ca9fe5fc513"
|
||||||
|
integrity sha512-1HP8BxD2azjqWJvxIaWAMyTySeZY0Osr83ukYjltPVkNXeJvTz7yDrPLBtnrD5uqJ3tg4CcLuuBW09wahqL/fg==
|
||||||
|
|
||||||
|
"@emoji-mart/react@^1.1.1":
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emoji-mart/react/-/react-1.1.1.tgz#ddad52f93a25baf31c5383c3e7e4c6e05554312a"
|
||||||
|
integrity sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==
|
||||||
|
|
||||||
"@emotion/is-prop-valid@^0.8.2":
|
"@emotion/is-prop-valid@^0.8.2":
|
||||||
version "0.8.8"
|
version "0.8.8"
|
||||||
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
|
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
|
||||||
@@ -5937,6 +5947,11 @@ emittery@^0.13.1:
|
|||||||
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
|
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
|
||||||
integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==
|
integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==
|
||||||
|
|
||||||
|
emoji-mart@^5.5.2:
|
||||||
|
version "5.5.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.5.2.tgz#3ddbaf053139cf4aa217650078bc1c50ca8381af"
|
||||||
|
integrity sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==
|
||||||
|
|
||||||
emoji-regex@*, emoji-regex@^10.2.1:
|
emoji-regex@*, emoji-regex@^10.2.1:
|
||||||
version "10.2.1"
|
version "10.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.2.1.tgz#a41c330d957191efd3d9dfe6e1e8e1e9ab048b3f"
|
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.2.1.tgz#a41c330d957191efd3d9dfe6e1e8e1e9ab048b3f"
|
||||||
@@ -7103,11 +7118,6 @@ fuzzy-search@^3.2.1:
|
|||||||
resolved "https://registry.yarnpkg.com/fuzzy-search/-/fuzzy-search-3.2.1.tgz#65d5faad6bc633aee86f1898b7788dfe312ac6c9"
|
resolved "https://registry.yarnpkg.com/fuzzy-search/-/fuzzy-search-3.2.1.tgz#65d5faad6bc633aee86f1898b7788dfe312ac6c9"
|
||||||
integrity sha512-vAcPiyomt1ioKAsAL2uxSABHJ4Ju/e4UeDM+g1OlR0vV4YhLGMNsdLNvZTpEDY4JCSt0E4hASCNM5t2ETtsbyg==
|
integrity sha512-vAcPiyomt1ioKAsAL2uxSABHJ4Ju/e4UeDM+g1OlR0vV4YhLGMNsdLNvZTpEDY4JCSt0E4hASCNM5t2ETtsbyg==
|
||||||
|
|
||||||
gemoji@6.x:
|
|
||||||
version "6.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/gemoji/-/gemoji-6.1.0.tgz#268fbb0c81d1a8c32a4bcc39bdfdd66080ba7ce9"
|
|
||||||
integrity sha512-MOlX3doQ1fsfzxQX8Y+u6bC5Ssc1pBUBIPVyrS69EzKt+5LIZAOm0G5XGVNhwXFgkBF3r+Yk88ONyrFHo8iNFA==
|
|
||||||
|
|
||||||
gensync@^1.0.0-beta.2:
|
gensync@^1.0.0-beta.2:
|
||||||
version "1.0.0-beta.2"
|
version "1.0.0-beta.2"
|
||||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||||
|
|||||||
Reference in New Issue
Block a user