chore: Move editor into codebase (#2930)
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
50
app/editor/components/BlockMenu.tsx
Normal file
50
app/editor/components/BlockMenu.tsx
Normal 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;
|
||||
110
app/editor/components/BlockMenuItem.tsx
Normal file
110
app/editor/components/BlockMenuItem.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
{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;
|
||||
614
app/editor/components/CommandMenu.tsx
Normal file
614
app/editor/components/CommandMenu.tsx
Normal 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;
|
||||
116
app/editor/components/ComponentView.tsx
Normal file
116
app/editor/components/ComponentView.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
90
app/editor/components/EmojiMenu.tsx
Normal file
90
app/editor/components/EmojiMenu.tsx
Normal 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;
|
||||
35
app/editor/components/EmojiMenuItem.tsx
Normal file
35
app/editor/components/EmojiMenuItem.tsx
Normal 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>
|
||||
|
||||
{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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
267
app/editor/components/FloatingToolbar.tsx
Normal file
267
app/editor/components/FloatingToolbar.tsx
Normal 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} />;
|
||||
});
|
||||
19
app/editor/components/Input.tsx
Normal file
19
app/editor/components/Input.tsx
Normal 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;
|
||||
403
app/editor/components/LinkEditor.tsx
Normal file
403
app/editor/components/LinkEditor.tsx
Normal 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;
|
||||
84
app/editor/components/LinkSearchResult.tsx
Normal file
84
app/editor/components/LinkSearchResult.tsx
Normal 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;
|
||||
151
app/editor/components/LinkToolbar.tsx
Normal file
151
app/editor/components/LinkToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
255
app/editor/components/SelectionToolbar.tsx
Normal file
255
app/editor/components/SelectionToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
1144
app/editor/components/Styles.ts
Normal file
1144
app/editor/components/Styles.ts
Normal file
File diff suppressed because it is too large
Load Diff
40
app/editor/components/ToolbarButton.tsx
Normal file
40
app/editor/components/ToolbarButton.tsx
Normal 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;"};
|
||||
`;
|
||||
54
app/editor/components/ToolbarMenu.tsx
Normal file
54
app/editor/components/ToolbarMenu.tsx
Normal 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;
|
||||
12
app/editor/components/ToolbarSeparator.tsx
Normal file
12
app/editor/components/ToolbarSeparator.tsx
Normal 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;
|
||||
20
app/editor/components/Tooltip.tsx
Normal file
20
app/editor/components/Tooltip.tsx
Normal 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;
|
||||
11
app/editor/components/WithTheme.tsx
Normal file
11
app/editor/components/WithTheme.tsx
Normal 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
818
app/editor/index.tsx
Normal 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
148
app/editor/menus/block.ts
Normal 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" },
|
||||
},
|
||||
];
|
||||
}
|
||||
29
app/editor/menus/divider.tsx
Normal file
29
app/editor/menus/divider.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
135
app/editor/menus/formatting.ts
Normal file
135
app/editor/menus/formatting.ts
Normal 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: "" },
|
||||
},
|
||||
];
|
||||
}
|
||||
77
app/editor/menus/image.tsx
Normal file
77
app/editor/menus/image.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
14
app/editor/menus/table.tsx
Normal file
14
app/editor/menus/table.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
81
app/editor/menus/tableCol.tsx
Normal file
81
app/editor/menus/tableCol.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
37
app/editor/menus/tableRow.tsx
Normal file
37
app/editor/menus/tableRow.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
31
app/hooks/useComponentSize.ts
Normal file
31
app/hooks/useComponentSize.ts
Normal 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;
|
||||
}
|
||||
@@ -72,3 +72,5 @@ export default function useDictionary() {
|
||||
};
|
||||
}, [t]);
|
||||
}
|
||||
|
||||
export type Dictionary = ReturnType<typeof useDictionary>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
24
app/hooks/useViewportHeight.ts
Normal file
24
app/hooks/useViewportHeight.ts
Normal 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;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
11
app/scenes/Document/components/AsyncMultiplayerEditor.ts
Normal file
11
app/scenes/Document/components/AsyncMultiplayerEditor.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MultiplayerEditor = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "multiplayer-editor" */
|
||||
"./MultiplayerEditor"
|
||||
)
|
||||
);
|
||||
|
||||
export default MultiplayerEditor;
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
2
app/typings/index.d.ts
vendored
2
app/typings/index.d.ts
vendored
@@ -12,5 +12,3 @@ declare module "*.png" {
|
||||
const value: any;
|
||||
export = value;
|
||||
}
|
||||
|
||||
declare const EDITOR_VERSION: string;
|
||||
|
||||
2
app/typings/styled-components.d.ts
vendored
2
app/typings/styled-components.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
53
package.json
53
package.json
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
129
server/editor/__snapshots__/renderToHtml.test.ts.snap
Normal file
129
server/editor/__snapshots__/renderToHtml.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
10
server/editor/index.test.ts
Normal file
10
server/editor/index.test.ts
Normal 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
85
server/editor/index.ts
Normal 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);
|
||||
154
server/editor/renderToHtml.test.ts
Normal file
154
server/editor/renderToHtml.test.ts
Normal 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(``)
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("renders image with alignment", () => {
|
||||
expect(
|
||||
renderToHtml(``)
|
||||
).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();
|
||||
});
|
||||
29
server/editor/renderToHtml.ts
Normal file
29
server/editor/renderToHtml.ts
Normal 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();
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
5
shared/editor/commands/README.md
Normal file
5
shared/editor/commands/README.md
Normal 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.
|
||||
26
shared/editor/commands/backspaceToParagraph.ts
Normal file
26
shared/editor/commands/backspaceToParagraph.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
85
shared/editor/commands/createAndInsertLink.ts
Normal file
85
shared/editor/commands/createAndInsertLink.ts
Normal 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;
|
||||
143
shared/editor/commands/insertFiles.ts
Normal file
143
shared/editor/commands/insertFiles.ts
Normal 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;
|
||||
111
shared/editor/commands/moveLeft.ts
Normal file
111
shared/editor/commands/moveLeft.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
71
shared/editor/commands/moveRight.ts
Normal file
71
shared/editor/commands/moveRight.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
54
shared/editor/commands/splitHeading.ts
Normal file
54
shared/editor/commands/splitHeading.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
20
shared/editor/commands/toggleBlockType.ts
Normal file
20
shared/editor/commands/toggleBlockType.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
43
shared/editor/commands/toggleList.ts
Normal file
43
shared/editor/commands/toggleList.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
19
shared/editor/commands/toggleWrap.ts
Normal file
19
shared/editor/commands/toggleWrap.ts
Normal 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
Reference in New Issue
Block a user