chore: Move editor into codebase (#2930)

This commit is contained in:
Tom Moor
2022-01-19 18:43:15 -08:00
committed by GitHub
parent 266f8c96c4
commit 062016b164
216 changed files with 12417 additions and 382 deletions

View File

@@ -26,6 +26,7 @@
"rules": {
"eqeqeq": 2,
"no-mixed-operators": "off",
"no-useless-escape": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
@@ -109,8 +110,5 @@
"import/resolver": {
"typescript": {}
}
},
"globals": {
"EDITOR_VERSION": true
}
}

View File

@@ -12,10 +12,10 @@ import {
PinIcon,
} from "outline-icons";
import * as React from "react";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import DocumentTemplatize from "~/scenes/DocumentTemplatize";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import getDataTransferFiles from "~/utils/getDataTransferFiles";
import history from "~/utils/history";
import { homePath, newDocumentPath } from "~/utils/routeHelpers";

View File

@@ -1,26 +1,21 @@
import { lighten } from "polished";
import * as React from "react";
import { Props as EditorProps } from "rich-markdown-editor";
import { EmbedDescriptor } from "rich-markdown-editor/dist/types";
import styled, { useTheme } from "styled-components";
import { Optional } from "utility-types";
import embeds from "@shared/embeds";
import { light } from "@shared/theme";
import embeds from "@shared/editor/embeds";
import { EmbedDescriptor } from "@shared/editor/types";
import ErrorBoundary from "~/components/ErrorBoundary";
import Tooltip from "~/components/Tooltip";
import { Props as EditorProps } from "~/editor";
import useDictionary from "~/hooks/useDictionary";
import useMediaQuery from "~/hooks/useMediaQuery";
import useToasts from "~/hooks/useToasts";
import history from "~/utils/history";
import { isModKey } from "~/utils/keyboard";
import { uploadFile } from "~/utils/uploadFile";
import { isInternalUrl, isHash } from "~/utils/urls";
const RichMarkdownEditor = React.lazy(
const SharedEditor = React.lazy(
() =>
import(
/* webpackChunkName: "rich-markdown-editor" */
"rich-markdown-editor"
/* webpackChunkName: "shared-editor" */
"~/editor"
)
);
@@ -28,7 +23,7 @@ const EMPTY_ARRAY: EmbedDescriptor[] = [];
export type Props = Optional<
EditorProps,
"placeholder" | "defaultValue" | "tooltip" | "onClickLink" | "embeds"
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "dictionary"
> & {
shareId?: string | undefined;
disableEmbeds?: boolean;
@@ -39,9 +34,7 @@ export type Props = Optional<
function Editor(props: Props, ref: React.Ref<any>) {
const { id, shareId } = props;
const theme = useTheme();
const { showToast } = useToasts();
const isPrinting = useMediaQuery("print");
const dictionary = useDictionary();
const onUploadImage = React.useCallback(
@@ -97,144 +90,19 @@ function Editor(props: Props, ref: React.Ref<any>) {
return (
<ErrorBoundary reloadOnChunkMissing>
<StyledEditor
<SharedEditor
ref={ref}
uploadImage={onUploadImage}
onShowToast={onShowToast}
embeds={props.disableEmbeds ? EMPTY_ARRAY : embeds}
dictionary={dictionary}
{...props}
tooltip={EditorTooltip}
onClickLink={onClickLink}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
theme={isPrinting ? light : theme}
/>
</ErrorBoundary>
);
}
const StyledEditor = styled(RichMarkdownEditor)<{ grow?: boolean }>`
flex-grow: ${(props) => (props.grow ? 1 : 0)};
justify-content: start;
> div {
background: transparent;
}
& * {
box-sizing: content-box;
}
.notice-block.tip,
.notice-block.warning {
font-weight: 500;
}
.heading-anchor {
box-sizing: border-box;
}
.heading-name {
pointer-events: none;
display: block;
position: relative;
top: -60px;
visibility: hidden;
}
.heading-name:first-child,
.heading-name:first-child + .ProseMirror-yjs-cursor {
& + h1,
& + h2,
& + h3,
& + h4 {
margin-top: 0;
}
}
p {
a {
color: ${(props) => props.theme.text};
border-bottom: 1px solid ${(props) => lighten(0.5, props.theme.text)};
text-decoration: none !important;
font-weight: 500;
&:hover {
border-bottom: 1px solid ${(props) => props.theme.text};
text-decoration: none;
}
}
}
.ProseMirror {
& > .ProseMirror-yjs-cursor {
display: none;
}
.ProseMirror-yjs-cursor {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid black;
border-right: 1px solid black;
height: 1em;
word-break: normal;
&:after {
content: "";
display: block;
position: absolute;
left: -8px;
right: -8px;
top: 0;
bottom: 0;
}
> div {
opacity: 0;
transition: opacity 100ms ease-in-out;
position: absolute;
top: -1.8em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-style: normal;
line-height: normal;
user-select: none;
white-space: nowrap;
color: white;
padding: 2px 6px;
font-weight: 500;
border-radius: 4px;
pointer-events: none;
left: -1px;
}
&:hover {
> div {
opacity: 1;
}
}
}
}
&.show-cursor-names .ProseMirror-yjs-cursor > div {
opacity: 1;
}
`;
type TooltipProps = {
children: React.ReactNode;
tooltip: string;
};
const EditorTooltip = ({ children, tooltip, ...props }: TooltipProps) => (
<Tooltip offset="0, 16" delay={150} tooltip={tooltip} {...props}>
<TooltipContent>{children}</TooltipContent>
</Tooltip>
);
const TooltipContent = styled.span`
outline: none;
`;
export default React.forwardRef<typeof Editor, Props>(Editor);

View File

@@ -15,9 +15,10 @@ function Theme({ children }: Props) {
const theme = ui.resolvedTheme === "dark" ? dark : light;
const mobileTheme = ui.resolvedTheme === "dark" ? darkMobile : lightMobile;
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.tablet}px)`);
const isPrinting = useMediaQuery("print");
return (
<ThemeProvider theme={isMobile ? mobileTheme : theme}>
<ThemeProvider theme={isPrinting ? light : isMobile ? mobileTheme : theme}>
<>
<GlobalStyles />
{children}

View File

@@ -0,0 +1,50 @@
import { findParentNode } from "prosemirror-utils";
import React from "react";
import getMenuItems from "../menus/block";
import BlockMenuItem from "./BlockMenuItem";
import CommandMenu, { Props } from "./CommandMenu";
type BlockMenuProps = Omit<
Props,
"renderMenuItem" | "items" | "onClearSearch"
> &
Required<Pick<Props, "onLinkToolbarOpen" | "embeds">>;
class BlockMenu extends React.Component<BlockMenuProps> {
get items() {
return getMenuItems(this.props.dictionary);
}
clearSearch = () => {
const { state, dispatch } = this.props.view;
const parent = findParentNode((node) => !!node)(state.selection);
if (parent) {
dispatch(state.tr.insertText("", parent.pos, state.selection.to));
}
};
render() {
return (
<CommandMenu
{...this.props}
filterable={true}
onClearSearch={this.clearSearch}
renderMenuItem={(item, _index, options) => {
return (
<BlockMenuItem
onClick={options.onClick}
selected={options.selected}
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
/>
);
}}
items={this.items}
/>
);
}
}
export default BlockMenu;

View File

@@ -0,0 +1,110 @@
import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
export type Props = {
selected: boolean;
disabled?: boolean;
onClick: () => void;
icon?: typeof React.Component | React.FC<any>;
title: React.ReactNode;
shortcut?: string;
containerId?: string;
};
function BlockMenuItem({
selected,
disabled,
onClick,
title,
shortcut,
icon,
containerId = "block-menu-container",
}: Props) {
const Icon = icon;
const theme = useTheme();
const ref = React.useCallback(
(node) => {
if (selected && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
block: "center",
boundary: (parent) => {
// All the parent elements of your target are checked until they
// reach the #block-menu-container. Prevents body and other parent
// elements from being scrolled
return parent.id !== containerId;
},
});
}
},
[selected, containerId]
);
return (
<MenuItem
selected={selected}
onClick={disabled ? undefined : onClick}
ref={ref}
>
{Icon && (
<>
<Icon
color={
selected ? theme.blockToolbarIconSelected : theme.blockToolbarIcon
}
/>
&nbsp;&nbsp;
</>
)}
{title}
{shortcut && <Shortcut>{shortcut}</Shortcut>}
</MenuItem>
);
}
const MenuItem = styled.button<{
selected: boolean;
}>`
display: flex;
align-items: center;
justify-content: flex-start;
font-weight: 500;
font-size: 14px;
line-height: 1;
width: 100%;
height: 36px;
cursor: pointer;
border: none;
opacity: ${(props) => (props.disabled ? ".5" : "1")};
color: ${(props) =>
props.selected
? props.theme.blockToolbarTextSelected
: props.theme.blockToolbarText};
background: ${(props) =>
props.selected
? props.theme.blockToolbarSelectedBackground ||
props.theme.blockToolbarTrigger
: "none"};
padding: 0 16px;
outline: none;
&:hover,
&:active {
color: ${(props) => props.theme.blockToolbarTextSelected};
background: ${(props) =>
props.selected
? props.theme.blockToolbarSelectedBackground ||
props.theme.blockToolbarTrigger
: props.theme.blockToolbarHoverBackground};
}
`;
const Shortcut = styled.span`
color: ${(props) => props.theme.textSecondary};
flex-grow: 1;
text-align: right;
`;
export default BlockMenuItem;

View File

@@ -0,0 +1,614 @@
import { capitalize } from "lodash";
import { findDomRefAtPos, findParentNode } from "prosemirror-utils";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { Portal } from "react-portal";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import insertFiles from "@shared/editor/commands/insertFiles";
import { CommandFactory } from "@shared/editor/lib/Extension";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { EmbedDescriptor, MenuItem, ToastType } from "@shared/editor/types";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import { Dictionary } from "~/hooks/useDictionary";
import Input from "./Input";
const defaultPosition = {
left: -1000,
top: 0,
bottom: undefined,
isAbove: false,
};
export type Props<T extends MenuItem = MenuItem> = {
rtl: boolean;
isActive: boolean;
commands: Record<string, CommandFactory>;
dictionary: Dictionary;
view: EditorView;
search: string;
uploadImage?: (file: File) => Promise<string>;
onImageUploadStart?: () => void;
onImageUploadStop?: () => void;
onShowToast?: (message: string, id: string) => void;
onLinkToolbarOpen?: () => void;
onClose: () => void;
onClearSearch: () => void;
embeds?: EmbedDescriptor[];
renderMenuItem: (
item: T,
index: number,
options: {
selected: boolean;
onClick: () => void;
}
) => React.ReactNode;
filterable?: boolean;
items: T[];
id?: string;
};
type State = {
insertItem?: EmbedDescriptor;
left?: number;
top?: number;
bottom?: number;
isAbove: boolean;
selectedIndex: number;
};
class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
menuRef = React.createRef<HTMLDivElement>();
inputRef = React.createRef<HTMLInputElement>();
state: State = {
left: -1000,
top: 0,
bottom: undefined,
isAbove: false,
selectedIndex: 0,
insertItem: undefined,
};
componentDidMount() {
window.addEventListener("keydown", this.handleKeyDown);
}
shouldComponentUpdate(nextProps: Props, nextState: State) {
return (
nextProps.search !== this.props.search ||
nextProps.isActive !== this.props.isActive ||
nextState !== this.state
);
}
componentDidUpdate(prevProps: Props) {
if (!prevProps.isActive && this.props.isActive) {
const position = this.calculatePosition(this.props);
this.setState({
insertItem: undefined,
selectedIndex: 0,
...position,
});
} else if (prevProps.search !== this.props.search) {
this.setState({ selectedIndex: 0 });
}
}
componentWillUnmount() {
window.removeEventListener("keydown", this.handleKeyDown);
}
handleKeyDown = (event: KeyboardEvent) => {
if (!this.props.isActive) return;
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
const item = this.filtered[this.state.selectedIndex];
if (item) {
this.insertItem(item);
} else {
this.props.onClose();
}
}
if (
event.key === "ArrowUp" ||
(event.key === "Tab" && event.shiftKey) ||
(event.ctrlKey && event.key === "p")
) {
event.preventDefault();
event.stopPropagation();
if (this.filtered.length) {
const prevIndex = this.state.selectedIndex - 1;
const prev = this.filtered[prevIndex];
this.setState({
selectedIndex: Math.max(
0,
prev && prev.name === "separator" ? prevIndex - 1 : prevIndex
),
});
} else {
this.close();
}
}
if (
event.key === "ArrowDown" ||
(event.key === "Tab" && !event.shiftKey) ||
(event.ctrlKey && event.key === "n")
) {
event.preventDefault();
event.stopPropagation();
if (this.filtered.length) {
const total = this.filtered.length - 1;
const nextIndex = this.state.selectedIndex + 1;
const next = this.filtered[nextIndex];
this.setState({
selectedIndex: Math.min(
next && next.name === "separator" ? nextIndex + 1 : nextIndex,
total
),
});
} else {
this.close();
}
}
if (event.key === "Escape") {
this.close();
}
};
insertItem = (item: any) => {
switch (item.name) {
case "image":
return this.triggerImagePick();
case "embed":
return this.triggerLinkInput(item);
case "link": {
this.clearSearch();
this.props.onClose();
this.props.onLinkToolbarOpen?.();
return;
}
default:
this.insertBlock(item);
}
};
close = () => {
this.props.onClose();
this.props.view.focus();
};
handleLinkInputKeydown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!this.props.isActive) return;
if (!this.state.insertItem) return;
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
const href = event.currentTarget.value;
const matches = this.state.insertItem.matcher(href);
if (!matches && this.props.onShowToast) {
this.props.onShowToast(
this.props.dictionary.embedInvalidLink,
ToastType.Error
);
return;
}
this.insertBlock({
name: "embed",
attrs: {
href,
},
});
}
if (event.key === "Escape") {
this.props.onClose();
this.props.view.focus();
}
};
handleLinkInputPaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
if (!this.props.isActive) return;
if (!this.state.insertItem) return;
const href = event.clipboardData.getData("text/plain");
const matches = this.state.insertItem.matcher(href);
if (matches) {
event.preventDefault();
event.stopPropagation();
this.insertBlock({
name: "embed",
attrs: {
href,
},
});
}
};
triggerImagePick = () => {
if (this.inputRef.current) {
this.inputRef.current.click();
}
};
triggerLinkInput = (item: EmbedDescriptor) => {
this.setState({ insertItem: item });
};
handleImagePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(event);
const {
view,
uploadImage,
onImageUploadStart,
onImageUploadStop,
onShowToast,
} = this.props;
const { state } = view;
const parent = findParentNode((node) => !!node)(state.selection);
this.clearSearch();
if (!uploadImage) {
throw new Error("uploadImage prop is required to replace images");
}
if (parent) {
insertFiles(view, event, parent.pos, files, {
uploadImage,
onImageUploadStart,
onImageUploadStop,
onShowToast,
dictionary: this.props.dictionary,
});
}
if (this.inputRef.current) {
this.inputRef.current.value = "";
}
this.props.onClose();
};
clearSearch = () => {
this.props.onClearSearch();
};
insertBlock(item: MenuItem) {
this.clearSearch();
const command = item.name ? this.props.commands[item.name] : undefined;
if (command) {
command(item.attrs);
} else {
this.props.commands[`create${capitalize(item.name)}`](item.attrs);
}
this.props.onClose();
}
get caretPosition(): { top: number; left: number } {
const selection = window.document.getSelection();
if (!selection || !selection.anchorNode || !selection.focusNode) {
return {
top: 0,
left: 0,
};
}
const range = window.document.createRange();
range.setStart(selection.anchorNode, selection.anchorOffset);
range.setEnd(selection.focusNode, selection.focusOffset);
// This is a workaround for an edgecase where getBoundingClientRect will
// return zero values if the selection is collapsed at the start of a newline
// see reference here: https://stackoverflow.com/a/59780954
const rects = range.getClientRects();
if (rects.length === 0) {
// probably buggy newline behavior, explicitly select the node contents
if (range.startContainer && range.collapsed) {
range.selectNodeContents(range.startContainer);
}
}
const rect = range.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
};
}
calculatePosition(props: Props) {
const { view } = props;
const { selection } = view.state;
let startPos;
try {
startPos = view.coordsAtPos(selection.from);
} catch (err) {
console.warn(err);
return defaultPosition;
}
const domAtPos = view.domAtPos.bind(view);
const ref = this.menuRef.current;
const offsetHeight = ref ? ref.offsetHeight : 0;
const node = findDomRefAtPos(selection.from, domAtPos);
const paragraph: any = { node };
if (
!props.isActive ||
!paragraph.node ||
!paragraph.node.getBoundingClientRect
) {
return defaultPosition;
}
const { left } = this.caretPosition;
const { top, bottom, right } = paragraph.node.getBoundingClientRect();
const margin = 24;
let leftPos = left + window.scrollX;
if (props.rtl && ref) {
leftPos = right - ref.scrollWidth;
}
if (startPos.top - offsetHeight > margin) {
return {
left: leftPos,
top: undefined,
bottom: window.innerHeight - top - window.scrollY,
isAbove: false,
};
} else {
return {
left: leftPos,
top: bottom + window.scrollY,
bottom: undefined,
isAbove: true,
};
}
}
get filtered() {
const {
embeds = [],
search = "",
uploadImage,
commands,
filterable = true,
} = this.props;
let items: (EmbedDescriptor | MenuItem)[] = this.props.items;
const embedItems: EmbedDescriptor[] = [];
for (const embed of embeds) {
if (embed.title && embed.icon) {
embedItems.push({
...embed,
name: "embed",
});
}
}
if (embedItems.length) {
items.push({
name: "separator",
});
items = items.concat(embedItems);
}
const filtered = items.filter((item) => {
if (item.name === "separator") return true;
// Some extensions may be disabled, remove corresponding menu items
if (
item.name &&
!commands[item.name] &&
!commands[`create${capitalize(item.name)}`]
) {
return false;
}
// If no image upload callback has been passed, filter the image block out
if (!uploadImage && item.name === "image") return false;
// some items (defaultHidden) are not visible until a search query exists
if (!search) return !item.defaultHidden;
const n = search.toLowerCase();
if (!filterable) {
return item;
}
return (
(item.title || "").toLowerCase().includes(n) ||
(item.keywords || "").toLowerCase().includes(n)
);
});
return filterExcessSeparators(filtered);
}
render() {
const { dictionary, isActive, uploadImage } = this.props;
const items = this.filtered;
const { insertItem, ...positioning } = this.state;
return (
<Portal>
<Wrapper
id={this.props.id || "block-menu-container"}
active={isActive}
ref={this.menuRef}
{...positioning}
>
{insertItem ? (
<LinkInputWrapper>
<LinkInput
type="text"
placeholder={
insertItem.title
? dictionary.pasteLinkWithTitle(insertItem.title)
: dictionary.pasteLink
}
onKeyDown={this.handleLinkInputKeydown}
onPaste={this.handleLinkInputPaste}
autoFocus
/>
</LinkInputWrapper>
) : (
<List>
{items.map((item, index) => {
if (item.name === "separator") {
return (
<ListItem key={index}>
<hr />
</ListItem>
);
}
const selected = index === this.state.selectedIndex && isActive;
if (!item.title) {
return null;
}
return (
<ListItem key={index}>
{this.props.renderMenuItem(item as any, index, {
selected,
onClick: () => this.insertItem(item),
})}
</ListItem>
);
})}
{items.length === 0 && (
<ListItem>
<Empty>{dictionary.noResults}</Empty>
</ListItem>
)}
</List>
)}
{uploadImage && (
<VisuallyHidden>
<input
type="file"
ref={this.inputRef}
onChange={this.handleImagePicked}
accept="image/*"
/>
</VisuallyHidden>
)}
</Wrapper>
</Portal>
);
}
}
const LinkInputWrapper = styled.div`
margin: 8px;
`;
const LinkInput = styled(Input)`
height: 36px;
width: 100%;
color: ${(props) => props.theme.blockToolbarText};
`;
const List = styled.ol`
list-style: none;
text-align: left;
height: 100%;
padding: 8px 0;
margin: 0;
`;
const ListItem = styled.li`
padding: 0;
margin: 0;
`;
const Empty = styled.div`
display: flex;
align-items: center;
color: ${(props) => props.theme.textSecondary};
font-weight: 500;
font-size: 14px;
height: 36px;
padding: 0 16px;
`;
export const Wrapper = styled.div<{
active: boolean;
top?: number;
bottom?: number;
left?: number;
isAbove: boolean;
}>`
color: ${(props) => props.theme.text};
font-family: ${(props) => props.theme.fontFamily};
position: absolute;
z-index: ${(props) => props.theme.zIndex + 100};
${(props) => props.top !== undefined && `top: ${props.top}px`};
${(props) => props.bottom !== undefined && `bottom: ${props.bottom}px`};
left: ${(props) => props.left}px;
background-color: ${(props) => props.theme.blockToolbarBackground};
border-radius: 4px;
box-shadow: rgba(0, 0, 0, 0.05) 0px 0px 0px 1px,
rgba(0, 0, 0, 0.08) 0px 4px 8px, rgba(0, 0, 0, 0.08) 0px 2px 4px;
opacity: 0;
transform: scale(0.95);
transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition-delay: 150ms;
line-height: 0;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
width: 300px;
max-height: 224px;
overflow: hidden;
overflow-y: auto;
* {
box-sizing: border-box;
}
hr {
border: 0;
height: 0;
border-top: 1px solid ${(props) => props.theme.blockToolbarDivider};
}
${({ active, isAbove }) =>
active &&
`
transform: translateY(${isAbove ? "6px" : "-6px"}) scale(1);
pointer-events: all;
opacity: 1;
`};
@media print {
display: none;
}
`;
export default CommandMenu;

View File

@@ -0,0 +1,116 @@
import { Node as ProsemirrorNode } from "prosemirror-model";
import { EditorView, Decoration } from "prosemirror-view";
import * as React from "react";
import ReactDOM from "react-dom";
import { ThemeProvider } from "styled-components";
import Extension from "@shared/editor/lib/Extension";
import { ComponentProps } from "@shared/editor/types";
import { Editor } from "~/editor";
type Component = (props: ComponentProps) => React.ReactElement;
export default class ComponentView {
component: Component;
editor: Editor;
extension: Extension;
node: ProsemirrorNode;
view: EditorView;
getPos: () => number;
decorations: Decoration<{
[key: string]: any;
}>[];
isSelected = false;
dom: HTMLElement | null;
// See https://prosemirror.net/docs/ref/#view.NodeView
constructor(
component: Component,
{
editor,
extension,
node,
view,
getPos,
decorations,
}: {
editor: Editor;
extension: Extension;
node: ProsemirrorNode;
view: EditorView;
getPos: () => number;
decorations: Decoration<{
[key: string]: any;
}>[];
}
) {
this.component = component;
this.editor = editor;
this.extension = extension;
this.getPos = getPos;
this.decorations = decorations;
this.node = node;
this.view = view;
this.dom = node.type.spec.inline
? document.createElement("span")
: document.createElement("div");
this.renderElement();
}
renderElement() {
const { theme } = this.editor.props;
const children = this.component({
theme,
node: this.node,
isSelected: this.isSelected,
isEditable: this.view.editable,
getPos: this.getPos,
});
ReactDOM.render(
<ThemeProvider theme={theme}>{children}</ThemeProvider>,
this.dom
);
}
update(node: ProsemirrorNode) {
if (node.type !== this.node.type) {
return false;
}
this.node = node;
this.renderElement();
return true;
}
selectNode() {
if (this.view.editable) {
this.isSelected = true;
this.renderElement();
}
}
deselectNode() {
if (this.view.editable) {
this.isSelected = false;
this.renderElement();
}
}
stopEvent() {
return true;
}
destroy() {
if (this.dom) {
ReactDOM.unmountComponentAtNode(this.dom);
}
this.dom = null;
}
ignoreMutation() {
return true;
}
}

View File

@@ -0,0 +1,90 @@
import FuzzySearch from "fuzzy-search";
import gemojies from "gemoji";
import React from "react";
import CommandMenu, { Props } from "./CommandMenu";
import EmojiMenuItem from "./EmojiMenuItem";
type Emoji = {
name: string;
title: string;
emoji: string;
description: string;
attrs: { markup: string; "data-name": string };
};
const searcher = new FuzzySearch<{
names: string[];
description: string;
emoji: string;
}>(gemojies, ["names"], {
caseSensitive: true,
sort: true,
});
class EmojiMenu extends React.Component<
Omit<
Props<Emoji>,
| "renderMenuItem"
| "items"
| "onLinkToolbarOpen"
| "embeds"
| "onClearSearch"
>
> {
get items(): Emoji[] {
const { search = "" } = this.props;
const n = search.toLowerCase();
const result = searcher.search(n).map((item) => {
const description = item.description;
const name = item.names[0];
return {
...item,
name: "emoji",
title: name,
description,
attrs: { markup: name, "data-name": name },
};
});
return result.slice(0, 10);
}
clearSearch = () => {
const { state, dispatch } = this.props.view;
// clear search input
dispatch(
state.tr.insertText(
"",
state.selection.$from.pos - (this.props.search ?? "").length - 1,
state.selection.to
)
);
};
render() {
return (
<CommandMenu
{...this.props}
id="emoji-menu-container"
filterable={false}
onClearSearch={this.clearSearch}
renderMenuItem={(item, _index, options) => {
return (
<EmojiMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.description}
emoji={item.emoji}
containerId="emoji-menu-container"
/>
);
}}
items={this.items}
/>
);
}
}
export default EmojiMenu;

View File

@@ -0,0 +1,35 @@
import * as React from "react";
import styled from "styled-components";
import BlockMenuItem, { Props as BlockMenuItemProps } from "./BlockMenuItem";
const Emoji = styled.span`
font-size: 16px;
`;
type Props = {
emoji: React.ReactNode;
title: React.ReactNode;
};
const EmojiTitle = ({ emoji, title }: Props) => {
return (
<p>
<Emoji className="emoji">{emoji}</Emoji>
&nbsp;&nbsp;
{title}
</p>
);
};
type EmojiMenuItemProps = Omit<BlockMenuItemProps, "shortcut" | "theme"> & {
emoji: string;
};
export default function EmojiMenuItem(props: EmojiMenuItemProps) {
return (
<BlockMenuItem
{...props}
title={<EmojiTitle emoji={props.emoji} title={props.title} />}
/>
);
}

View File

@@ -0,0 +1,267 @@
import { NodeSelection } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import useComponentSize from "~/hooks/useComponentSize";
import useMediaQuery from "~/hooks/useMediaQuery";
import useViewportHeight from "~/hooks/useViewportHeight";
type Props = {
active?: boolean;
view: EditorView;
children: React.ReactNode;
forwardedRef?: React.RefObject<HTMLDivElement> | null;
};
const defaultPosition = {
left: -1000,
top: 0,
offset: 0,
visible: false,
};
function usePosition({
menuRef,
isSelectingText,
props,
}: {
menuRef: React.RefObject<HTMLDivElement>;
isSelectingText: boolean;
props: Props;
}) {
const { view, active } = props;
const { selection } = view.state;
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
const viewportHeight = useViewportHeight();
const isTouchDevice = useMediaQuery("(hover: none) and (pointer: coarse)");
if (!active || !menuWidth || !menuHeight || isSelectingText) {
return defaultPosition;
}
// If we're on a mobile device then stick the floating toolbar to the bottom
// of the screen above the virtual keyboard.
if (isTouchDevice && viewportHeight) {
return {
left: 0,
right: 0,
top: viewportHeight - menuHeight,
offset: 0,
visible: true,
};
}
// based on the start and end of the selection calculate the position at
// the center top
let fromPos;
let toPos;
try {
fromPos = view.coordsAtPos(selection.from);
toPos = view.coordsAtPos(selection.to, -1);
} catch (err) {
console.warn(err);
return defaultPosition;
}
// ensure that start < end for the menu to be positioned correctly
const selectionBounds = {
top: Math.min(fromPos.top, toPos.top),
bottom: Math.max(fromPos.bottom, toPos.bottom),
left: Math.min(fromPos.left, toPos.left),
right: Math.max(fromPos.right, toPos.right),
};
// tables are an oddity, and need their own positioning logic
const isColSelection =
selection instanceof CellSelection &&
selection.isColSelection &&
selection.isColSelection();
const isRowSelection =
selection instanceof CellSelection &&
selection.isRowSelection &&
selection.isRowSelection();
if (isColSelection) {
const { node: element } = view.domAtPos(selection.from);
const { width } = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top -= 20;
selectionBounds.right = selectionBounds.left + width;
}
if (isRowSelection) {
selectionBounds.right = selectionBounds.left = selectionBounds.left - 18;
}
const isImageSelection =
selection instanceof NodeSelection && selection.node?.type.name === "image";
// Images need their own positioning to get the toolbar in the center
if (isImageSelection) {
const element = view.nodeDOM(selection.from);
// Images are wrapped which impacts positioning - need to traverse through
// p > span > div.image
const imageElement = (element as HTMLElement).getElementsByTagName(
"img"
)[0];
const { left, top, width } = imageElement.getBoundingClientRect();
return {
left: Math.round(left + width / 2 + window.scrollX - menuWidth / 2),
top: Math.round(top + window.scrollY - menuHeight),
offset: 0,
visible: true,
};
} else {
// calcluate the horizontal center of the selection
const halfSelection =
Math.abs(selectionBounds.right - selectionBounds.left) / 2;
const centerOfSelection = selectionBounds.left + halfSelection;
// position the menu so that it is centered over the selection except in
// the cases where it would extend off the edge of the screen. In these
// instances leave a margin
const margin = 12;
const left = Math.min(
window.innerWidth - menuWidth - margin,
Math.max(margin, centerOfSelection - menuWidth / 2)
);
const top = Math.min(
window.innerHeight - menuHeight - margin,
Math.max(margin, selectionBounds.top - menuHeight)
);
// if the menu has been offset to not extend offscreen then we should adjust
// the position of the triangle underneath to correctly point to the center
// of the selection still
const offset = left - (centerOfSelection - menuWidth / 2);
return {
left: Math.round(left + window.scrollX),
top: Math.round(top + window.scrollY),
offset: Math.round(offset),
visible: true,
};
}
}
function FloatingToolbar(props: Props) {
const menuRef = props.forwardedRef || React.createRef<HTMLDivElement>();
const [isSelectingText, setSelectingText] = React.useState(false);
const position = usePosition({
menuRef,
isSelectingText,
props,
});
React.useEffect(() => {
const handleMouseDown = () => {
if (!props.active) {
setSelectingText(true);
}
};
const handleMouseUp = () => {
setSelectingText(false);
};
window.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [props.active]);
// only render children when state is updated to visible
// to prevent gaining input focus before calculatePosition runs
return (
<Portal>
<Wrapper
active={props.active && position.visible}
ref={menuRef}
offset={position.offset}
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
{position.visible && props.children}
</Wrapper>
</Portal>
);
}
const Wrapper = styled.div<{
active?: boolean;
offset: number;
}>`
will-change: opacity, transform;
padding: 8px 16px;
position: absolute;
z-index: ${(props) => props.theme.zIndex + 100};
opacity: 0;
background-color: ${(props) => props.theme.toolbarBackground};
border-radius: 4px;
transform: scale(0.95);
transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition-delay: 150ms;
line-height: 0;
height: 40px;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
&::before {
content: "";
display: block;
width: 24px;
height: 24px;
transform: translateX(-50%) rotate(45deg);
background: ${(props) => props.theme.toolbarBackground};
border-radius: 3px;
z-index: -1;
position: absolute;
bottom: -2px;
left: calc(50% - ${(props) => props.offset || 0}px);
pointer-events: none;
}
* {
box-sizing: border-box;
}
${({ active }) =>
active &&
`
transform: translateY(-6px) scale(1);
opacity: 1;
`};
@media print {
display: none;
}
@media (hover: none) and (pointer: coarse) {
&:before {
display: none;
}
transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transform: scale(1);
border-radius: 0;
width: 100vw;
position: fixed;
}
`;
export default React.forwardRef(function FloatingToolbarWithForwardedRef(
props: Props,
ref: React.RefObject<HTMLDivElement>
) {
return <FloatingToolbar {...props} forwardedRef={ref} />;
});

View File

@@ -0,0 +1,19 @@
import styled from "styled-components";
const Input = styled.input`
font-size: 15px;
background: ${(props) => props.theme.toolbarInput};
color: ${(props) => props.theme.toolbarItem};
border-radius: 2px;
padding: 3px 8px;
border: 0;
margin: 0;
outline: none;
flex-grow: 1;
@media (hover: none) and (pointer: coarse) {
font-size: 16px;
}
`;
export default Input;

View File

@@ -0,0 +1,403 @@
import {
DocumentIcon,
CloseIcon,
PlusIcon,
TrashIcon,
OpenIcon,
} from "outline-icons";
import { Mark } from "prosemirror-model";
import { setTextSelection } from "prosemirror-utils";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import styled from "styled-components";
import isUrl from "@shared/editor/lib/isUrl";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import Input from "./Input";
import LinkSearchResult from "./LinkSearchResult";
import ToolbarButton from "./ToolbarButton";
import Tooltip from "./Tooltip";
export type SearchResult = {
title: string;
subtitle?: string;
url: string;
};
type Props = {
mark?: Mark;
from: number;
to: number;
dictionary: Dictionary;
onRemoveLink?: () => void;
onCreateLink?: (title: string) => Promise<void>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onSelectLink: (options: {
href: string;
title?: string;
from: number;
to: number;
}) => void;
onClickLink: (
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast?: (message: string, code: string) => void;
view: EditorView;
};
type State = {
results: {
[keyword: string]: SearchResult[];
};
value: string;
previousValue: string;
selectedIndex: number;
};
class LinkEditor extends React.Component<Props, State> {
discardInputValue = false;
initialValue = this.href;
initialSelectionLength = this.props.to - this.props.from;
state: State = {
selectedIndex: -1,
value: this.href,
previousValue: "",
results: {},
};
get href(): string {
return this.props.mark ? this.props.mark.attrs.href : "";
}
get suggestedLinkTitle(): string {
const { state } = this.props.view;
const { value } = this.state;
const selectionText = state.doc.cut(
state.selection.from,
state.selection.to
).textContent;
return value.trim() || selectionText.trim();
}
componentWillUnmount = () => {
// If we discarded the changes then nothing to do
if (this.discardInputValue) {
return;
}
// If the link is the same as it was when the editor opened, nothing to do
if (this.state.value === this.initialValue) {
return;
}
// If the link is totally empty or only spaces then remove the mark
const href = (this.state.value || "").trim();
if (!href) {
return this.handleRemoveLink();
}
this.save(href, href);
};
save = (href: string, title?: string): void => {
href = href.trim();
if (href.length === 0) return;
this.discardInputValue = true;
const { from, to } = this.props;
// Make sure a protocol is added to the beginning of the input if it's
// likely an absolute URL that was entered without one.
if (
!isUrl(href) &&
!href.startsWith("/") &&
!href.startsWith("#") &&
!href.startsWith("mailto:")
) {
href = `https://${href}`;
}
this.props.onSelectLink({ href, title, from, to });
};
handleKeyDown = (event: React.KeyboardEvent): void => {
switch (event.key) {
case "Enter": {
event.preventDefault();
const { selectedIndex, value } = this.state;
const results = this.state.results[value] || [];
const { onCreateLink } = this.props;
if (selectedIndex >= 0) {
const result = results[selectedIndex];
if (result) {
this.save(result.url, result.title);
} else if (onCreateLink && selectedIndex === results.length) {
this.handleCreateLink(this.suggestedLinkTitle);
}
} else {
// saves the raw input as href
this.save(value, value);
}
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
return;
}
case "Escape": {
event.preventDefault();
if (this.initialValue) {
this.setState({ value: this.initialValue }, this.moveSelectionToEnd);
} else {
this.handleRemoveLink();
}
return;
}
case "ArrowUp": {
if (event.shiftKey) return;
event.preventDefault();
event.stopPropagation();
const prevIndex = this.state.selectedIndex - 1;
this.setState({
selectedIndex: Math.max(-1, prevIndex),
});
return;
}
case "ArrowDown":
case "Tab": {
if (event.shiftKey) return;
event.preventDefault();
event.stopPropagation();
const { selectedIndex, value } = this.state;
const results = this.state.results[value] || [];
const total = results.length;
const nextIndex = selectedIndex + 1;
this.setState({
selectedIndex: Math.min(nextIndex, total),
});
return;
}
}
};
handleFocusLink = (selectedIndex: number) => {
this.setState({ selectedIndex });
};
handleChange = async (
event: React.ChangeEvent<HTMLInputElement>
): Promise<void> => {
const value = event.target.value;
this.setState({
value,
selectedIndex: -1,
});
const trimmedValue = value.trim();
if (trimmedValue && this.props.onSearchLink) {
try {
const results = await this.props.onSearchLink(trimmedValue);
this.setState((state) => ({
results: {
...state.results,
[trimmedValue]: results,
},
previousValue: trimmedValue,
}));
} catch (error) {
console.error(error);
}
}
};
handlePaste = (): void => {
setTimeout(() => this.save(this.state.value, this.state.value), 0);
};
handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
this.props.onClickLink(this.href, event);
};
handleCreateLink = async (value: string) => {
this.discardInputValue = true;
const { onCreateLink } = this.props;
value = value.trim();
if (value.length === 0) return;
if (onCreateLink) return onCreateLink(value);
};
handleRemoveLink = (): void => {
this.discardInputValue = true;
const { from, to, mark, view, onRemoveLink } = this.props;
const { state, dispatch } = this.props.view;
if (mark) {
dispatch(state.tr.removeMark(from, to, mark));
}
if (onRemoveLink) {
onRemoveLink();
}
view.focus();
};
handleSelectLink = (url: string, title: string) => (
event: React.MouseEvent
) => {
event.preventDefault();
this.save(url, title);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
};
moveSelectionToEnd = () => {
const { to, view } = this.props;
const { state, dispatch } = view;
dispatch(setTextSelection(to)(state.tr));
view.focus();
};
render() {
const { dictionary } = this.props;
const { value, selectedIndex } = this.state;
const results =
this.state.results[value.trim()] ||
this.state.results[this.state.previousValue] ||
[];
const looksLikeUrl = value.match(/^https?:\/\//i);
const suggestedLinkTitle = this.suggestedLinkTitle;
const showCreateLink =
!!this.props.onCreateLink &&
!(suggestedLinkTitle === this.initialValue) &&
suggestedLinkTitle.length > 0 &&
!looksLikeUrl;
const showResults =
!!suggestedLinkTitle && (showCreateLink || results.length > 0);
return (
<Wrapper>
<Input
value={value}
placeholder={
showCreateLink
? dictionary.findOrCreateDoc
: dictionary.searchOrPasteLink
}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
onChange={this.handleChange}
autoFocus={this.href === ""}
/>
<Tooltip tooltip={dictionary.openLink}>
<ToolbarButton onClick={this.handleOpenLink} disabled={!value}>
<OpenIcon color="currentColor" />
</ToolbarButton>
</Tooltip>
<Tooltip tooltip={dictionary.removeLink}>
<ToolbarButton onClick={this.handleRemoveLink}>
{this.initialValue ? (
<TrashIcon color="currentColor" />
) : (
<CloseIcon color="currentColor" />
)}
</ToolbarButton>
</Tooltip>
{showResults && (
<SearchResults id="link-search-results">
{results.map((result, index) => (
<LinkSearchResult
key={result.url}
title={result.title}
subtitle={result.subtitle}
icon={<DocumentIcon color="currentColor" />}
onMouseOver={() => this.handleFocusLink(index)}
onClick={this.handleSelectLink(result.url, result.title)}
selected={index === selectedIndex}
/>
))}
{showCreateLink && (
<LinkSearchResult
key="create"
title={suggestedLinkTitle}
subtitle={dictionary.createNewDoc}
icon={<PlusIcon color="currentColor" />}
onMouseOver={() => this.handleFocusLink(results.length)}
onClick={() => {
this.handleCreateLink(suggestedLinkTitle);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
}}
selected={results.length === selectedIndex}
/>
)}
</SearchResults>
)}
</Wrapper>
);
}
}
const Wrapper = styled(Flex)`
margin-left: -8px;
margin-right: -8px;
min-width: 336px;
pointer-events: all;
gap: 8px;
`;
const SearchResults = styled.ol`
background: ${(props) => props.theme.toolbarBackground};
position: absolute;
top: 100%;
width: 100%;
height: auto;
left: 0;
padding: 4px 8px 8px;
margin: 0;
margin-top: -3px;
margin-bottom: 0;
border-radius: 0 0 4px 4px;
overflow-y: auto;
max-height: 25vh;
@media (hover: none) and (pointer: coarse) {
position: fixed;
top: auto;
bottom: 40px;
border-radius: 0;
max-height: 50vh;
padding: 8px 8px 4px;
}
`;
export default LinkEditor;

View File

@@ -0,0 +1,84 @@
import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled from "styled-components";
type Props = {
onClick: (event: React.MouseEvent) => void;
onMouseOver: (event: React.MouseEvent) => void;
icon: React.ReactNode;
selected: boolean;
title: string;
subtitle?: string;
};
function LinkSearchResult({ title, subtitle, selected, icon, ...rest }: Props) {
const ref = React.useCallback(
(node: HTMLElement | null) => {
if (selected && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
block: "center",
boundary: (parent) => {
// All the parent elements of your target are checked until they
// reach the #link-search-results. Prevents body and other parent
// elements from being scrolled
return parent.id !== "link-search-results";
},
});
}
},
[selected]
);
return (
<ListItem ref={ref} compact={!subtitle} selected={selected} {...rest}>
<IconWrapper>{icon}</IconWrapper>
<div>
<Title>{title}</Title>
{subtitle ? <Subtitle selected={selected}>{subtitle}</Subtitle> : null}
</div>
</ListItem>
);
}
const IconWrapper = styled.span`
flex-shrink: 0;
margin-right: 4px;
opacity: 0.8;
color: ${(props) => props.theme.toolbarItem};
`;
const ListItem = styled.li<{
selected: boolean;
compact: boolean;
}>`
display: flex;
align-items: center;
padding: 8px;
border-radius: 2px;
color: ${(props) => props.theme.toolbarItem};
background: ${(props) =>
props.selected ? props.theme.toolbarHoverBackground : "transparent"};
font-family: ${(props) => props.theme.fontFamily};
text-decoration: none;
overflow: hidden;
white-space: nowrap;
cursor: pointer;
user-select: none;
line-height: ${(props) => (props.compact ? "inherit" : "1.2")};
height: ${(props) => (props.compact ? "28px" : "auto")};
`;
const Title = styled.div`
font-size: 14px;
font-weight: 500;
`;
const Subtitle = styled.div<{
selected: boolean;
}>`
font-size: 13px;
opacity: ${(props) => (props.selected ? 0.75 : 0.5)};
`;
export default LinkSearchResult;

View File

@@ -0,0 +1,151 @@
import { EditorView } from "prosemirror-view";
import * as React from "react";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
import { Dictionary } from "~/hooks/useDictionary";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor, { SearchResult } from "./LinkEditor";
type Props = {
isActive: boolean;
view: EditorView;
dictionary: Dictionary;
onCreateLink?: (title: string) => Promise<string>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onClickLink: (
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast?: (msg: string, code: string) => void;
onClose: () => void;
};
function isActive(props: Props) {
const { view } = props;
const { selection } = view.state;
try {
const paragraph = view.domAtPos(selection.from);
return props.isActive && !!paragraph.node;
} catch (err) {
return false;
}
}
export default class LinkToolbar extends React.Component<Props> {
menuRef = React.createRef<HTMLDivElement>();
state = {
left: -1000,
top: undefined,
};
componentDidMount() {
window.addEventListener("mousedown", this.handleClickOutside);
}
componentWillUnmount() {
window.removeEventListener("mousedown", this.handleClickOutside);
}
handleClickOutside = (event: Event) => {
if (
event.target instanceof HTMLElement &&
this.menuRef.current &&
this.menuRef.current.contains(event.target)
) {
return;
}
this.props.onClose();
};
handleOnCreateLink = async (title: string) => {
const { dictionary, onCreateLink, view, onClose, onShowToast } = this.props;
onClose();
this.props.view.focus();
if (!onCreateLink) {
return;
}
const { dispatch, state } = view;
const { from, to } = state.selection;
if (from !== to) {
// selection must be collapsed
return;
}
const href = `creating#${title}`;
// Insert a placeholder link
dispatch(
view.state.tr
.insertText(title, from, to)
.addMark(
from,
to + title.length,
state.schema.marks.link.create({ href })
)
);
createAndInsertLink(view, title, href, {
onCreateLink,
onShowToast,
dictionary,
});
};
handleOnSelectLink = ({
href,
title,
}: {
href: string;
title: string;
from: number;
to: number;
}) => {
const { view, onClose } = this.props;
onClose();
this.props.view.focus();
const { dispatch, state } = view;
const { from, to } = state.selection;
if (from !== to) {
// selection must be collapsed
return;
}
dispatch(
view.state.tr
.insertText(title, from, to)
.addMark(
from,
to + title.length,
state.schema.marks.link.create({ href })
)
);
};
render() {
const { onCreateLink, onClose, ...rest } = this.props;
const { selection } = this.props.view.state;
const active = isActive(this.props);
return (
<FloatingToolbar ref={this.menuRef} active={active} {...rest}>
{active && (
<LinkEditor
from={selection.from}
to={selection.to}
onCreateLink={onCreateLink ? this.handleOnCreateLink : undefined}
onSelectLink={this.handleOnSelectLink}
onRemoveLink={onClose}
{...rest}
/>
)}
</FloatingToolbar>
);
}
}

View File

@@ -0,0 +1,255 @@
import { some } from "lodash";
import { NodeSelection, TextSelection } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { Portal } from "react-portal";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import getColumnIndex from "@shared/editor/queries/getColumnIndex";
import getMarkRange from "@shared/editor/queries/getMarkRange";
import getRowIndex from "@shared/editor/queries/getRowIndex";
import isMarkActive from "@shared/editor/queries/isMarkActive";
import isNodeActive from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
import getDividerMenuItems from "../menus/divider";
import getFormattingMenuItems from "../menus/formatting";
import getImageMenuItems from "../menus/image";
import getTableMenuItems from "../menus/table";
import getTableColMenuItems from "../menus/tableCol";
import getTableRowMenuItems from "../menus/tableRow";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor, { SearchResult } from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
type Props = {
dictionary: Dictionary;
rtl: boolean;
isTemplate: boolean;
commands: Record<string, any>;
onOpen: () => void;
onClose: () => void;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onClickLink: (
href: string,
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
onCreateLink?: (title: string) => Promise<string>;
onShowToast?: (msg: string, code: string) => void;
view: EditorView;
};
function isVisible(props: Props) {
const { view } = props;
const { selection } = view.state;
if (!selection) return false;
if (selection.empty) return false;
if (selection instanceof NodeSelection && selection.node.type.name === "hr") {
return true;
}
if (
selection instanceof NodeSelection &&
selection.node.type.name === "image"
) {
return true;
}
if (selection instanceof NodeSelection) {
return false;
}
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
return some(nodes, (n) => n.content.size);
}
export default class SelectionToolbar extends React.Component<Props> {
isActive = false;
menuRef = React.createRef<HTMLDivElement>();
componentDidUpdate(): void {
const visible = isVisible(this.props);
if (this.isActive && !visible) {
this.isActive = false;
this.props.onClose();
}
if (!this.isActive && visible) {
this.isActive = true;
this.props.onOpen();
}
}
componentDidMount(): void {
window.addEventListener("mouseup", this.handleClickOutside);
}
componentWillUnmount(): void {
window.removeEventListener("mouseup", this.handleClickOutside);
}
handleClickOutside = (ev: MouseEvent): void => {
if (
ev.target instanceof HTMLElement &&
this.menuRef.current &&
this.menuRef.current.contains(ev.target)
) {
return;
}
if (!this.isActive) {
return;
}
const { view } = this.props;
if (view.hasFocus()) {
return;
}
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
);
};
handleOnCreateLink = async (title: string): Promise<void> => {
const { dictionary, onCreateLink, view, onShowToast } = this.props;
if (!onCreateLink) {
return;
}
const { dispatch, state } = view;
const { from, to } = state.selection;
if (from === to) {
// selection cannot be collapsed
return;
}
const href = `creating#${title}`;
const markType = state.schema.marks.link;
// Insert a placeholder link
dispatch(
view.state.tr
.removeMark(from, to, markType)
.addMark(from, to, markType.create({ href }))
);
createAndInsertLink(view, title, href, {
onCreateLink,
onShowToast,
dictionary,
});
};
handleOnSelectLink = ({
href,
from,
to,
}: {
href: string;
from: number;
to: number;
}): void => {
const { view } = this.props;
const { state, dispatch } = view;
const markType = state.schema.marks.link;
dispatch(
state.tr
.removeMark(from, to, markType)
.addMark(from, to, markType.create({ href }))
);
};
render() {
const { dictionary, onCreateLink, isTemplate, rtl, ...rest } = this.props;
const { view } = rest;
const { state } = view;
const { selection }: { selection: any } = state;
const isCodeSelection = isNodeActive(state.schema.nodes.code_block)(state);
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
// toolbar is disabled in code blocks, no bold / italic etc
if (isCodeSelection) {
return null;
}
const colIndex = getColumnIndex(
(state.selection as unknown) as CellSelection
);
const rowIndex = getRowIndex((state.selection as unknown) as CellSelection);
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
const link = isMarkActive(state.schema.marks.link)(state);
const range = getMarkRange(selection.$from, state.schema.marks.link);
const isImageSelection =
selection.node && selection.node.type.name === "image";
let isTextSelection = false;
let items: MenuItem[] = [];
if (isTableSelection) {
items = getTableMenuItems(dictionary);
} else if (colIndex !== undefined) {
items = getTableColMenuItems(state, colIndex, rtl, dictionary);
} else if (rowIndex !== undefined) {
items = getTableRowMenuItems(state, rowIndex, dictionary);
} else if (isImageSelection) {
items = getImageMenuItems(state, dictionary);
} else if (isDividerSelection) {
items = getDividerMenuItems(state, dictionary);
} else {
items = getFormattingMenuItems(state, isTemplate, dictionary);
isTextSelection = true;
}
// Some extensions may be disabled, remove corresponding items
items = items.filter((item) => {
if (item.name === "separator") return true;
if (item.name && !this.props.commands[item.name]) return false;
return true;
});
items = filterExcessSeparators(items);
if (!items.length) {
return null;
}
const selectionText = state.doc.cut(
state.selection.from,
state.selection.to
).textContent;
if (isTextSelection && !selectionText) {
return null;
}
return (
<Portal>
<FloatingToolbar
view={view}
active={isVisible(this.props)}
ref={this.menuRef}
>
{link && range ? (
<LinkEditor
dictionary={dictionary}
mark={range.mark}
from={range.from}
to={range.to}
onCreateLink={onCreateLink ? this.handleOnCreateLink : undefined}
onSelectLink={this.handleOnSelectLink}
{...rest}
/>
) : (
<ToolbarMenu items={items} {...rest} />
)}
</FloatingToolbar>
</Portal>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
import styled from "styled-components";
type Props = { active?: boolean; disabled?: boolean };
export default styled.button<Props>`
display: inline-block;
flex: 0;
width: 24px;
height: 24px;
cursor: pointer;
border: none;
background: none;
transition: opacity 100ms ease-in-out;
padding: 0;
opacity: 0.7;
outline: none;
pointer-events: all;
position: relative;
color: ${(props) => props.theme.toolbarItem};
&:hover {
opacity: 1;
}
&:disabled {
opacity: 0.3;
cursor: default;
}
&:before {
position: absolute;
content: "";
top: -4px;
right: -4px;
left: -4px;
bottom: -4px;
}
${(props) => props.active && "opacity: 1;"};
`;

View File

@@ -0,0 +1,54 @@
import { EditorView } from "prosemirror-view";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import { CommandFactory } from "@shared/editor/lib/Extension";
import { MenuItem } from "@shared/editor/types";
import ToolbarButton from "./ToolbarButton";
import ToolbarSeparator from "./ToolbarSeparator";
import Tooltip from "./Tooltip";
type Props = {
commands: Record<string, CommandFactory>;
view: EditorView;
items: MenuItem[];
};
const FlexibleWrapper = styled.div`
display: flex;
gap: 8px;
`;
function ToolbarMenu(props: Props) {
const theme = useTheme();
const { view, items } = props;
const { state } = view;
return (
<FlexibleWrapper>
{items.map((item, index) => {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || !item.icon) {
return null;
}
const Icon = item.icon;
const isActive = item.active ? item.active(state) : false;
return (
<Tooltip tooltip={item.tooltip}>
<ToolbarButton
key={index}
onClick={() => item.name && props.commands[item.name](item.attrs)}
active={isActive}
>
<Icon color={theme.toolbarItem} />
</ToolbarButton>
</Tooltip>
);
})}
</FlexibleWrapper>
);
}
export default ToolbarMenu;

View File

@@ -0,0 +1,12 @@
import styled from "styled-components";
const Separator = styled.div`
height: 24px;
width: 2px;
background: ${(props) => props.theme.toolbarItem};
opacity: 0.3;
display: inline-block;
margin-left: 8px;
`;
export default Separator;

View File

@@ -0,0 +1,20 @@
import * as React from "react";
import styled from "styled-components";
import Tooltip from "~/components/Tooltip";
type Props = {
children: React.ReactNode;
tooltip?: string;
};
const WrappedTooltip = ({ children, tooltip }: Props) => (
<Tooltip offset="0, 8" delay={150} tooltip={tooltip} placement="top">
<TooltipContent>{children}</TooltipContent>
</Tooltip>
);
const TooltipContent = styled.span`
outline: none;
`;
export default WrappedTooltip;

View File

@@ -0,0 +1,11 @@
import * as React from "react";
import { DefaultTheme, useTheme } from "styled-components";
type Props = {
children: (theme: DefaultTheme) => React.ReactElement;
};
export default function WithTheme({ children }: Props) {
const theme = useTheme();
return children(theme);
}

818
app/editor/index.tsx Normal file
View File

@@ -0,0 +1,818 @@
/* global File Promise */
import { PluginSimple } from "markdown-it";
import { baseKeymap } from "prosemirror-commands";
import { dropCursor } from "prosemirror-dropcursor";
import { gapCursor } from "prosemirror-gapcursor";
import { inputRules, InputRule } from "prosemirror-inputrules";
import { keymap } from "prosemirror-keymap";
import { MarkdownParser } from "prosemirror-markdown";
import { Schema, NodeSpec, MarkSpec, Node } from "prosemirror-model";
import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
import { selectColumn, selectRow, selectTable } from "prosemirror-utils";
import { Decoration, EditorView } from "prosemirror-view";
import * as React from "react";
import { DefaultTheme, ThemeProps } from "styled-components";
import Extension from "@shared/editor/lib/Extension";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import headingToSlug from "@shared/editor/lib/headingToSlug";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
// marks
import Bold from "@shared/editor/marks/Bold";
import Code from "@shared/editor/marks/Code";
import Highlight from "@shared/editor/marks/Highlight";
import Italic from "@shared/editor/marks/Italic";
import Link from "@shared/editor/marks/Link";
import TemplatePlaceholder from "@shared/editor/marks/Placeholder";
import Strikethrough from "@shared/editor/marks/Strikethrough";
import Underline from "@shared/editor/marks/Underline";
// nodes
import Blockquote from "@shared/editor/nodes/Blockquote";
import BulletList from "@shared/editor/nodes/BulletList";
import CheckboxItem from "@shared/editor/nodes/CheckboxItem";
import CheckboxList from "@shared/editor/nodes/CheckboxList";
import CodeBlock from "@shared/editor/nodes/CodeBlock";
import CodeFence from "@shared/editor/nodes/CodeFence";
import Doc from "@shared/editor/nodes/Doc";
import Embed from "@shared/editor/nodes/Embed";
import Emoji from "@shared/editor/nodes/Emoji";
import HardBreak from "@shared/editor/nodes/HardBreak";
import Heading from "@shared/editor/nodes/Heading";
import HorizontalRule from "@shared/editor/nodes/HorizontalRule";
import Image from "@shared/editor/nodes/Image";
import ListItem from "@shared/editor/nodes/ListItem";
import Notice from "@shared/editor/nodes/Notice";
import OrderedList from "@shared/editor/nodes/OrderedList";
import Paragraph from "@shared/editor/nodes/Paragraph";
import ReactNode from "@shared/editor/nodes/ReactNode";
import Table from "@shared/editor/nodes/Table";
import TableCell from "@shared/editor/nodes/TableCell";
import TableHeadCell from "@shared/editor/nodes/TableHeadCell";
import TableRow from "@shared/editor/nodes/TableRow";
import Text from "@shared/editor/nodes/Text";
// plugins
import BlockMenuTrigger from "@shared/editor/plugins/BlockMenuTrigger";
import EmojiTrigger from "@shared/editor/plugins/EmojiTrigger";
import Folding from "@shared/editor/plugins/Folding";
import History from "@shared/editor/plugins/History";
import Keys from "@shared/editor/plugins/Keys";
import MaxLength from "@shared/editor/plugins/MaxLength";
import PasteHandler from "@shared/editor/plugins/PasteHandler";
import Placeholder from "@shared/editor/plugins/Placeholder";
import SmartText from "@shared/editor/plugins/SmartText";
import TrailingNode from "@shared/editor/plugins/TrailingNode";
import { EmbedDescriptor, ToastType } from "@shared/editor/types";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import BlockMenu from "./components/BlockMenu";
import ComponentView from "./components/ComponentView";
import EmojiMenu from "./components/EmojiMenu";
import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar";
import SelectionToolbar from "./components/SelectionToolbar";
import EditorContainer from "./components/Styles";
import WithTheme from "./components/WithTheme";
export { default as Extension } from "@shared/editor/lib/Extension";
export type Props = {
/** An optional identifier for the editor context. It is used to persist local settings */
id?: string;
/** The editor content, should only be changed if you wish to reset the content */
value?: string;
/** The initial editor content */
defaultValue: string;
/** Placeholder displayed when the editor is empty */
placeholder: string;
/** Additional extensions to load into the editor */
extensions?: Extension[];
/** If the editor should be focused on mount */
autoFocus?: boolean;
/** If the editor should not allow editing */
readOnly?: boolean;
/** If the editor should still allow editing checkboxes when it is readOnly */
readOnlyWriteCheckboxes?: boolean;
/** A dictionary of translated strings used in the editor */
dictionary: Dictionary;
/** The reading direction of the text content, if known */
dir?: "rtl" | "ltr";
/** If the editor should vertically grow to fill available space */
grow?: boolean;
/** If the editor should display template options such as inserting placeholders */
template?: boolean;
/** An enforced maximum content length */
maxLength?: number;
/** Heading id to scroll to when the editor has loaded */
scrollTo?: string;
/** Callback for handling uploaded images, should return the url of uploaded file */
uploadImage?: (file: File) => Promise<string>;
/** Callback when editor is blurred, as native input */
onBlur?: () => void;
/** Callback when editor is focused, as native input */
onFocus?: () => void;
/** Callback when user uses save key combo */
onSave?: (options: { done: boolean }) => void;
/** Callback when user uses cancel key combo */
onCancel?: () => void;
/** Callback when user changes editor content */
onChange?: (value: () => string) => void;
/** Callback when a file upload begins */
onImageUploadStart?: () => void;
/** Callback when a file upload ends */
onImageUploadStop?: () => void;
/** Callback when a link is created, should return url to created document */
onCreateLink?: (title: string) => Promise<string>;
/** Callback when user searches for documents from link insert interface */
onSearchLink?: (term: string) => Promise<SearchResult[]>;
/** Callback when user clicks on any link in the document */
onClickLink: (
href: string,
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
/** Callback when user hovers on any link in the document */
onHoverLink?: (event: MouseEvent) => boolean;
/** Callback when user clicks on any hashtag in the document */
onClickHashtag?: (tag: string, event: MouseEvent) => void;
/** Callback when user presses any key with document focused */
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
/** Collection of embed types to render in the document */
embeds: EmbedDescriptor[];
/** Callback when a toast message is triggered (eg "link copied") */
onShowToast?: (message: string, code: ToastType) => void;
className?: string;
style?: React.CSSProperties;
};
type State = {
/** If the document text has been detected as using RTL script */
isRTL: boolean;
/** If the editor is currently focused */
isEditorFocused: boolean;
/** If the toolbar for a text selection is visible */
selectionMenuOpen: boolean;
/** If the block insert menu is visible (triggered with /) */
blockMenuOpen: boolean;
/** If the insert link toolbar is visible */
linkMenuOpen: boolean;
/** The search term currently filtering the block menu */
blockMenuSearch: string;
/** If the emoji insert menu is visible */
emojiMenuOpen: boolean;
};
/**
* The shared editor at the root of all rich editable text in Outline. Do not
* use this component directly, it should by lazy loaded. Use
* ~/components/Editor instead.
*/
export class Editor extends React.PureComponent<
Props & ThemeProps<DefaultTheme>,
State
> {
static defaultProps = {
defaultValue: "",
dir: "auto",
placeholder: "Write something nice…",
onImageUploadStart: () => {
// no default behavior
},
onImageUploadStop: () => {
// no default behavior
},
embeds: [],
extensions: [],
};
state = {
isRTL: false,
isEditorFocused: false,
selectionMenuOpen: false,
blockMenuOpen: false,
linkMenuOpen: false,
blockMenuSearch: "",
emojiMenuOpen: false,
};
isBlurred: boolean;
extensions: ExtensionManager;
element?: HTMLElement | null;
view: EditorView;
schema: Schema;
serializer: MarkdownSerializer;
parser: MarkdownParser;
pasteParser: MarkdownParser;
plugins: Plugin[];
keymaps: Plugin[];
inputRules: InputRule[];
nodeViews: {
[name: string]: (
node: Node,
view: EditorView,
getPos: () => number,
decorations: Decoration<{
[key: string]: any;
}>[]
) => ComponentView;
};
nodes: { [name: string]: NodeSpec };
marks: { [name: string]: MarkSpec };
commands: Record<string, any>;
rulePlugins: PluginSimple[];
componentDidMount() {
this.init();
if (this.props.scrollTo) {
this.scrollToAnchor(this.props.scrollTo);
}
this.calculateDir();
if (this.props.readOnly) return;
if (this.props.autoFocus) {
this.focusAtEnd();
}
}
componentDidUpdate(prevProps: Props) {
// Allow changes to the 'value' prop to update the editor from outside
if (this.props.value && prevProps.value !== this.props.value) {
const newState = this.createState(this.props.value);
this.view.updateState(newState);
}
// pass readOnly changes through to underlying editor instance
if (prevProps.readOnly !== this.props.readOnly) {
this.view.update({
...this.view.props,
editable: () => !this.props.readOnly,
});
}
if (this.props.scrollTo && this.props.scrollTo !== prevProps.scrollTo) {
this.scrollToAnchor(this.props.scrollTo);
}
// Focus at the end of the document if switching from readOnly and autoFocus
// is set to true
if (prevProps.readOnly && !this.props.readOnly && this.props.autoFocus) {
this.focusAtEnd();
}
if (prevProps.dir !== this.props.dir) {
this.calculateDir();
}
if (
!this.isBlurred &&
!this.state.isEditorFocused &&
!this.state.blockMenuOpen &&
!this.state.linkMenuOpen &&
!this.state.selectionMenuOpen
) {
this.isBlurred = true;
if (this.props.onBlur) {
this.props.onBlur();
}
}
if (
this.isBlurred &&
(this.state.isEditorFocused ||
this.state.blockMenuOpen ||
this.state.linkMenuOpen ||
this.state.selectionMenuOpen)
) {
this.isBlurred = false;
if (this.props.onFocus) {
this.props.onFocus();
}
}
}
init() {
this.extensions = this.createExtensions();
this.nodes = this.createNodes();
this.marks = this.createMarks();
this.schema = this.createSchema();
this.plugins = this.createPlugins();
this.rulePlugins = this.createRulePlugins();
this.keymaps = this.createKeymaps();
this.serializer = this.createSerializer();
this.parser = this.createParser();
this.pasteParser = this.createPasteParser();
this.inputRules = this.createInputRules();
this.nodeViews = this.createNodeViews();
this.view = this.createView();
this.commands = this.createCommands();
}
createExtensions() {
const { dictionary } = this.props;
// adding nodes here? Update schema.ts for serialization on the server
return new ExtensionManager(
[
...[
new Doc(),
new HardBreak(),
new Paragraph(),
new Blockquote(),
new CodeBlock({
dictionary,
onShowToast: this.props.onShowToast,
}),
new CodeFence({
dictionary,
onShowToast: this.props.onShowToast,
}),
new Emoji(),
new Text(),
new CheckboxList(),
new CheckboxItem(),
new BulletList(),
new Embed({ embeds: this.props.embeds }),
new ListItem(),
new Notice({
dictionary,
}),
new Heading({
dictionary,
onShowToast: this.props.onShowToast,
}),
new HorizontalRule(),
new Image({
dictionary,
uploadImage: this.props.uploadImage,
onImageUploadStart: this.props.onImageUploadStart,
onImageUploadStop: this.props.onImageUploadStop,
onShowToast: this.props.onShowToast,
}),
new Table(),
new TableCell({
onSelectTable: this.handleSelectTable,
onSelectRow: this.handleSelectRow,
}),
new TableHeadCell({
onSelectColumn: this.handleSelectColumn,
}),
new TableRow(),
new Bold(),
new Code(),
new Highlight(),
new Italic(),
new TemplatePlaceholder(),
new Underline(),
new Link({
onKeyboardShortcut: this.handleOpenLinkMenu,
onClickLink: this.props.onClickLink,
onClickHashtag: this.props.onClickHashtag,
onHoverLink: this.props.onHoverLink,
}),
new Strikethrough(),
new OrderedList(),
new History(),
new Folding(),
new SmartText(),
new TrailingNode(),
new PasteHandler(),
new Keys({
onBlur: this.handleEditorBlur,
onFocus: this.handleEditorFocus,
onSave: this.handleSave,
onSaveAndExit: this.handleSaveAndExit,
onCancel: this.props.onCancel,
}),
new BlockMenuTrigger({
dictionary,
onOpen: this.handleOpenBlockMenu,
onClose: this.handleCloseBlockMenu,
}),
new EmojiTrigger({
onOpen: (search: string) => {
this.setState({ emojiMenuOpen: true, blockMenuSearch: search });
},
onClose: () => {
this.setState({ emojiMenuOpen: false });
},
}),
new Placeholder({
placeholder: this.props.placeholder,
}),
new MaxLength({
maxLength: this.props.maxLength,
}),
],
...(this.props.extensions || []),
],
this
);
}
createPlugins() {
return this.extensions.plugins;
}
createRulePlugins() {
return this.extensions.rulePlugins;
}
createKeymaps() {
return this.extensions.keymaps({
schema: this.schema,
});
}
createInputRules() {
return this.extensions.inputRules({
schema: this.schema,
});
}
createNodeViews() {
return this.extensions.extensions
.filter((extension: ReactNode) => extension.component)
.reduce((nodeViews, extension: ReactNode) => {
const nodeView = (
node: Node,
view: EditorView,
getPos: () => number,
decorations: Decoration<{
[key: string]: any;
}>[]
) => {
return new ComponentView(extension.component, {
editor: this,
extension,
node,
view,
getPos,
decorations,
});
};
return {
...nodeViews,
[extension.name]: nodeView,
};
}, {});
}
createCommands() {
return this.extensions.commands({
schema: this.schema,
view: this.view,
});
}
createNodes() {
return this.extensions.nodes;
}
createMarks() {
return this.extensions.marks;
}
createSchema() {
return new Schema({
nodes: this.nodes,
marks: this.marks,
});
}
createSerializer() {
return this.extensions.serializer();
}
createParser() {
return this.extensions.parser({
schema: this.schema,
plugins: this.rulePlugins,
});
}
createPasteParser() {
return this.extensions.parser({
schema: this.schema,
rules: { linkify: true },
plugins: this.rulePlugins,
});
}
createState(value?: string) {
const doc = this.createDocument(value || this.props.defaultValue);
return EditorState.create({
schema: this.schema,
doc,
plugins: [
...this.plugins,
...this.keymaps,
dropCursor({ color: this.props.theme.cursor }),
gapCursor(),
inputRules({
rules: this.inputRules,
}),
keymap(baseKeymap),
],
});
}
createDocument(content: string) {
return this.parser.parse(content);
}
createView() {
if (!this.element) {
throw new Error("createView called before ref available");
}
const isEditingCheckbox = (tr: Transaction) => {
return tr.steps.some(
(step: any) =>
step.slice?.content?.firstChild?.type.name ===
this.schema.nodes.checkbox_item.name
);
};
const self = this; // eslint-disable-line
const view = new EditorView(this.element, {
state: this.createState(this.props.value),
editable: () => !this.props.readOnly,
nodeViews: this.nodeViews,
dispatchTransaction: function (transaction) {
// callback is bound to have the view instance as its this binding
const { state, transactions } = this.state.applyTransaction(
transaction
);
this.updateState(state);
// If any of the transactions being dispatched resulted in the doc
// changing then call our own change handler to let the outside world
// know
if (
transactions.some((tr) => tr.docChanged) &&
(!self.props.readOnly ||
(self.props.readOnlyWriteCheckboxes &&
transactions.some(isEditingCheckbox)))
) {
self.handleChange();
}
self.calculateDir();
// Because Prosemirror and React are not linked we must tell React that
// a render is needed whenever the Prosemirror state changes.
self.forceUpdate();
},
});
// Tell third-party libraries and screen-readers that this is an input
view.dom.setAttribute("role", "textbox");
return view;
}
scrollToAnchor(hash: string) {
if (!hash) return;
try {
const element = document.querySelector(hash);
if (element) element.scrollIntoView({ behavior: "smooth" });
} catch (err) {
// querySelector will throw an error if the hash begins with a number
// or contains a period. This is protected against now by safeSlugify
// however previous links may be in the wild.
console.warn(`Attempted to scroll to invalid hash: ${hash}`, err);
}
}
calculateDir = () => {
if (!this.element) return;
const isRTL =
this.props.dir === "rtl" ||
getComputedStyle(this.element).direction === "rtl";
if (this.state.isRTL !== isRTL) {
this.setState({ isRTL });
}
};
value = (): string => {
return this.serializer.serialize(this.view.state.doc);
};
handleChange = () => {
if (!this.props.onChange) return;
this.props.onChange(() => {
return this.value();
});
};
handleSave = () => {
const { onSave } = this.props;
if (onSave) {
onSave({ done: false });
}
};
handleSaveAndExit = () => {
const { onSave } = this.props;
if (onSave) {
onSave({ done: true });
}
};
handleEditorBlur = () => {
this.setState({ isEditorFocused: false });
};
handleEditorFocus = () => {
this.setState({ isEditorFocused: true });
};
handleOpenSelectionMenu = () => {
this.setState({ blockMenuOpen: false, selectionMenuOpen: true });
};
handleCloseSelectionMenu = () => {
this.setState({ selectionMenuOpen: false });
};
handleOpenLinkMenu = () => {
this.setState({ blockMenuOpen: false, linkMenuOpen: true });
};
handleCloseLinkMenu = () => {
this.setState({ linkMenuOpen: false });
};
handleOpenBlockMenu = (search: string) => {
this.setState({ blockMenuOpen: true, blockMenuSearch: search });
};
handleCloseBlockMenu = () => {
if (!this.state.blockMenuOpen) return;
this.setState({ blockMenuOpen: false });
};
handleSelectRow = (index: number, state: EditorState) => {
this.view.dispatch(selectRow(index)(state.tr));
};
handleSelectColumn = (index: number, state: EditorState) => {
this.view.dispatch(selectColumn(index)(state.tr));
};
handleSelectTable = (state: EditorState) => {
this.view.dispatch(selectTable(state.tr));
};
// 'public' methods
focusAtStart = () => {
const selection = Selection.atStart(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection);
this.view.dispatch(transaction);
this.view.focus();
};
focusAtEnd = () => {
const selection = Selection.atEnd(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection);
this.view.dispatch(transaction);
this.view.focus();
};
getHeadings = () => {
const headings: { title: string; level: number; id: string }[] = [];
const previouslySeen = {};
this.view.state.doc.forEach((node) => {
if (node.type.name === "heading") {
// calculate the optimal slug
const slug = headingToSlug(node);
let id = slug;
// check if we've already used it, and if so how many times?
// Make the new id based on that number ensuring that we have
// unique ID's even when headings are identical
if (previouslySeen[slug] > 0) {
id = headingToSlug(node, previouslySeen[slug]);
}
// record that we've seen this slug for the next loop
previouslySeen[slug] =
previouslySeen[slug] !== undefined ? previouslySeen[slug] + 1 : 1;
headings.push({
title: node.textContent,
level: node.attrs.level,
id,
});
}
});
return headings;
};
render() {
const {
dir,
readOnly,
readOnlyWriteCheckboxes,
grow,
style,
className,
dictionary,
onKeyDown,
} = this.props;
const { isRTL } = this.state;
return (
<Flex
onKeyDown={onKeyDown}
style={style}
className={className}
align="flex-start"
justify="center"
dir={dir}
column
>
<EditorContainer
dir={dir}
rtl={isRTL}
grow={grow}
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnlyWriteCheckboxes}
ref={(ref) => (this.element = ref)}
/>
{!readOnly && this.view && (
<React.Fragment>
<SelectionToolbar
view={this.view}
dictionary={dictionary}
commands={this.commands}
rtl={isRTL}
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionMenu}
onClose={this.handleCloseSelectionMenu}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
/>
<LinkToolbar
view={this.view}
dictionary={dictionary}
isActive={this.state.linkMenuOpen}
onCreateLink={this.props.onCreateLink}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onShowToast={this.props.onShowToast}
onClose={this.handleCloseLinkMenu}
/>
<EmojiMenu
view={this.view}
commands={this.commands}
dictionary={dictionary}
rtl={isRTL}
isActive={this.state.emojiMenuOpen}
search={this.state.blockMenuSearch}
onClose={() => this.setState({ emojiMenuOpen: false })}
/>
<BlockMenu
view={this.view}
commands={this.commands}
dictionary={dictionary}
rtl={isRTL}
isActive={this.state.blockMenuOpen}
search={this.state.blockMenuSearch}
onClose={this.handleCloseBlockMenu}
uploadImage={this.props.uploadImage}
onLinkToolbarOpen={this.handleOpenLinkMenu}
onImageUploadStart={this.props.onImageUploadStart}
onImageUploadStop={this.props.onImageUploadStop}
onShowToast={this.props.onShowToast}
embeds={this.props.embeds}
/>
</React.Fragment>
)}
</Flex>
);
}
}
const EditorWithTheme = React.forwardRef<Editor, Props>((props: Props, ref) => {
return (
<WithTheme>
{(theme) => <Editor theme={theme} {...props} ref={ref} />}
</WithTheme>
);
});
export default EditorWithTheme;

148
app/editor/menus/block.ts Normal file
View File

@@ -0,0 +1,148 @@
import {
BlockQuoteIcon,
BulletedListIcon,
CodeIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
HorizontalRuleIcon,
OrderedListIcon,
PageBreakIcon,
TableIcon,
TodoListIcon,
ImageIcon,
StarredIcon,
WarningIcon,
InfoIcon,
LinkIcon,
} from "outline-icons";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
const SSR = typeof window === "undefined";
const isMac = !SSR && window.navigator.platform === "MacIntel";
const mod = isMac ? "⌘" : "ctrl";
export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
return [
{
name: "heading",
title: dictionary.h1,
keywords: "h1 heading1 title",
icon: Heading1Icon,
shortcut: "^ ⇧ 1",
attrs: { level: 1 },
},
{
name: "heading",
title: dictionary.h2,
keywords: "h2 heading2",
icon: Heading2Icon,
shortcut: "^ ⇧ 2",
attrs: { level: 2 },
},
{
name: "heading",
title: dictionary.h3,
keywords: "h3 heading3",
icon: Heading3Icon,
shortcut: "^ ⇧ 3",
attrs: { level: 3 },
},
{
name: "separator",
},
{
name: "checkbox_list",
title: dictionary.checkboxList,
icon: TodoListIcon,
keywords: "checklist checkbox task",
shortcut: "^ ⇧ 7",
},
{
name: "bullet_list",
title: dictionary.bulletList,
icon: BulletedListIcon,
shortcut: "^ ⇧ 8",
},
{
name: "ordered_list",
title: dictionary.orderedList,
icon: OrderedListIcon,
shortcut: "^ ⇧ 9",
},
{
name: "separator",
},
{
name: "table",
title: dictionary.table,
icon: TableIcon,
attrs: { rowsCount: 3, colsCount: 3 },
},
{
name: "blockquote",
title: dictionary.quote,
icon: BlockQuoteIcon,
shortcut: `${mod} ]`,
},
{
name: "code_block",
title: dictionary.codeBlock,
icon: CodeIcon,
shortcut: "^ ⇧ \\",
keywords: "script",
},
{
name: "hr",
title: dictionary.hr,
icon: HorizontalRuleIcon,
shortcut: `${mod} _`,
keywords: "horizontal rule break line",
},
{
name: "hr",
title: dictionary.pageBreak,
icon: PageBreakIcon,
keywords: "page print break line",
attrs: { markup: "***" },
},
{
name: "image",
title: dictionary.image,
icon: ImageIcon,
keywords: "picture photo",
},
{
name: "link",
title: dictionary.link,
icon: LinkIcon,
shortcut: `${mod} k`,
keywords: "link url uri href",
},
{
name: "separator",
},
{
name: "container_notice",
title: dictionary.infoNotice,
icon: InfoIcon,
keywords: "container_notice card information",
attrs: { style: "info" },
},
{
name: "container_notice",
title: dictionary.warningNotice,
icon: WarningIcon,
keywords: "container_notice card error",
attrs: { style: "warning" },
},
{
name: "container_notice",
title: dictionary.tipNotice,
icon: StarredIcon,
keywords: "container_notice card suggestion",
attrs: { style: "tip" },
},
];
}

View File

@@ -0,0 +1,29 @@
import { PageBreakIcon, HorizontalRuleIcon } from "outline-icons";
import { EditorState } from "prosemirror-state";
import isNodeActive from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
export default function dividerMenuItems(
state: EditorState,
dictionary: Dictionary
): MenuItem[] {
const { schema } = state;
return [
{
name: "hr",
tooltip: dictionary.pageBreak,
attrs: { markup: "***" },
active: isNodeActive(schema.nodes.hr, { markup: "***" }),
icon: PageBreakIcon,
},
{
name: "hr",
tooltip: dictionary.hr,
attrs: { markup: "---" },
active: isNodeActive(schema.nodes.hr, { markup: "---" }),
icon: HorizontalRuleIcon,
},
];
}

View File

@@ -0,0 +1,135 @@
import {
BoldIcon,
CodeIcon,
Heading1Icon,
Heading2Icon,
BlockQuoteIcon,
LinkIcon,
StrikethroughIcon,
OrderedListIcon,
BulletedListIcon,
TodoListIcon,
InputIcon,
HighlightIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { isInTable } from "prosemirror-tables";
import isInList from "@shared/editor/queries/isInList";
import isMarkActive from "@shared/editor/queries/isMarkActive";
import isNodeActive from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
export default function formattingMenuItems(
state: EditorState,
isTemplate: boolean,
dictionary: Dictionary
): MenuItem[] {
const { schema } = state;
const isTable = isInTable(state);
const isList = isInList(state);
const allowBlocks = !isTable && !isList;
return [
{
name: "placeholder",
tooltip: dictionary.placeholder,
icon: InputIcon,
active: isMarkActive(schema.marks.placeholder),
visible: isTemplate,
},
{
name: "separator",
visible: isTemplate,
},
{
name: "strong",
tooltip: dictionary.strong,
icon: BoldIcon,
active: isMarkActive(schema.marks.strong),
},
{
name: "strikethrough",
tooltip: dictionary.strikethrough,
icon: StrikethroughIcon,
active: isMarkActive(schema.marks.strikethrough),
},
{
name: "highlight",
tooltip: dictionary.mark,
icon: HighlightIcon,
active: isMarkActive(schema.marks.highlight),
visible: !isTemplate,
},
{
name: "code_inline",
tooltip: dictionary.codeInline,
icon: CodeIcon,
active: isMarkActive(schema.marks.code_inline),
},
{
name: "separator",
visible: allowBlocks,
},
{
name: "heading",
tooltip: dictionary.heading,
icon: Heading1Icon,
active: isNodeActive(schema.nodes.heading, { level: 1 }),
attrs: { level: 1 },
visible: allowBlocks,
},
{
name: "heading",
tooltip: dictionary.subheading,
icon: Heading2Icon,
active: isNodeActive(schema.nodes.heading, { level: 2 }),
attrs: { level: 2 },
visible: allowBlocks,
},
{
name: "blockquote",
tooltip: dictionary.quote,
icon: BlockQuoteIcon,
active: isNodeActive(schema.nodes.blockquote),
attrs: { level: 2 },
visible: allowBlocks,
},
{
name: "separator",
visible: allowBlocks || isList,
},
{
name: "checkbox_list",
tooltip: dictionary.checkboxList,
icon: TodoListIcon,
keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list),
visible: allowBlocks || isList,
},
{
name: "bullet_list",
tooltip: dictionary.bulletList,
icon: BulletedListIcon,
active: isNodeActive(schema.nodes.bullet_list),
visible: allowBlocks || isList,
},
{
name: "ordered_list",
tooltip: dictionary.orderedList,
icon: OrderedListIcon,
active: isNodeActive(schema.nodes.ordered_list),
visible: allowBlocks || isList,
},
{
name: "separator",
},
{
name: "link",
tooltip: dictionary.createLink,
icon: LinkIcon,
active: isMarkActive(schema.marks.link),
attrs: { href: "" },
},
];
}

View File

@@ -0,0 +1,77 @@
import {
TrashIcon,
DownloadIcon,
ReplaceIcon,
AlignImageLeftIcon,
AlignImageRightIcon,
AlignImageCenterIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import isNodeActive from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
export default function imageMenuItems(
state: EditorState,
dictionary: Dictionary
): MenuItem[] {
const { schema } = state;
const isLeftAligned = isNodeActive(schema.nodes.image, {
layoutClass: "left-50",
});
const isRightAligned = isNodeActive(schema.nodes.image, {
layoutClass: "right-50",
});
return [
{
name: "alignLeft",
tooltip: dictionary.alignLeft,
icon: AlignImageLeftIcon,
visible: true,
active: isLeftAligned,
},
{
name: "alignCenter",
tooltip: dictionary.alignCenter,
icon: AlignImageCenterIcon,
visible: true,
active: (state) =>
isNodeActive(schema.nodes.image)(state) &&
!isLeftAligned(state) &&
!isRightAligned(state),
},
{
name: "alignRight",
tooltip: dictionary.alignRight,
icon: AlignImageRightIcon,
visible: true,
active: isRightAligned,
},
{
name: "separator",
visible: true,
},
{
name: "downloadImage",
tooltip: dictionary.downloadImage,
icon: DownloadIcon,
visible: !!fetch,
active: () => false,
},
{
name: "replaceImage",
tooltip: dictionary.replaceImage,
icon: ReplaceIcon,
visible: true,
active: () => false,
},
{
name: "deleteImage",
tooltip: dictionary.deleteImage,
icon: TrashIcon,
visible: true,
active: () => false,
},
];
}

View File

@@ -0,0 +1,14 @@
import { TrashIcon } from "outline-icons";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
export default function tableMenuItems(dictionary: Dictionary): MenuItem[] {
return [
{
name: "deleteTable",
tooltip: dictionary.deleteTable,
icon: TrashIcon,
active: () => false,
},
];
}

View File

@@ -0,0 +1,81 @@
import {
TrashIcon,
AlignLeftIcon,
AlignRightIcon,
AlignCenterIcon,
InsertLeftIcon,
InsertRightIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import isNodeActive from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
export default function tableColMenuItems(
state: EditorState,
index: number,
rtl: boolean,
dictionary: Dictionary
): MenuItem[] {
const { schema } = state;
return [
{
name: "setColumnAttr",
tooltip: dictionary.alignLeft,
icon: AlignLeftIcon,
attrs: { index, alignment: "left" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "left",
}),
},
{
name: "setColumnAttr",
tooltip: dictionary.alignCenter,
icon: AlignCenterIcon,
attrs: { index, alignment: "center" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "center",
}),
},
{
name: "setColumnAttr",
tooltip: dictionary.alignRight,
icon: AlignRightIcon,
attrs: { index, alignment: "right" },
active: isNodeActive(schema.nodes.th, {
colspan: 1,
rowspan: 1,
alignment: "right",
}),
},
{
name: "separator",
},
{
name: rtl ? "addColumnAfter" : "addColumnBefore",
tooltip: rtl ? dictionary.addColumnAfter : dictionary.addColumnBefore,
icon: InsertLeftIcon,
active: () => false,
},
{
name: rtl ? "addColumnBefore" : "addColumnAfter",
tooltip: rtl ? dictionary.addColumnBefore : dictionary.addColumnAfter,
icon: InsertRightIcon,
active: () => false,
},
{
name: "separator",
},
{
name: "deleteColumn",
tooltip: dictionary.deleteColumn,
icon: TrashIcon,
active: () => false,
},
];
}

View File

@@ -0,0 +1,37 @@
import { TrashIcon, InsertAboveIcon, InsertBelowIcon } from "outline-icons";
import { EditorState } from "prosemirror-state";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
export default function tableRowMenuItems(
state: EditorState,
index: number,
dictionary: Dictionary
): MenuItem[] {
return [
{
name: "addRowAfter",
tooltip: dictionary.addRowBefore,
icon: InsertAboveIcon,
attrs: { index: index - 1 },
active: () => false,
visible: index !== 0,
},
{
name: "addRowAfter",
tooltip: dictionary.addRowAfter,
icon: InsertBelowIcon,
attrs: { index },
active: () => false,
},
{
name: "separator",
},
{
name: "deleteRow",
tooltip: dictionary.deleteRow,
icon: TrashIcon,
active: () => false,
},
];
}

View File

@@ -0,0 +1,31 @@
import { useState, useEffect } from "react";
export default function useComponentSize(
ref: React.RefObject<HTMLElement>
): { width: number; height: number } {
const [size, setSize] = useState({
width: 0,
height: 0,
});
useEffect(() => {
const sizeObserver = new ResizeObserver((entries) => {
entries.forEach(({ target }) => {
if (
size.width !== target.clientWidth ||
size.height !== target.clientHeight
) {
setSize({ width: target.clientWidth, height: target.clientHeight });
}
});
});
if (ref.current) {
sizeObserver.observe(ref.current);
}
return () => sizeObserver.disconnect();
}, [ref]);
return size;
}

View File

@@ -72,3 +72,5 @@ export default function useDictionary() {
};
}, [t]);
}
export type Dictionary = ReturnType<typeof useDictionary>;

View File

@@ -6,15 +6,12 @@ export default function useMediaQuery(query: string): boolean {
useEffect(() => {
if (window.matchMedia) {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => {
setMatches(media.matches);
};
media.addListener(listener);
return () => media.removeListener(listener);
}

View File

@@ -0,0 +1,24 @@
import { useLayoutEffect, useState } from "react";
export default function useViewportHeight(): number | void {
// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport#browser_compatibility
// Note: No support in Firefox at time of writing, however this mainly exists
// for virtual keyboards on mobile devices, so that's okay.
const [height, setHeight] = useState<number>(
() => window.visualViewport?.height || window.innerHeight
);
useLayoutEffect(() => {
const handleResize = () => {
setHeight(() => window.visualViewport?.height || window.innerHeight);
};
window.visualViewport?.addEventListener("resize", handleResize);
return () => {
window.visualViewport?.removeEventListener("resize", handleResize);
};
}, []);
return height;
}

View File

@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState, MenuButton } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import Collection from "~/models/Collection";
import CollectionDelete from "~/scenes/CollectionDelete";
import CollectionEdit from "~/scenes/CollectionEdit";
@@ -25,7 +26,6 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { MenuItem } from "~/types";
import getDataTransferFiles from "~/utils/getDataTransferFiles";
import { newDocumentPath } from "~/utils/routeHelpers";
type Props = {

View File

@@ -25,6 +25,7 @@ import { useMenuState, MenuButton } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import Document from "~/models/Document";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
@@ -48,7 +49,6 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { MenuItem } from "~/types";
import getDataTransferFiles from "~/utils/getDataTransferFiles";
import {
documentHistoryUrl,
documentUrl,

View File

@@ -1,5 +1,4 @@
import { keymap } from "prosemirror-keymap";
import { Extension } from "rich-markdown-editor";
import {
ySyncPlugin,
yCursorPlugin,
@@ -8,6 +7,7 @@ import {
redo,
} from "y-prosemirror";
import * as Y from "yjs";
import { Extension } from "~/editor";
export default class MultiplayerExtension extends Extension {
get name() {

View File

@@ -0,0 +1,11 @@
import * as React from "react";
const MultiplayerEditor = React.lazy(
() =>
import(
/* webpackChunkName: "multiplayer-editor" */
"./MultiplayerEditor"
)
);
export default MultiplayerEditor;

View File

@@ -9,8 +9,8 @@ import Editor, { Props as EditorProps } from "~/components/Editor";
import Flex from "~/components/Flex";
import HoverPreview from "~/components/HoverPreview";
import { documentHistoryUrl } from "~/utils/routeHelpers";
import MultiplayerEditor from "./AsyncMultiplayerEditor";
import EditableTitle from "./EditableTitle";
import MultiplayerEditor from "./MultiplayerEditor";
type Props = EditorProps &
WithTranslation & {

View File

@@ -1,4 +1,4 @@
import { Search } from "js-search";
import FuzzySearch from "fuzzy-search";
import { last } from "lodash";
import { observer } from "mobx-react";
import { useMemo, useState } from "react";
@@ -29,8 +29,6 @@ function DocumentMove({ document, onRequestClose }: Props) {
const searchIndex = useMemo(() => {
const paths = collections.pathsToDocuments;
const index = new Search("id");
index.addIndex("title");
// Build index
const indexeableDocuments: DocumentPath[] = [];
@@ -43,8 +41,10 @@ function DocumentMove({ document, onRequestClose }: Props) {
}
});
index.addDocuments(indexeableDocuments);
return index;
return new FuzzySearch<DocumentPath>(indexeableDocuments, ["title"], {
caseSensitive: false,
sort: true,
});
}, [documents, collections.pathsToDocuments]);
const results = useMemo(() => {
@@ -53,10 +53,9 @@ function DocumentMove({ document, onRequestClose }: Props) {
if (collections.isLoaded) {
if (searchTerm) {
results = searchIndex.search(searchTerm) as DocumentPath[];
results = searchIndex.search(searchTerm);
} else {
// @ts-expect-error it's there, but it's not in typings
results = searchIndex._documents;
results = searchIndex.haystack;
}
}
@@ -107,8 +106,15 @@ function DocumentMove({ document, onRequestClose }: Props) {
return null;
};
// @ts-expect-error ts-migrate(7031) FIXME: Binding element 'index' implicitly has an 'any' ty... Remove this comment to see the full error message
const row = ({ index, data, style }) => {
const row = ({
index,
data,
style,
}: {
index: number;
data: DocumentPath[];
style: React.CSSProperties;
}) => {
const result = data[index];
return (
<PathToDocument

View File

@@ -5,6 +5,7 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import { parseOutlineExport, Item } from "@shared/utils/zip";
import FileOperation from "~/models/FileOperation";
import Button from "~/components/Button";
@@ -17,7 +18,6 @@ import Subheading from "~/components/Subheading";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import getDataTransferFiles from "~/utils/getDataTransferFiles";
import { uploadFile } from "~/utils/uploadFile";
import FileOperationListItem from "./components/FileOperationListItem";

View File

@@ -12,5 +12,3 @@ declare module "*.png" {
const value: any;
export = value;
}
declare const EDITOR_VERSION: string;

View File

@@ -15,6 +15,7 @@ declare module "styled-components" {
tableDivider: string;
tableSelected: string;
tableSelectedBackground: string;
tableHeaderBackground: string;
quote: string;
codeBackground: string;
codeBorder: string;
@@ -31,6 +32,7 @@ declare module "styled-components" {
textHighlight: string;
textHighlightForeground: string;
selected: string;
code: string;
codeComment: string;
codePunctuation: string;
codeNumber: string;

View File

@@ -2,6 +2,7 @@ import retry from "fetch-retry";
import invariant from "invariant";
import { map, trim } from "lodash";
import { getCookie } from "tiny-cookie";
import EDITOR_VERSION from "@shared/editor/version";
import stores from "~/stores";
import download from "./download";
import {

View File

@@ -6,8 +6,6 @@ Outline is composed of a backend and frontend codebase in this monorepo. As both
Outline's frontend is a React application compiled with [Webpack](https://webpack.js.org/). It uses [MobX](https://mobx.js.org/) for state management and [Styled Components](https://www.styled-components.com/) for component styles. Unless global, state logic and styles are always co-located with React components together with their subcomponents to make the component tree easier to manage.
> Important Note: The Outline editor is built on [Prosemirror](https://github.com/prosemirror) and managed in a separate open source repository to encourage re-use: [rich-markdown-editor](https://github.com/outline/rich-markdown-editor).
```
app
├── components - React components reusable across scenes
@@ -61,6 +59,7 @@ small utilities.
```
shared
├── editor - The text editor, based on Prosemirror
├── i18n - Internationalization confiuration
│ └── locales - Language specific translation files
├── styles - Styles, colors and other global aesthetics

View File

@@ -5,13 +5,13 @@
"main": "index.js",
"scripts": {
"clean": "rimraf build",
"build:i18n": "i18next --silent 'app/**/*.tsx' 'app/**/*.ts' 'server/**/*.ts' 'server/**/*.tsx' && mkdir -p ./build/shared/i18n && cp -R ./shared/i18n/locales ./build/shared/i18n",
"build:i18n": "i18next --silent 'shared/**/*.tsx' 'shared/**/*.ts' 'app/**/*.tsx' 'app/**/*.ts' 'server/**/*.ts' 'server/**/*.tsx' && mkdir -p ./build/shared/i18n && cp -R ./shared/i18n/locales ./build/shared/i18n",
"build:server": "babel --extensions .ts,.tsx --quiet -d ./build/server ./server && babel --quiet --extensions .ts,.tsx -d./build/shared ./shared && cp ./server/collaboration/Procfile ./build/server/collaboration/Procfile && cp package.json ./build && ln -sf \"$(pwd)/webpack.config.dev.js\" ./build",
"build:webpack": "webpack --config webpack.config.prod.js",
"build": "yarn clean && yarn build:webpack && yarn build:i18n && yarn build:server",
"start": "node ./build/server/index.js",
"dev": "NODE_ENV=development yarn concurrently -n api,collaboration -c \"blue,magenta\" \"node --inspect=0.0.0.0 build/server/index.js --services=collaboration,websockets,admin,web,worker\"",
"dev:watch": "nodemon --exec \"yarn build:server && yarn build:i18n && yarn dev\" -e js,ts --ignore build/ --ignore app/",
"dev:watch": "nodemon --exec \"yarn build:server && yarn build:i18n && yarn dev\" -e js,ts --ignore build/ --ignore app/ --ignore shared/editor",
"lint": "eslint app server shared",
"deploy": "git push heroku master",
"prepare": "yarn yarn-deduplicate yarn.lock",
@@ -86,6 +86,8 @@
"fractional-index": "^1.0.0",
"framer-motion": "^4.1.17",
"fs-extra": "^4.0.2",
"fuzzy-search": "^3.2.1",
"gemoji": "6.x",
"http-errors": "1.4.0",
"i18next": "^20.6.1",
"i18next-http-backend": "^1.3.1",
@@ -94,7 +96,6 @@
"ioredis": "^4.28.0",
"is-printable-key-event": "^1.0.0",
"joplin-turndown-plugin-gfm": "^1.0.12",
"js-search": "^1.4.2",
"json-loader": "0.5.4",
"jsonwebtoken": "^8.5.0",
"jszip": "^3.7.1",
@@ -114,6 +115,9 @@
"koa-static": "^4.0.1",
"lodash": "^4.17.21",
"mammoth": "^1.4.19",
"markdown-it": "^12.3.2",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
"natural-sort": "^1.0.0",
@@ -127,6 +131,20 @@
"pg": "^8.5.1",
"pg-hstore": "^2.3.4",
"polished": "^3.7.2",
"prosemirror-commands": "^1.1.6",
"prosemirror-dropcursor": "^1.3.3",
"prosemirror-gapcursor": "^1.1.5",
"prosemirror-history": "^1.1.3",
"prosemirror-inputrules": "^1.1.3",
"prosemirror-keymap": "^1.1.4",
"prosemirror-markdown": "^1.5.2",
"prosemirror-model": "^1.13.3",
"prosemirror-schema-list": "^1.1.2",
"prosemirror-state": "^1.3.4",
"prosemirror-tables": "^1.1.1",
"prosemirror-transform": "1.2.5",
"prosemirror-utils": "^0.9.6",
"prosemirror-view": "1.22.0",
"query-string": "^7.0.1",
"quoted-printable": "^1.0.1",
"randomstring": "1.1.5",
@@ -140,6 +158,7 @@
"react-dropzone": "^11.3.2",
"react-helmet": "^6.1.0",
"react-i18next": "^11.13.0",
"react-medium-image-zoom": "^3.1.3",
"react-portal": "^4.2.0",
"react-router-dom": "^5.2.0",
"react-table": "^7.7.0",
@@ -148,8 +167,8 @@
"react-window": "^1.8.6",
"reakit": "^1.3.10",
"reflect-metadata": "^0.1.13",
"refractor": "^3.3.1",
"regenerator-runtime": "^0.13.7",
"rich-markdown-editor": "^11.21.3",
"semver": "^7.3.2",
"sequelize": "^6.9.0",
"sequelize-cli": "^6.3.0",
@@ -158,7 +177,8 @@
"slate": "0.45.0",
"slate-md-serializer": "5.5.4",
"slug": "^4.0.4",
"smooth-scroll-into-view-if-needed": "^1.1.29",
"slugify": "^1.6.5",
"smooth-scroll-into-view-if-needed": "^1.1.32",
"socket.io": "^2.4.0",
"socket.io-redis": "^5.4.0",
"socketio-auth": "^0.1.1",
@@ -193,11 +213,11 @@
"@types/enzyme-adapter-react-16": "^1.0.6",
"@types/formidable": "^2.0.0",
"@types/fs-extra": "^9.0.13",
"@types/fuzzy-search": "^2.1.2",
"@types/google.analytics": "^0.0.42",
"@types/invariant": "^2.2.35",
"@types/ioredis": "^4.28.1",
"@types/jest": "^27.0.2",
"@types/js-search": "^1.4.0",
"@types/jsonwebtoken": "^8.5.5",
"@types/koa": "^2.13.4",
"@types/koa-compress": "^4.0.3",
@@ -208,16 +228,23 @@
"@types/koa-sslify": "^4.0.2",
"@types/koa-static": "^4.0.2",
"@types/markdown-it": "^12.2.3",
"@types/markdown-it-emoji": "^2.0.2",
"@types/markdown-it-container": "^2.0.4",
"@types/natural-sort": "^0.0.21",
"@types/node": "15.12.2",
"@types/nodemailer": "^6.4.4",
"@types/passport-oauth2": "^1.4.11",
"@types/prosemirror-inputrules": "^1.0.4",
"@types/prosemirror-keymap": "^1.0.4",
"@types/prosemirror-markdown": "^1.5.3",
"@types/prosemirror-model": "^1.13.2",
"@types/prosemirror-state": "^1.2.8",
"@types/prosemirror-view": "^1.19.1",
"@types/prosemirror-commands": "^1.0.1",
"@types/prosemirror-dropcursor": "^1.0.0",
"@types/prosemirror-gapcursor": "^1.0.1",
"@types/prosemirror-history": "^1.0.1",
"@types/prosemirror-inputrules": "^1.0.2",
"@types/prosemirror-keymap": "^1.0.1",
"@types/prosemirror-markdown": "^1.0.3",
"@types/prosemirror-model": "^1.7.2",
"@types/prosemirror-schema-list": "^1.0.3",
"@types/prosemirror-state": "^1.2.4",
"@types/prosemirror-view": "^1.11.4",
"@types/quoted-printable": "^1.0.0",
"@types/randomstring": "^1.1.8",
"@types/react": "^17.0.34",
@@ -231,6 +258,7 @@
"@types/react-table": "^7.7.9",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/refractor": "^3.0.2",
"@types/semver": "^7.3.9",
"@types/sequelize": "^4.28.10",
"@types/slug": "^5.0.2",
@@ -286,6 +314,7 @@
},
"resolutions": {
"socket.io-parser": "^3.4.0",
"prosemirror-transform": "1.2.5",
"prosemirror-view": "1.22.0",
"dot-prop": "^5.2.0",
"js-yaml": "^3.14.1"

View File

@@ -1,8 +1,8 @@
import { Node, Fragment } from "prosemirror-model";
import { parser, schema } from "rich-markdown-editor";
import { prosemirrorToYDoc } from "y-prosemirror";
import * as Y from "yjs";
import embeds from "@shared/embeds";
import embeds from "@shared/editor/embeds";
import { parser, schema } from "@server/editor";
export default function markdownToYDoc(
markdown: string,
@@ -10,7 +10,7 @@ export default function markdownToYDoc(
): Y.Doc {
let node = parser.parse(markdown);
// in rich-markdown-editor embeds were created at runtime by converting links
// in the editor embeds were created at runtime by converting links
// into embeds where they match. Because we're converting to a CRDT structure
// on the server we need to mimic this behavior.
function urlsToEmbeds(node: Node): Node {

View File

@@ -1,9 +1,9 @@
import invariant from "invariant";
import { uniq } from "lodash";
import { Node } from "prosemirror-model";
import { schema, serializer } from "rich-markdown-editor";
import { yDocToProsemirrorJSON } from "y-prosemirror";
import * as Y from "yjs";
import { schema, serializer } from "@server/editor";
import { Document, Event } from "@server/models";
export default async function documentUpdater({

View File

@@ -0,0 +1,129 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders blockquote 1`] = `
"<blockquote>
<p>blockquote</p>
</blockquote>"
`;
exports[`renders bold marks 1`] = `"<p>this is <strong>bold</strong> text</p>"`;
exports[`renders bullet list 1`] = `
"<ul>
<li>item one</li>
<li>item two
<ul>
<li>nested item</li>
</ul>
</li>
</ul>"
`;
exports[`renders checkbox list 1`] = `
"<ul>
<li class=\\"checkbox-list-item\\"><span class=\\"checkbox \\">[ ]</span>unchecked</li>
<li class=\\"checkbox-list-item\\"><span class=\\"checkbox checked\\">[x]</span>checked</li>
</ul>"
`;
exports[`renders code block 1`] = `
"<pre><code>this is indented code
</code></pre>"
`;
exports[`renders code fence 1`] = `
"<pre><code class=\\"language-javascript\\">this is code
</code></pre>"
`;
exports[`renders code marks 1`] = `"<p>this is <code>inline code</code> text</p>"`;
exports[`renders headings 1`] = `
"<h1>Heading 1</h1>
<h2>Heading 2</h2>
<h3>Heading 3</h3>
<h4>Heading 4</h4>"
`;
exports[`renders highlight marks 1`] = `"<p>this is <span class=\\"highlight\\">highlighted</span> text</p>"`;
exports[`renders horizontal rule 1`] = `"<hr>"`;
exports[`renders image 1`] = `"<p><img src=\\"https://lorempixel.com/200/200\\" alt=\\"caption\\"></p>"`;
exports[`renders image with alignment 1`] = `"<p><img src=\\"https://lorempixel.com/200/200\\" alt=\\"caption\\" title=\\"left-40\\"></p>"`;
exports[`renders info notice 1`] = `
"<div class=\\"notice notice-info\\">
<p>content of notice</p>
</div>"
`;
exports[`renders italic marks 1`] = `"<p>this is <em>italic</em> text</p>"`;
exports[`renders italic marks 2`] = `"<p>this is <em>also italic</em> text</p>"`;
exports[`renders link marks 1`] = `"<p>this is <a href=\\"https://www.example.com\\">linked</a> text</p>"`;
exports[`renders ordered list 1`] = `
"<ol>
<li>item one</li>
<li>item two</li>
</ol>"
`;
exports[`renders ordered list 2`] = `
"<ol>
<li>item one</li>
<li>item two</li>
</ol>"
`;
exports[`renders plain text as paragraph 1`] = `"<p>plain text</p>"`;
exports[`renders table 1`] = `
"<table>
<tr>
<th>
<p>heading</p></th>
<th style=\\"text-align:center\\">
<p>centered</p></th>
<th style=\\"text-align:right\\">
<p>right aligned</p></th>
</tr>
<tr>
<td>
<p></p></td>
<td style=\\"text-align:center\\">
<p>center</p></td>
<td style=\\"text-align:right\\">
<p></p></td>
</tr>
<tr>
<td>
<p></p></td>
<td style=\\"text-align:center\\">
<p></p></td>
<td style=\\"text-align:right\\">
<p>bottom r</p></td>
</tr>
</table>"
`;
exports[`renders template placeholder marks 1`] = `"<p>this is <span class=\\"placeholder\\">a placeholder</span></p>"`;
exports[`renders tip notice 1`] = `
"<div class=\\"notice notice-tip\\">
<p>content of notice</p>
</div>"
`;
exports[`renders underline marks 1`] = `"<p>this is <underline>underlined</underline> text</p>"`;
exports[`renders underline marks 2`] = `"<p>this is <s>strikethrough</s> text</p>"`;
exports[`renders warning notice 1`] = `
"<div class=\\"notice notice-warning\\">
<p>content of notice</p>
</div>"
`;

View File

@@ -0,0 +1,10 @@
import { parser } from ".";
test("renders an empty doc", () => {
const ast = parser.parse("");
expect(ast.toJSON()).toEqual({
content: [{ type: "paragraph" }],
type: "doc",
});
});

85
server/editor/index.ts Normal file
View File

@@ -0,0 +1,85 @@
import { Schema } from "prosemirror-model";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
// marks
import Bold from "@shared/editor/marks/Bold";
import Code from "@shared/editor/marks/Code";
import Highlight from "@shared/editor/marks/Highlight";
import Italic from "@shared/editor/marks/Italic";
import Link from "@shared/editor/marks/Link";
import TemplatePlaceholder from "@shared/editor/marks/Placeholder";
import Strikethrough from "@shared/editor/marks/Strikethrough";
import Underline from "@shared/editor/marks/Underline";
// nodes
import Blockquote from "@shared/editor/nodes/Blockquote";
import BulletList from "@shared/editor/nodes/BulletList";
import CheckboxItem from "@shared/editor/nodes/CheckboxItem";
import CheckboxList from "@shared/editor/nodes/CheckboxList";
import CodeBlock from "@shared/editor/nodes/CodeBlock";
import CodeFence from "@shared/editor/nodes/CodeFence";
import Doc from "@shared/editor/nodes/Doc";
import Embed from "@shared/editor/nodes/Embed";
import Emoji from "@shared/editor/nodes/Emoji";
import HardBreak from "@shared/editor/nodes/HardBreak";
import Heading from "@shared/editor/nodes/Heading";
import HorizontalRule from "@shared/editor/nodes/HorizontalRule";
import Image from "@shared/editor/nodes/Image";
import ListItem from "@shared/editor/nodes/ListItem";
import Notice from "@shared/editor/nodes/Notice";
import OrderedList from "@shared/editor/nodes/OrderedList";
import Paragraph from "@shared/editor/nodes/Paragraph";
import Table from "@shared/editor/nodes/Table";
import TableCell from "@shared/editor/nodes/TableCell";
import TableHeadCell from "@shared/editor/nodes/TableHeadCell";
import TableRow from "@shared/editor/nodes/TableRow";
import Text from "@shared/editor/nodes/Text";
import render from "./renderToHtml";
const extensions = new ExtensionManager([
new Doc(),
new Text(),
new HardBreak(),
new Paragraph(),
new Blockquote(),
new Emoji(),
new BulletList(),
new CodeBlock(),
new CodeFence(),
new CheckboxList(),
new CheckboxItem(),
new Embed(),
new ListItem(),
new Notice(),
new Heading(),
new HorizontalRule(),
new Image(),
new Table(),
new TableCell(),
new TableHeadCell(),
new TableRow(),
new Bold(),
new Code(),
new Highlight(),
new Italic(),
new Link(),
new Strikethrough(),
new TemplatePlaceholder(),
new Underline(),
new OrderedList(),
]);
export const schema = new Schema({
nodes: extensions.nodes,
marks: extensions.marks,
});
export const parser = extensions.parser({
schema,
plugins: extensions.rulePlugins,
});
export const serializer = extensions.serializer();
export const renderToHtml = (markdown: string): string =>
render(markdown, extensions.rulePlugins);

View File

@@ -0,0 +1,154 @@
import renderToHtml from "./renderToHtml";
test("renders an empty string", () => {
expect(renderToHtml("")).toBe("");
});
test("renders plain text as paragraph", () => {
expect(renderToHtml("plain text")).toMatchSnapshot();
});
test("renders blockquote", () => {
expect(renderToHtml("> blockquote")).toMatchSnapshot();
});
test("renders code block", () => {
expect(
renderToHtml(`
this is indented code
`)
).toMatchSnapshot();
});
test("renders code fence", () => {
expect(
renderToHtml(`\`\`\`javascript
this is code
\`\`\``)
).toMatchSnapshot();
});
test("renders checkbox list", () => {
expect(
renderToHtml(`- [ ] unchecked
- [x] checked`)
).toMatchSnapshot();
});
test("renders bullet list", () => {
expect(
renderToHtml(`- item one
- item two
- nested item`)
).toMatchSnapshot();
});
test("renders info notice", () => {
expect(
renderToHtml(`:::info
content of notice
:::`)
).toMatchSnapshot();
});
test("renders warning notice", () => {
expect(
renderToHtml(`:::warning
content of notice
:::`)
).toMatchSnapshot();
});
test("renders tip notice", () => {
expect(
renderToHtml(`:::tip
content of notice
:::`)
).toMatchSnapshot();
});
test("renders headings", () => {
expect(
renderToHtml(`# Heading 1
## Heading 2
### Heading 3
#### Heading 4`)
).toMatchSnapshot();
});
test("renders horizontal rule", () => {
expect(renderToHtml(`---`)).toMatchSnapshot();
});
test("renders image", () => {
expect(
renderToHtml(`![caption](https://lorempixel.com/200/200)`)
).toMatchSnapshot();
});
test("renders image with alignment", () => {
expect(
renderToHtml(`![caption](https://lorempixel.com/200/200 "left-40")`)
).toMatchSnapshot();
});
test("renders table", () => {
expect(
renderToHtml(`
| heading | centered | right aligned |
|---------|:--------:|--------------:|
| | center | |
| | | bottom r |
`)
).toMatchSnapshot();
});
test("renders bold marks", () => {
expect(renderToHtml(`this is **bold** text`)).toMatchSnapshot();
});
test("renders code marks", () => {
expect(renderToHtml(`this is \`inline code\` text`)).toMatchSnapshot();
});
test("renders highlight marks", () => {
expect(renderToHtml(`this is ==highlighted== text`)).toMatchSnapshot();
});
test("renders italic marks", () => {
expect(renderToHtml(`this is *italic* text`)).toMatchSnapshot();
expect(renderToHtml(`this is _also italic_ text`)).toMatchSnapshot();
});
test("renders template placeholder marks", () => {
expect(renderToHtml(`this is !!a placeholder!!`)).toMatchSnapshot();
});
test("renders underline marks", () => {
expect(renderToHtml(`this is __underlined__ text`)).toMatchSnapshot();
});
test("renders link marks", () => {
expect(
renderToHtml(`this is [linked](https://www.example.com) text`)
).toMatchSnapshot();
});
test("renders underline marks", () => {
expect(renderToHtml(`this is ~~strikethrough~~ text`)).toMatchSnapshot();
});
test("renders ordered list", () => {
expect(
renderToHtml(`1. item one
1. item two`)
).toMatchSnapshot();
expect(
renderToHtml(`1. item one
2. item two`)
).toMatchSnapshot();
});

View File

@@ -0,0 +1,29 @@
import { PluginSimple } from "markdown-it";
import createMarkdown from "@shared/editor/lib/markdown/rules";
import breakRule from "@shared/editor/rules/breaks";
import checkboxRule from "@shared/editor/rules/checkboxes";
import embedsRule from "@shared/editor/rules/embeds";
import emojiRule from "@shared/editor/rules/emoji";
import markRule from "@shared/editor/rules/mark";
import noticesRule from "@shared/editor/rules/notices";
import tablesRule from "@shared/editor/rules/tables";
import underlinesRule from "@shared/editor/rules/underlines";
const defaultRules = [
embedsRule([]),
breakRule,
checkboxRule,
markRule({ delim: "==", mark: "highlight" }),
markRule({ delim: "!!", mark: "placeholder" }),
underlinesRule,
tablesRule,
noticesRule,
emojiRule,
];
export default function renderToHtml(
markdown: string,
rulePlugins: PluginSimple[] = defaultRules
): string {
return createMarkdown({ plugins: rulePlugins }).render(markdown).trim();
}

View File

@@ -1,6 +1,6 @@
import { Context, Next } from "koa";
import pkg from "rich-markdown-editor/package.json";
import semver from "semver";
import EDITOR_VERSION from "@shared/editor/version";
import { EditorUpdateError } from "@server/errors";
export default function editor() {
@@ -12,7 +12,7 @@ export default function editor() {
// changes) then force a client reload.
if (clientVersion) {
const parsedClientVersion = semver.parse(clientVersion as string);
const parsedCurrentVersion = semver.parse(pkg.version);
const parsedCurrentVersion = semver.parse(EDITOR_VERSION);
if (
parsedClientVersion &&

View File

@@ -1,5 +1,5 @@
import { Node } from "prosemirror-model";
import { parser } from "rich-markdown-editor";
import { parser } from "@server/editor";
export default function parseDocumentIds(text: string): string[] {
const value = parser.parse(text);

View File

@@ -1,5 +1,5 @@
import { Node } from "prosemirror-model";
import { parser } from "rich-markdown-editor";
import { parser } from "@server/editor";
export default function parseImages(text: string): string[] {
const value = parser.parse(text);

View File

@@ -0,0 +1,5 @@
https://prosemirror.net/docs/ref/#commands
Commands are building block functions that encapsulate an editing action. A command function takes an editor state, optionally a dispatch function that it can use to dispatch a transaction and optionally an EditorView instance. It should return a boolean that indicates whether it could perform any action.
Additional commands that are not included as part of prosemirror-commands, but are often reused can be found in this folder.

View File

@@ -0,0 +1,26 @@
import { NodeType } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
export default function backspaceToParagraph(type: NodeType) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const { $from, from, to, empty } = state.selection;
// if the selection has anything in it then use standard delete behavior
if (!empty) return null;
// check we're in a matching node
if ($from.parent.type !== type) return null;
// check if we're at the beginning of the heading
const $pos = state.doc.resolve(from - 1);
if ($pos.parent === $from.parent) return null;
// okay, replace it with a paragraph
dispatch(
state.tr
.setBlockType(from, to, type.schema.nodes.paragraph)
.scrollIntoView()
);
return true;
};
}

View File

@@ -0,0 +1,85 @@
import { Node } from "prosemirror-model";
import { EditorView } from "prosemirror-view";
import { ToastType } from "../types";
function findPlaceholderLink(doc: Node, href: string) {
let result: { pos: number; node: Node } | undefined;
function findLinks(node: Node, pos = 0) {
// get text nodes
if (node.type.name === "text") {
// get marks for text nodes
node.marks.forEach((mark) => {
// any of the marks links?
if (mark.type.name === "link") {
// any of the links to other docs?
if (mark.attrs.href === href) {
result = { node, pos };
}
}
});
}
if (!node.content.size) {
return;
}
node.descendants(findLinks);
}
findLinks(doc);
return result;
}
const createAndInsertLink = async function (
view: EditorView,
title: string,
href: string,
options: {
dictionary: any;
onCreateLink: (title: string) => Promise<string>;
onShowToast?: (message: string, code: string) => void;
}
) {
const { dispatch, state } = view;
const { onCreateLink, onShowToast } = options;
try {
const url = await onCreateLink(title);
const result = findPlaceholderLink(view.state.doc, href);
if (!result) return;
dispatch(
view.state.tr
.removeMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link
)
.addMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link.create({ href: url })
)
);
} catch (err) {
const result = findPlaceholderLink(view.state.doc, href);
if (!result) return;
dispatch(
view.state.tr.removeMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link
)
);
// let the user know
if (onShowToast) {
onShowToast(options.dictionary.createLinkError, ToastType.Error);
}
}
};
export default createAndInsertLink;

View File

@@ -0,0 +1,143 @@
import { NodeSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import uploadPlaceholderPlugin, {
findPlaceholder,
} from "../lib/uploadPlaceholder";
import { ToastType } from "../types";
let uploadId = 0;
export type Options = {
dictionary: any;
replaceExisting?: boolean;
uploadImage: (file: File) => Promise<string>;
onImageUploadStart?: () => void;
onImageUploadStop?: () => void;
onShowToast?: (message: string, code: string) => void;
};
const insertFiles = function (
view: EditorView,
event: Event | React.ChangeEvent<HTMLInputElement>,
pos: number,
files: File[],
options: Options
): void {
// filter to only include image files
const images = files.filter((file) => /image/i.test(file.type));
if (images.length === 0) return;
const {
dictionary,
uploadImage,
onImageUploadStart,
onImageUploadStop,
onShowToast,
} = options;
if (!uploadImage) {
console.warn(
"uploadImage callback must be defined to handle image uploads."
);
return;
}
// okay, we have some dropped images and a handler lets stop this
// event going any further up the stack
event.preventDefault();
// let the user know we're starting to process the images
if (onImageUploadStart) onImageUploadStart();
const { schema } = view.state;
// we'll use this to track of how many images have succeeded or failed
let complete = 0;
// the user might have dropped multiple images at once, we need to loop
for (const file of images) {
const id = `upload-${uploadId++}`;
const { tr } = view.state;
// insert a placeholder at this position, or mark an existing image as being
// replaced
tr.setMeta(uploadPlaceholderPlugin, {
add: {
id,
file,
pos,
replaceExisting: options.replaceExisting,
},
});
view.dispatch(tr);
// start uploading the image file to the server. Using "then" syntax
// to allow all placeholders to be entered at once with the uploads
// happening in the background in parallel.
uploadImage(file)
.then((src) => {
// otherwise, insert it at the placeholder's position, and remove
// the placeholder itself
const newImg = new Image();
newImg.onload = () => {
const result = findPlaceholder(view.state, id);
// if the content around the placeholder has been deleted
// then forget about inserting this image
if (result === null) {
return;
}
const [from, to] = result;
view.dispatch(
view.state.tr
.replaceWith(from, to || from, schema.nodes.image.create({ src }))
.setMeta(uploadPlaceholderPlugin, { remove: { id } })
);
// If the users selection is still at the image then make sure to select
// the entire node once done. Otherwise, if the selection has moved
// elsewhere then we don't want to modify it
if (view.state.selection.from === from) {
view.dispatch(
view.state.tr.setSelection(
new NodeSelection(view.state.doc.resolve(from))
)
);
}
};
newImg.onerror = (error) => {
throw error;
};
newImg.src = src;
})
.catch((error) => {
console.error(error);
// cleanup the placeholder if there is a failure
const transaction = view.state.tr.setMeta(uploadPlaceholderPlugin, {
remove: { id },
});
view.dispatch(transaction);
// let the user know
if (onShowToast) {
onShowToast(dictionary.imageUploadError, ToastType.Error);
}
})
.finally(() => {
complete++;
// once everything is done, let the user know
if (complete === images.length && onImageUploadStop) {
onImageUploadStop();
}
});
}
};
export default insertFiles;

View File

@@ -0,0 +1,111 @@
/*
Copyright 2020 Atlassian Pty Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// This file is based on the implementation found here:
// https://bitbucket.org/atlassian/design-system-mirror/src/master/editor/editor-core/src/plugins/text-formatting/commands/text-formatting.ts
import {
Selection,
EditorState,
Transaction,
TextSelection,
} from "prosemirror-state";
import isMarkActive from "../queries/isMarkActive";
function hasCode(state: EditorState, pos: number) {
const { code_inline } = state.schema.marks;
const node = pos >= 0 && state.doc.nodeAt(pos);
return node
? !!node.marks.filter((mark) => mark.type === code_inline).length
: false;
}
export default function moveLeft() {
return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => {
const { code_inline } = state.schema.marks;
const { empty, $cursor } = state.selection as TextSelection;
if (!empty || !$cursor) {
return false;
}
const { storedMarks } = state.tr;
if (code_inline) {
const insideCode = code_inline && isMarkActive(code_inline)(state);
const currentPosHasCode = hasCode(state, $cursor.pos);
const nextPosHasCode = hasCode(state, $cursor.pos - 1);
const nextNextPosHasCode = hasCode(state, $cursor.pos - 2);
const exitingCode =
currentPosHasCode && !nextPosHasCode && Array.isArray(storedMarks);
const atLeftEdge =
nextPosHasCode &&
!nextNextPosHasCode &&
(storedMarks === null ||
(Array.isArray(storedMarks) && !!storedMarks.length));
const atRightEdge =
((exitingCode && Array.isArray(storedMarks) && !storedMarks.length) ||
(!exitingCode && storedMarks === null)) &&
!nextPosHasCode &&
nextNextPosHasCode;
const enteringCode =
!currentPosHasCode &&
nextPosHasCode &&
Array.isArray(storedMarks) &&
!storedMarks.length;
// at the right edge: remove code mark and move the cursor to the left
if (!insideCode && atRightEdge) {
const tr = state.tr.setSelection(
Selection.near(state.doc.resolve($cursor.pos - 1))
);
dispatch(tr.removeStoredMark(code_inline));
return true;
}
// entering code mark (from right edge): don't move the cursor, just add the mark
if (!insideCode && enteringCode) {
dispatch(state.tr.addStoredMark(code_inline.create()));
return true;
}
// at the left edge: add code mark and move the cursor to the left
if (insideCode && atLeftEdge) {
const tr = state.tr.setSelection(
Selection.near(state.doc.resolve($cursor.pos - 1))
);
dispatch(tr.addStoredMark(code_inline.create()));
return true;
}
// exiting code mark (or at the beginning of the line): don't move the cursor, just remove the mark
const isFirstChild = $cursor.index($cursor.depth - 1) === 0;
if (
insideCode &&
(exitingCode || (!$cursor.nodeBefore && isFirstChild))
) {
dispatch(state.tr.removeStoredMark(code_inline));
return true;
}
}
return false;
};
}

View File

@@ -0,0 +1,71 @@
/*
Copyright 2020 Atlassian Pty Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// This file is based on the implementation found here:
// https://bitbucket.org/atlassian/design-system-mirror/src/master/editor/editor-core/src/plugins/text-formatting/commands/text-formatting.ts
import { EditorState, Transaction, TextSelection } from "prosemirror-state";
import isMarkActive from "../queries/isMarkActive";
export default function moveRight() {
return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => {
const { code_inline } = state.schema.marks;
const { empty, $cursor } = state.selection as TextSelection;
if (!empty || !$cursor) {
return false;
}
const { storedMarks } = state.tr;
if (code_inline) {
const insideCode = isMarkActive(code_inline)(state);
const currentPosHasCode = state.doc.rangeHasMark(
$cursor.pos,
$cursor.pos,
code_inline
);
const nextPosHasCode = state.doc.rangeHasMark(
$cursor.pos,
$cursor.pos + 1,
code_inline
);
const exitingCode =
!currentPosHasCode &&
!nextPosHasCode &&
(!storedMarks || !!storedMarks.length);
const enteringCode =
!currentPosHasCode &&
nextPosHasCode &&
(!storedMarks || !storedMarks.length);
// entering code mark (from the left edge): don't move the cursor, just add the mark
if (!insideCode && enteringCode) {
dispatch(state.tr.addStoredMark(code_inline.create()));
return true;
}
// exiting code mark: don't move the cursor, just remove the mark
if (insideCode && exitingCode) {
dispatch(state.tr.removeStoredMark(code_inline));
return true;
}
}
return false;
};
}

View File

@@ -0,0 +1,54 @@
import { NodeType } from "prosemirror-model";
import { EditorState, TextSelection, Transaction } from "prosemirror-state";
import { findBlockNodes } from "prosemirror-utils";
import findCollapsedNodes from "../queries/findCollapsedNodes";
export default function splitHeading(type: NodeType) {
return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => {
const { $from, from, $to, to } = state.selection;
// check we're in a matching heading node
if ($from.parent.type !== type) return false;
// check that the caret is at the end of the content, if it isn't then
// standard node splitting behaviour applies
const endPos = $to.after() - 1;
if (endPos !== to) return false;
// If the node isn't collapsed standard behavior applies
if (!$from.parent.attrs.collapsed) return false;
// Find the next visible block after this one. It takes into account nested
// collapsed headings and reaching the end of the document
const allBlocks = findBlockNodes(state.doc);
const collapsedBlocks = findCollapsedNodes(state.doc);
const visibleBlocks = allBlocks.filter(
(a) => !collapsedBlocks.find((b) => b.pos === a.pos)
);
const nextVisibleBlock = visibleBlocks.find((a) => a.pos > from);
const pos = nextVisibleBlock
? nextVisibleBlock.pos
: state.doc.content.size;
// Insert our new heading directly before the next visible block
const transaction = state.tr.insert(
pos,
type.create({ ...$from.parent.attrs, collapsed: false })
);
// Move the selection into the new heading node and make sure it's on screen
dispatch(
transaction
.setSelection(
TextSelection.near(
transaction.doc.resolve(
Math.min(pos + 1, transaction.doc.content.size)
)
)
)
.scrollIntoView()
);
return true;
};
}

View File

@@ -0,0 +1,20 @@
import { setBlockType } from "prosemirror-commands";
import { NodeType } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import isNodeActive from "../queries/isNodeActive";
export default function toggleBlockType(
type: NodeType,
toggleType: NodeType,
attrs = {}
) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const isActive = isNodeActive(type, attrs)(state);
if (isActive) {
return setBlockType(toggleType)(state, dispatch);
}
return setBlockType(type, attrs)(state, dispatch);
};
}

View File

@@ -0,0 +1,43 @@
import { NodeType } from "prosemirror-model";
import { wrapInList, liftListItem } from "prosemirror-schema-list";
import { EditorState, Transaction } from "prosemirror-state";
import { findParentNode } from "prosemirror-utils";
import isList from "../queries/isList";
export default function toggleList(listType: NodeType, itemType: NodeType) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const { schema, selection } = state;
const { $from, $to } = selection;
const range = $from.blockRange($to);
if (!range) {
return false;
}
const parentList = findParentNode((node) => isList(node, schema))(
selection
);
if (range.depth >= 1 && parentList && range.depth - parentList.depth <= 1) {
if (parentList.node.type === listType) {
return liftListItem(itemType)(state, dispatch);
}
if (
isList(parentList.node, schema) &&
listType.validContent(parentList.node.content)
) {
const { tr } = state;
tr.setNodeMarkup(parentList.pos, listType);
if (dispatch) {
dispatch(tr);
}
return false;
}
}
return wrapInList(listType)(state, dispatch);
};
}

View File

@@ -0,0 +1,19 @@
import { wrapIn, lift } from "prosemirror-commands";
import { NodeType } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import isNodeActive from "../queries/isNodeActive";
export default function toggleWrap(
type: NodeType,
attrs?: Record<string, any>
) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const isActive = isNodeActive(type)(state);
if (isActive) {
return lift(state, dispatch);
}
return wrapIn(type, attrs)(state, dispatch);
};
}

Some files were not shown because too many files have changed in this diff Show More