chore: Editor refactor (#3286)

* cleanup

* add context

* EventEmitter allows removal of toolbar props from extensions

* Move to 'packages' of extensions
Remove EmojiTrigger extension

* types

* iteration

* fix render flashing

* fix: Missing nodes in collection descriptions
This commit is contained in:
Tom Moor
2022-03-30 19:10:34 -07:00
committed by GitHub
parent c5b9a742c0
commit 6f2a4488e8
30 changed files with 517 additions and 581 deletions

View File

@@ -1,6 +1,5 @@
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;
@@ -38,7 +37,7 @@ const createAndInsertLink = async function (
options: {
dictionary: any;
onCreateLink: (title: string) => Promise<string>;
onShowToast: (message: string, code: string) => void;
onShowToast: (message: string) => void;
}
) {
const { dispatch, state } = view;
@@ -79,10 +78,7 @@ const createAndInsertLink = async function (
)
);
// let the user know
if (onShowToast) {
onShowToast(options.dictionary.createLinkError, ToastType.Error);
}
onShowToast(options.dictionary.createLinkError);
}
};

View File

@@ -6,7 +6,6 @@ import uploadPlaceholderPlugin, {
findPlaceholder,
} from "../lib/uploadPlaceholder";
import findAttachmentById from "../queries/findAttachmentById";
import { ToastType } from "../types";
export type Options = {
dictionary: any;
@@ -17,7 +16,7 @@ export type Options = {
uploadFile?: (file: File) => Promise<string>;
onFileUploadStart?: () => void;
onFileUploadStop?: () => void;
onShowToast: (message: string, code: string) => void;
onShowToast: (message: string) => void;
};
const insertFiles = function (
@@ -187,10 +186,7 @@ const insertFiles = function (
view.dispatch(view.state.tr.deleteRange(from, to || from));
}
onShowToast(
error.message || dictionary.fileUploadError,
ToastType.Error
);
onShowToast(error.message || dictionary.fileUploadError);
})
.finally(() => {
complete++;

View File

@@ -3,6 +3,7 @@ import { keymap } from "prosemirror-keymap";
import { MarkdownParser, TokenConfig } from "prosemirror-markdown";
import { Schema } from "prosemirror-model";
import { EditorView } from "prosemirror-view";
import { Editor } from "~/editor";
import Mark from "../marks/Mark";
import Node from "../nodes/Node";
import Extension, { CommandFactory } from "./Extension";
@@ -10,16 +11,32 @@ import makeRules from "./markdown/rules";
import { MarkdownSerializer } from "./markdown/serializer";
export default class ExtensionManager {
extensions: (Node | Mark | Extension)[];
extensions: (Node | Mark | Extension)[] = [];
constructor(extensions: (Node | Mark | Extension)[] = [], editor?: any) {
if (editor) {
extensions.forEach((extension) => {
constructor(
extensions: (
| Extension
| typeof Node
| typeof Mark
| typeof Extension
)[] = [],
editor?: Editor
) {
extensions.forEach((ext) => {
let extension;
if (typeof ext === "function") {
extension = new ext(editor?.props);
} else {
extension = ext;
}
if (editor) {
extension.bindEditor(editor);
});
}
}
this.extensions = extensions;
this.extensions.push(extension);
});
}
get nodes() {

View File

@@ -15,7 +15,7 @@ import * as React from "react";
import ReactDOM from "react-dom";
import { isInternalUrl } from "../../utils/urls";
import findLinkNodes from "../queries/findLinkNodes";
import { Dispatch } from "../types";
import { EventType, Dispatch } from "../types";
import Mark from "./Mark";
const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/;
@@ -106,7 +106,7 @@ export default class Link extends Mark {
return {
"Mod-k": (state: EditorState, dispatch: Dispatch) => {
if (state.selection.empty) {
this.options.onKeyboardShortcut();
this.editor.events.emit(EventType.linkMenuOpen);
return true;
}

View File

@@ -33,12 +33,13 @@ import rust from "refractor/lang/rust";
import sql from "refractor/lang/sql";
import typescript from "refractor/lang/typescript";
import yaml from "refractor/lang/yaml";
import { Dictionary } from "~/hooks/useDictionary";
import toggleBlockType from "../commands/toggleBlockType";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import Prism, { LANGUAGES } from "../plugins/Prism";
import isInCode from "../queries/isInCode";
import { Dispatch, ToastType } from "../types";
import { Dispatch } from "../types";
import Node from "./Node";
const PERSISTENCE_KEY = "rme-code-language";
@@ -67,6 +68,13 @@ const DEFAULT_LANGUAGE = "javascript";
].forEach(refractor.register);
export default class CodeFence extends Node {
constructor(options: {
dictionary: Dictionary;
onShowToast: (message: string) => void;
}) {
super(options);
}
get languageOptions() {
return Object.entries(LANGUAGES);
}
@@ -194,10 +202,7 @@ export default class CodeFence extends Node {
const node = view.state.doc.nodeAt(result.pos);
if (node) {
copy(node.textContent);
this.options.onShowToast(
this.options.dictionary.codeCopied,
ToastType.Info
);
this.options.onShowToast(this.options.dictionary.codeCopied);
}
}
};

View File

@@ -2,12 +2,17 @@ import nameToEmoji from "gemoji/name-to-emoji.json";
import Token from "markdown-it/lib/token";
import { InputRule } from "prosemirror-inputrules";
import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
import { EditorState, TextSelection } from "prosemirror-state";
import { EditorState, TextSelection, Plugin } from "prosemirror-state";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { run } from "../plugins/BlockMenuTrigger";
import isInCode from "../queries/isInCode";
import emojiRule from "../rules/emoji";
import { Dispatch } from "../types";
import { Dispatch, EventType } from "../types";
import Node from "./Node";
const OPEN_REGEX = /(?:^|\s):([0-9a-zA-Z_+-]+)?$/;
const CLOSE_REGEX = /(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/;
export default class Emoji extends Node {
get name() {
return "emoji";
@@ -61,6 +66,57 @@ export default class Emoji extends Node {
return [emojiRule];
}
get plugins() {
return [
new Plugin({
props: {
handleClick: () => {
this.editor.events.emit(EventType.emojiMenuClose);
return false;
},
handleKeyDown: (view, event) => {
// Prosemirror input rules are not triggered on backspace, however
// we need them to be evaluted for the filter trigger to work
// correctly. This additional handler adds inputrules-like handling.
if (event.key === "Backspace") {
// timeout ensures that the delete has been handled by prosemirror
// and any characters removed, before we evaluate the rule.
setTimeout(() => {
const { pos } = view.state.selection.$from;
return run(view, pos, pos, OPEN_REGEX, (state, match) => {
if (match) {
this.editor.events.emit(EventType.emojiMenuOpen, match[1]);
} else {
this.editor.events.emit(EventType.emojiMenuClose);
}
return null;
});
});
}
// If the query is active and we're navigating the block menu then
// just ignore the key events in the editor itself until we're done
if (
event.key === "Enter" ||
event.key === "ArrowUp" ||
event.key === "ArrowDown" ||
event.key === "Tab"
) {
const { pos } = view.state.selection.$from;
return run(view, pos, pos, OPEN_REGEX, (state, match) => {
// just tell Prosemirror we handled it and not to do anything
return match ? true : null;
});
}
return false;
},
},
}),
];
}
commands({ type }: { type: NodeType }) {
return (attrs: Record<string, string>) => (
state: EditorState,
@@ -100,6 +156,29 @@ export default class Emoji extends Node {
return tr;
}),
// main regex should match only:
// :word
new InputRule(OPEN_REGEX, (state, match) => {
if (
match &&
state.selection.$from.parent.type.name === "paragraph" &&
!isInCode(state)
) {
this.editor.events.emit(EventType.emojiMenuOpen, match[1]);
}
return null;
}),
// invert regex should match some of these scenarios:
// :<space>word
// :<space>
// :word<space>
// :)
new InputRule(CLOSE_REGEX, (state, match) => {
if (match) {
this.editor.events.emit(EventType.emojiMenuClose);
}
return null;
}),
];
}

View File

@@ -14,7 +14,6 @@ import toggleBlockType from "../commands/toggleBlockType";
import { Command } from "../lib/Extension";
import headingToSlug, { headingToPersistenceKey } from "../lib/headingToSlug";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { ToastType } from "../types";
import Node from "./Node";
export default class Heading extends Node {
@@ -180,10 +179,7 @@ export default class Heading extends Node {
const urlWithoutHash = window.location.href.split("#")[0];
copy(urlWithoutHash + hash);
this.options.onShowToast(
this.options.dictionary.linkCopied,
ToastType.Info
);
this.options.onShowToast(this.options.dictionary.linkCopied);
};
keys({ type, schema }: { type: NodeType; schema: Schema }) {

View File

@@ -5,6 +5,8 @@ import {
isTableSelected,
isRowSelected,
getCellsInColumn,
selectRow,
selectTable,
} from "prosemirror-utils";
import { DecorationSet, Decoration } from "prosemirror-view";
import Node from "./Node";
@@ -72,7 +74,7 @@ export default class TableCell extends Node {
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.options.onSelectTable(state);
this.editor.view.dispatch(selectTable(state.tr));
});
return grip;
})
@@ -97,7 +99,7 @@ export default class TableCell extends Node {
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.options.onSelectRow(index, state);
this.editor.view.dispatch(selectRow(index)(state.tr));
});
return grip;
})

View File

@@ -1,7 +1,11 @@
import Token from "markdown-it/lib/token";
import { NodeSpec } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import { isColumnSelected, getCellsInRow } from "prosemirror-utils";
import {
isColumnSelected,
getCellsInRow,
selectColumn,
} from "prosemirror-utils";
import { DecorationSet, Decoration } from "prosemirror-view";
import Node from "./Node";
@@ -72,7 +76,7 @@ export default class TableHeadCell extends Node {
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.options.onSelectColumn(index, state);
this.editor.view.dispatch(selectColumn(index)(state.tr));
});
return grip;
})

View File

@@ -0,0 +1,2 @@
Packages are preselected collections of extensions that form the different types
of editors within Outline.

View File

@@ -0,0 +1,44 @@
import Extension from "../lib/Extension";
import Bold from "../marks/Bold";
import Code from "../marks/Code";
import Italic from "../marks/Italic";
import Link from "../marks/Link";
import Mark from "../marks/Mark";
import Strikethrough from "../marks/Strikethrough";
import Underline from "../marks/Underline";
import Doc from "../nodes/Doc";
import Emoji from "../nodes/Emoji";
import HardBreak from "../nodes/HardBreak";
import Image from "../nodes/Image";
import Node from "../nodes/Node";
import Paragraph from "../nodes/Paragraph";
import Text from "../nodes/Text";
import History from "../plugins/History";
import MaxLength from "../plugins/MaxLength";
import PasteHandler from "../plugins/PasteHandler";
import Placeholder from "../plugins/Placeholder";
import SmartText from "../plugins/SmartText";
import TrailingNode from "../plugins/TrailingNode";
const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
Doc,
HardBreak,
Paragraph,
Emoji,
Text,
Image,
Bold,
Code,
Italic,
Underline,
Link,
Strikethrough,
History,
SmartText,
TrailingNode,
PasteHandler,
Placeholder,
MaxLength,
];
export default basicPackage;

View File

@@ -0,0 +1,52 @@
import Extension from "../lib/Extension";
import Highlight from "../marks/Highlight";
import Mark from "../marks/Mark";
import TemplatePlaceholder from "../marks/Placeholder";
import Attachment from "../nodes/Attachment";
import BulletList from "../nodes/BulletList";
import CheckboxItem from "../nodes/CheckboxItem";
import CheckboxList from "../nodes/CheckboxList";
import CodeBlock from "../nodes/CodeBlock";
import CodeFence from "../nodes/CodeFence";
import Embed from "../nodes/Embed";
import Heading from "../nodes/Heading";
import HorizontalRule from "../nodes/HorizontalRule";
import ListItem from "../nodes/ListItem";
import Node from "../nodes/Node";
import Notice from "../nodes/Notice";
import OrderedList from "../nodes/OrderedList";
import Table from "../nodes/Table";
import TableCell from "../nodes/TableCell";
import TableHeadCell from "../nodes/TableHeadCell";
import TableRow from "../nodes/TableRow";
import BlockMenuTrigger from "../plugins/BlockMenuTrigger";
import Folding from "../plugins/Folding";
import Keys from "../plugins/Keys";
import basicPackage from "./basic";
const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
...basicPackage,
CodeBlock,
CodeFence,
CheckboxList,
CheckboxItem,
BulletList,
OrderedList,
Embed,
ListItem,
Attachment,
Notice,
Heading,
HorizontalRule,
Table,
TableCell,
TableHeadCell,
TableRow,
Highlight,
TemplatePlaceholder,
Folding,
Keys,
BlockMenuTrigger,
];
export default fullPackage;

View File

@@ -7,6 +7,7 @@ import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
import * as React from "react";
import ReactDOM from "react-dom";
import Extension from "../lib/Extension";
import { EventType } from "../types";
const MAX_MATCH = 500;
const OPEN_REGEX = /^\/(\w+)?$/;
@@ -65,7 +66,7 @@ export default class BlockMenuTrigger extends Extension {
new Plugin({
props: {
handleClick: () => {
this.options.onClose();
this.editor.events.emit(EventType.blockMenuClose);
return false;
},
handleKeyDown: (view, event) => {
@@ -79,9 +80,9 @@ export default class BlockMenuTrigger extends Extension {
const { pos } = view.state.selection.$from;
return run(view, pos, pos, OPEN_REGEX, (state, match) => {
if (match) {
this.options.onOpen(match[1]);
this.editor.events.emit(EventType.blockMenuOpen, match[1]);
} else {
this.options.onClose();
this.editor.events.emit(EventType.blockMenuClose);
}
return null;
});
@@ -125,7 +126,7 @@ export default class BlockMenuTrigger extends Extension {
decorations.push(
Decoration.widget(parent.pos, () => {
button.addEventListener("click", () => {
this.options.onOpen("");
this.editor.events.emit(EventType.blockMenuOpen, "");
});
return button;
})
@@ -176,7 +177,7 @@ export default class BlockMenuTrigger extends Extension {
state.selection.$from.parent.type.name === "paragraph" &&
!isInTable(state)
) {
this.options.onOpen(match[1]);
this.editor.events.emit(EventType.blockMenuOpen, match[1]);
}
return null;
}),
@@ -186,7 +187,7 @@ export default class BlockMenuTrigger extends Extension {
// /word<space>
new InputRule(CLOSE_REGEX, (state, match) => {
if (match) {
this.options.onClose();
this.editor.events.emit(EventType.blockMenuClose);
}
return null;
}),

View File

@@ -1,93 +0,0 @@
import { InputRule } from "prosemirror-inputrules";
import { Plugin } from "prosemirror-state";
import Extension from "../lib/Extension";
import isInCode from "../queries/isInCode";
import { run } from "./BlockMenuTrigger";
const OPEN_REGEX = /(?:^|\s):([0-9a-zA-Z_+-]+)?$/;
const CLOSE_REGEX = /(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/;
export default class EmojiTrigger extends Extension {
get name() {
return "emojimenu";
}
get plugins() {
return [
new Plugin({
props: {
handleClick: () => {
this.options.onClose();
return false;
},
handleKeyDown: (view, event) => {
// Prosemirror input rules are not triggered on backspace, however
// we need them to be evaluted for the filter trigger to work
// correctly. This additional handler adds inputrules-like handling.
if (event.key === "Backspace") {
// timeout ensures that the delete has been handled by prosemirror
// and any characters removed, before we evaluate the rule.
setTimeout(() => {
const { pos } = view.state.selection.$from;
return run(view, pos, pos, OPEN_REGEX, (state, match) => {
if (match) {
this.options.onOpen(match[1]);
} else {
this.options.onClose();
}
return null;
});
});
}
// If the query is active and we're navigating the block menu then
// just ignore the key events in the editor itself until we're done
if (
event.key === "Enter" ||
event.key === "ArrowUp" ||
event.key === "ArrowDown" ||
event.key === "Tab"
) {
const { pos } = view.state.selection.$from;
return run(view, pos, pos, OPEN_REGEX, (state, match) => {
// just tell Prosemirror we handled it and not to do anything
return match ? true : null;
});
}
return false;
},
},
}),
];
}
inputRules() {
return [
// main regex should match only:
// :word
new InputRule(OPEN_REGEX, (state, match) => {
if (
match &&
state.selection.$from.parent.type.name === "paragraph" &&
!isInCode(state)
) {
this.options.onOpen(match[1]);
}
return null;
}),
// invert regex should match some of these scenarios:
// :<space>word
// :<space>
// :word<space>
// :)
new InputRule(CLOSE_REGEX, (state, match) => {
if (match) {
this.options.onClose();
}
return null;
}),
];
}
}

View File

@@ -16,8 +16,8 @@ export default class Keys extends Extension {
keys(): Record<string, Command> {
const onCancel = () => {
if (this.options.onCancel) {
this.options.onCancel();
if (this.editor.props.onCancel) {
this.editor.props.onCancel();
return true;
}
return false;
@@ -32,15 +32,15 @@ export default class Keys extends Extension {
"Mod-Escape": onCancel,
"Shift-Escape": onCancel,
"Mod-s": () => {
if (this.options.onSave) {
this.options.onSave();
if (this.editor.props.onSave) {
this.editor.props.onSave({ done: false });
return true;
}
return false;
},
"Mod-Enter": (state: EditorState) => {
if (!isInCode(state) && this.options.onSaveAndExit) {
this.options.onSaveAndExit();
if (!isInCode(state) && this.editor.props.onSave) {
this.editor.props.onSave({ done: true });
return true;
}
return false;
@@ -52,10 +52,6 @@ export default class Keys extends Extension {
return [
new Plugin({
props: {
handleDOMEvents: {
blur: this.options.onBlur,
focus: this.options.onFocus,
},
// we can't use the keys bindings for this as we want to preventDefault
// on the original keyboard event when handled
handleKeyDown: (view, event) => {

View File

@@ -3,9 +3,13 @@ import { EditorState, Transaction } from "prosemirror-state";
import * as React from "react";
import { DefaultTheme } from "styled-components";
export enum ToastType {
Error = "error",
Info = "info",
export enum EventType {
blockMenuOpen = "blockMenuOpen",
blockMenuClose = "blockMenuClose",
emojiMenuOpen = "emojiMenuOpen",
emojiMenuClose = "emojiMenuClose",
linkMenuOpen = "linkMenuOpen",
linkMenuClose = "linkMenuClose",
}
export type MenuItem = {

29
shared/utils/events.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* A tiny EventEmitter implementation for the browser.
*/
export default class EventEmitter {
private listeners: { [name: string]: ((data: any) => unknown)[] } = {};
public addListener(name: string, callback: (data: any) => unknown) {
if (!this.listeners[name]) {
this.listeners[name] = [];
}
this.listeners[name].push(callback);
}
public removeListener(name: string, callback: (data: any) => unknown) {
this.listeners[name] = this.listeners[name]?.filter(
(cb) => cb !== callback
);
}
public on = this.addListener;
public off = this.removeListener;
public emit(name: string, data?: any) {
this.listeners[name]?.forEach((callback) => {
callback(data);
});
}
}