chore: Cleanup editor menu handlers (#5174

* wip

* wip

* refactor
This commit is contained in:
Tom Moor
2023-04-10 18:50:21 -04:00
committed by GitHub
parent 70f3a998a4
commit 60dbad765a
15 changed files with 502 additions and 572 deletions

View File

@@ -27,6 +27,7 @@ import Mark from "@shared/editor/marks/Mark";
import { richExtensions, withComments } from "@shared/editor/nodes";
import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode";
import { SuggestionsMenuType } from "@shared/editor/plugins/Suggestions";
import { EventType } from "@shared/editor/types";
import { UserPreferences } from "@shared/types";
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
@@ -136,17 +137,13 @@ type State = {
/** 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;
selectionToolbarOpen: boolean;
/** If a suggestions menu is visible */
suggestionsMenuOpen: SuggestionsMenuType | false;
/** 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;
/** If the mention user menu is visible */
mentionMenuOpen: boolean;
linkToolbarOpen: boolean;
/** The query for the suggestion menu */
query: string;
};
/**
@@ -172,15 +169,13 @@ export class Editor extends React.PureComponent<
extensions,
};
state = {
state: State = {
isRTL: false,
isEditorFocused: false,
selectionMenuOpen: false,
blockMenuOpen: false,
linkMenuOpen: false,
blockMenuSearch: "",
emojiMenuOpen: false,
mentionMenuOpen: false,
suggestionsMenuOpen: false,
selectionToolbarOpen: false,
linkToolbarOpen: false,
query: "",
};
isBlurred = true;
@@ -214,14 +209,15 @@ export class Editor extends React.PureComponent<
public constructor(props: Props & ThemeProps<DefaultTheme>) {
super(props);
this.events.on(EventType.linkMenuOpen, this.handleOpenLinkMenu);
this.events.on(EventType.linkMenuClose, this.handleCloseLinkMenu);
this.events.on(EventType.blockMenuOpen, this.handleOpenBlockMenu);
this.events.on(EventType.blockMenuClose, this.handleCloseBlockMenu);
this.events.on(EventType.emojiMenuOpen, this.handleOpenEmojiMenu);
this.events.on(EventType.emojiMenuClose, this.handleCloseEmojiMenu);
this.events.on(EventType.mentionMenuOpen, this.handleOpenMentionMenu);
this.events.on(EventType.mentionMenuClose, this.handleCloseMentionMenu);
this.events.on(EventType.LinkToolbarOpen, this.handleOpenLinkToolbar);
this.events.on(
EventType.SuggestionsMenuOpen,
this.handleOpenSuggestionsMenu
);
this.events.on(
EventType.SuggestionsMenuClose,
this.handleCloseSuggestionsMenu
);
}
/**
@@ -279,9 +275,9 @@ export class Editor extends React.PureComponent<
if (
!this.isBlurred &&
!this.state.isEditorFocused &&
!this.state.blockMenuOpen &&
!this.state.linkMenuOpen &&
!this.state.selectionMenuOpen
!this.state.suggestionsMenuOpen &&
!this.state.linkToolbarOpen &&
!this.state.selectionToolbarOpen
) {
this.isBlurred = true;
this.props.onBlur?.();
@@ -290,9 +286,9 @@ export class Editor extends React.PureComponent<
if (
this.isBlurred &&
(this.state.isEditorFocused ||
this.state.blockMenuOpen ||
this.state.linkMenuOpen ||
this.state.selectionMenuOpen)
this.state.suggestionsMenuOpen ||
this.state.linkToolbarOpen ||
this.state.selectionToolbarOpen)
) {
this.isBlurred = false;
this.props.onFocus?.();
@@ -666,52 +662,56 @@ export class Editor extends React.PureComponent<
return false;
};
private handleOpenSelectionMenu = () => {
this.setState({ blockMenuOpen: false, selectionMenuOpen: true });
private handleOpenSelectionToolbar = () => {
this.setState((state) => ({
...state,
selectionToolbarOpen: true,
suggestionsMenuOpen: false,
query: "",
}));
};
private handleCloseSelectionMenu = () => {
if (!this.state.selectionMenuOpen) {
private handleCloseSelectionToolbar = () => {
if (!this.state.selectionToolbarOpen) {
return;
}
this.setState({ selectionMenuOpen: false });
this.setState((state) => ({
...state,
selectionToolbarOpen: false,
}));
};
private handleOpenEmojiMenu = (search: string) => {
this.setState({ emojiMenuOpen: true, blockMenuSearch: search });
private handleOpenLinkToolbar = () => {
this.setState((state) => ({
...state,
suggestionsMenuOpen: false,
linkToolbarOpen: true,
query: "",
}));
};
private handleOpenMentionMenu = (search: string) => {
this.setState({ mentionMenuOpen: true, blockMenuSearch: search });
private handleCloseLinkToolbar = () => {
this.setState((state) => ({
...state,
linkToolbarOpen: false,
}));
};
private handleCloseEmojiMenu = () => {
if (!this.state.emojiMenuOpen) {
return;
}
this.setState({ emojiMenuOpen: false });
private handleOpenSuggestionsMenu = (data: {
type: SuggestionsMenuType;
query: string;
}) => {
this.setState((state) => ({
...state,
suggestionsMenuOpen: data.type,
query: data.query,
}));
};
private handleCloseMentionMenu = () => {
if (!this.state.mentionMenuOpen) {
return;
}
this.setState({ mentionMenuOpen: false });
};
private handleOpenLinkMenu = () => {
this.setState({ blockMenuOpen: false, linkMenuOpen: true });
};
private handleCloseLinkMenu = () => {
this.setState({ linkMenuOpen: false });
};
private handleOpenBlockMenu = (search: string) => {
this.setState({ blockMenuOpen: true, blockMenuSearch: search });
};
private handleCloseBlockMenu = (insertNewLine?: boolean) => {
private handleCloseSuggestionsMenu = (
type: SuggestionsMenuType,
insertNewLine?: boolean
) => {
if (insertNewLine) {
const transaction = this.view.state.tr.split(
this.view.state.selection.to
@@ -719,10 +719,14 @@ export class Editor extends React.PureComponent<
this.view.dispatch(transaction);
this.view.focus();
}
if (!this.state.blockMenuOpen) {
if (this.state.suggestionsMenuOpen !== type) {
return;
}
this.setState({ blockMenuOpen: false });
this.setState((state) => ({
...state,
suggestionsMenuOpen: false,
query: "",
}));
};
public render() {
@@ -762,45 +766,68 @@ export class Editor extends React.PureComponent<
<>
{this.marks.link && (
<LinkToolbar
isActive={this.state.linkMenuOpen}
isActive={this.state.linkToolbarOpen}
onCreateLink={this.props.onCreateLink}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onClose={this.handleCloseLinkMenu}
onClose={this.handleCloseLinkToolbar}
/>
)}
{this.nodes.emoji && (
<EmojiMenu
rtl={isRTL}
isActive={this.state.emojiMenuOpen}
search={this.state.blockMenuSearch}
onClose={this.handleCloseEmojiMenu}
isActive={
this.state.suggestionsMenuOpen ===
SuggestionsMenuType.Emoji
}
search={this.state.query}
onClose={(insertNewLine) =>
this.handleCloseSuggestionsMenu(
SuggestionsMenuType.Emoji,
insertNewLine
)
}
/>
)}
{this.nodes.mention && (
<MentionMenu
rtl={isRTL}
isActive={this.state.mentionMenuOpen}
search={this.state.blockMenuSearch}
onClose={this.handleCloseMentionMenu}
isActive={
this.state.suggestionsMenuOpen ===
SuggestionsMenuType.Mention
}
search={this.state.query}
onClose={(insertNewLine) =>
this.handleCloseSuggestionsMenu(
SuggestionsMenuType.Mention,
insertNewLine
)
}
/>
)}
<SelectionToolbar
rtl={isRTL}
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionMenu}
onClose={this.handleCloseSelectionMenu}
onOpen={this.handleOpenSelectionToolbar}
onClose={this.handleCloseSelectionToolbar}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
/>
<BlockMenu
rtl={isRTL}
isActive={this.state.blockMenuOpen}
search={this.state.blockMenuSearch}
onClose={this.handleCloseBlockMenu}
isActive={
this.state.suggestionsMenuOpen === SuggestionsMenuType.Block
}
search={this.state.query}
onClose={(insertNewLine) =>
this.handleCloseSuggestionsMenu(
SuggestionsMenuType.Block,
insertNewLine
)
}
uploadFile={this.props.uploadFile}
onLinkToolbarOpen={this.handleOpenLinkMenu}
onLinkToolbarOpen={this.handleOpenLinkToolbar}
onFileUploadStart={this.props.onFileUploadStart}
onFileUploadStop={this.props.onFileUploadStop}
embeds={this.props.embeds}

View File

@@ -0,0 +1,99 @@
import { PlusIcon } from "outline-icons";
import { Plugin } from "prosemirror-state";
import { findParentNode } from "prosemirror-utils";
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import ReactDOM from "react-dom";
import { SuggestionsMenuType } from "../plugins/Suggestions";
import { EventType } from "../types";
import Suggestion from "./Suggestion";
export default class BlockMenu extends Suggestion {
get defaultOptions() {
return {
type: SuggestionsMenuType.Block,
openRegex: /^\/(\w+)?$/,
closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/,
};
}
get name() {
return "blockmenu";
}
get plugins() {
const button = document.createElement("button");
button.className = "block-menu-trigger";
button.type = "button";
ReactDOM.render(<PlusIcon />, button);
return [
...super.plugins,
new Plugin({
props: {
decorations: (state) => {
const parent = findParentNode(
(node) => node.type.name === "paragraph"
)(state.selection);
if (!parent) {
return;
}
const isTopLevel = state.selection.$from.depth === 1;
if (!isTopLevel) {
return;
}
const decorations: Decoration[] = [];
const isEmptyNode = parent && parent.node.content.size === 0;
const isSlash = parent && parent.node.textContent === "/";
if (isEmptyNode) {
decorations.push(
Decoration.widget(
parent.pos,
() => {
button.addEventListener("click", () => {
this.editor.events.emit(EventType.SuggestionsMenuOpen, {
type: SuggestionsMenuType.Block,
query: "",
});
});
return button;
},
{
key: "block-trigger",
}
)
);
const isEmptyDoc = state.doc.textContent === "";
if (!isEmptyDoc) {
decorations.push(
Decoration.node(
parent.pos,
parent.pos + parent.node.nodeSize,
{
class: "placeholder",
"data-empty-text": this.options.dictionary.newLineEmpty,
}
)
);
}
} else if (isSlash) {
decorations.push(
Decoration.node(parent.pos, parent.pos + parent.node.nodeSize, {
class: "placeholder",
"data-empty-text": ` ${this.options.dictionary.newLineWithSlash}`,
})
);
}
return DecorationSet.create(state.doc, decorations);
},
},
}),
];
}
}

View File

@@ -1,199 +0,0 @@
import { PlusIcon } from "outline-icons";
import { InputRule } from "prosemirror-inputrules";
import { EditorState, Plugin } from "prosemirror-state";
import { isInTable } from "prosemirror-tables";
import { findParentNode } from "prosemirror-utils";
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+)?$/;
const CLOSE_REGEX = /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/;
// based on the input rules code in Prosemirror, here:
// https://github.com/ProseMirror/prosemirror-inputrules/blob/master/src/inputrules.js
export function run(
view: EditorView,
from: number,
to: number,
regex: RegExp,
handler: (
state: EditorState,
match: RegExpExecArray | null,
from?: number,
to?: number
) => boolean | null
) {
if (view.composing) {
return false;
}
const state = view.state;
const $from = state.doc.resolve(from);
if ($from.parent.type.spec.code) {
return false;
}
const textBefore = $from.parent.textBetween(
Math.max(0, $from.parentOffset - MAX_MATCH),
$from.parentOffset,
undefined,
"\ufffc"
);
const match = regex.exec(textBefore);
const tr = handler(state, match, match ? from - match[0].length : from, to);
if (!tr) {
return false;
}
return true;
}
export default class BlockMenuTrigger extends Extension {
get name() {
return "blockmenu";
}
get plugins() {
const button = document.createElement("button");
button.className = "block-menu-trigger";
button.type = "button";
ReactDOM.render(<PlusIcon />, button);
return [
new Plugin({
props: {
handleClick: () => {
this.editor.events.emit(EventType.blockMenuClose);
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.blockMenuOpen, match[1]);
} else {
this.editor.events.emit(EventType.blockMenuClose);
}
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
match ? true : null
);
}
return false;
},
decorations: (state) => {
const parent = findParentNode(
(node) => node.type.name === "paragraph"
)(state.selection);
if (!parent) {
return;
}
const isTopLevel = state.selection.$from.depth === 1;
if (!isTopLevel) {
return;
}
const decorations: Decoration[] = [];
const isEmptyNode = parent && parent.node.content.size === 0;
const isSlash = parent && parent.node.textContent === "/";
if (isEmptyNode) {
decorations.push(
Decoration.widget(
parent.pos,
() => {
button.addEventListener("click", () => {
this.editor.events.emit(EventType.blockMenuOpen, "");
});
return button;
},
{
key: "block-trigger",
}
)
);
const isEmptyDoc = state.doc.textContent === "";
if (!isEmptyDoc) {
decorations.push(
Decoration.node(
parent.pos,
parent.pos + parent.node.nodeSize,
{
class: "placeholder",
"data-empty-text": this.options.dictionary.newLineEmpty,
}
)
);
}
} else if (isSlash) {
decorations.push(
Decoration.node(parent.pos, parent.pos + parent.node.nodeSize, {
class: "placeholder",
"data-empty-text": ` ${this.options.dictionary.newLineWithSlash}`,
})
);
}
return DecorationSet.create(state.doc, decorations);
},
},
}),
];
}
inputRules() {
return [
// main regex should match only:
// /word
new InputRule(OPEN_REGEX, (state, match) => {
if (
match &&
state.selection.$from.parent.type.name === "paragraph" &&
!isInTable(state)
) {
this.editor.events.emit(EventType.blockMenuOpen, 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.blockMenuClose);
}
return null;
}),
];
}
}

View File

@@ -25,17 +25,17 @@ export default class DateTime extends Extension {
// in places other than the start of a line
new InputRule(/\/date\s$/, ({ tr }, _match, start, end) => {
tr.delete(start, end).insertText(getCurrentDateAsString() + " ");
this.editor.events.emit(EventType.blockMenuClose);
this.editor.events.emit(EventType.SuggestionsMenuClose);
return tr;
}),
new InputRule(/\/time\s$/, ({ tr }, _match, start, end) => {
tr.delete(start, end).insertText(getCurrentTimeAsString() + " ");
this.editor.events.emit(EventType.blockMenuClose);
this.editor.events.emit(EventType.SuggestionsMenuClose);
return tr;
}),
new InputRule(/\/datetime\s$/, ({ tr }, _match, start, end) => {
tr.delete(start, end).insertText(`${getCurrentDateTimeAsString()} `);
this.editor.events.emit(EventType.blockMenuClose);
this.editor.events.emit(EventType.SuggestionsMenuClose);
return tr;
}),
];

View File

@@ -1,75 +0,0 @@
import { Plugin } from "prosemirror-state";
import { findBlockNodes } from "prosemirror-utils";
import { Decoration, DecorationSet } from "prosemirror-view";
import Storage from "../../utils/Storage";
import Extension from "../lib/Extension";
import { headingToPersistenceKey } from "../lib/headingToSlug";
import findCollapsedNodes from "../queries/findCollapsedNodes";
export default class Folding extends Extension {
get name() {
return "folding";
}
get plugins() {
let loaded = false;
return [
new Plugin({
view: (view) => {
loaded = false;
view.dispatch(view.state.tr.setMeta("folding", { loaded: true }));
return {};
},
appendTransaction: (transactions, oldState, newState) => {
if (loaded) {
return;
}
if (
!transactions.some((transaction) => transaction.getMeta("folding"))
) {
return;
}
let modified = false;
const tr = newState.tr;
const blocks = findBlockNodes(newState.doc);
for (const block of blocks) {
if (block.node.type.name === "heading") {
const persistKey = headingToPersistenceKey(
block.node,
this.editor.props.id
);
const persistedState = Storage.get(persistKey);
if (persistedState === "collapsed") {
tr.setNodeMarkup(block.pos, undefined, {
...block.node.attrs,
collapsed: true,
});
modified = true;
}
}
}
loaded = true;
return modified ? tr : null;
},
props: {
decorations: (state) => {
const { doc } = state;
const decorations: Decoration[] = findCollapsedNodes(doc).map(
(block) =>
Decoration.node(block.pos, block.pos + block.node.nodeSize, {
class: "folded-content",
})
);
return DecorationSet.create(doc, decorations);
},
},
}),
];
}
}

View File

@@ -17,7 +17,7 @@ function isDropboxPaper(html: string): boolean {
export default class PasteHandler extends Extension {
get name() {
return "markdown-paste";
return "paste-handler";
}
get plugins() {

View File

@@ -0,0 +1,41 @@
import { InputRule } from "prosemirror-inputrules";
import { NodeType, Schema } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import { isInTable } from "prosemirror-tables";
import Extension from "../lib/Extension";
import { SuggestionsMenuPlugin } from "../plugins/Suggestions";
import isInCode from "../queries/isInCode";
import { EventType } from "../types";
export default class Suggestion extends Extension {
get plugins(): Plugin[] {
return [new SuggestionsMenuPlugin(this.editor, this.options)];
}
inputRules = (_options: { type: NodeType; schema: Schema }) => {
console.log(this.name, this.options.openRegex);
return [
new InputRule(this.options.openRegex, (state, match) => {
if (
match &&
state.selection.$from.parent.type.name === "paragraph" &&
(!isInCode(state) || this.options.enabledInCode) &&
(!isInTable(state) || this.options.enabledInTable)
) {
this.editor.events.emit(EventType.SuggestionsMenuOpen, {
type: this.options.type,
query: match[1],
});
}
return null;
}),
new InputRule(this.options.closeRegex, (state, match) => {
if (match) {
this.editor.events.emit(EventType.SuggestionsMenuClose);
}
return null;
}),
];
};
}

View File

@@ -117,7 +117,7 @@ export default class Link extends Mark {
return {
"Mod-k": (state: EditorState, dispatch: Dispatch) => {
if (state.selection.empty) {
this.editor.events.emit(EventType.linkMenuOpen);
this.editor.events.emit(EventType.LinkToolbarOpen);
return true;
}

View File

@@ -1,19 +1,32 @@
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, Plugin } from "prosemirror-state";
import { run } from "../extensions/BlockMenuTrigger";
import {
NodeSpec,
Node as ProsemirrorNode,
NodeType,
Schema,
} from "prosemirror-model";
import { EditorState, TextSelection } from "prosemirror-state";
import Suggestion from "../extensions/Suggestion";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import isInCode from "../queries/isInCode";
import { SuggestionsMenuType } from "../plugins/Suggestions";
import emojiRule from "../rules/emoji";
import { Dispatch, EventType } from "../types";
import Node from "./Node";
import { Dispatch } from "../types";
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 Suggestion {
get type() {
return "node";
}
get defaultOptions() {
return {
type: SuggestionsMenuType.Emoji,
openRegex: /(?:^|\s):([0-9a-zA-Z_+-]+)?$/,
closeRegex: /(?:^|\s):(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/,
enabledInTable: true,
};
}
export default class Emoji extends Node {
get name() {
return "emoji";
}
@@ -63,58 +76,7 @@ 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
match ? true : null
);
}
return false;
},
},
}),
];
}
commands({ type }: { type: NodeType }) {
commands({ type }: { type: NodeType; schema: Schema }) {
return (attrs: Record<string, string>) => (
state: EditorState,
dispatch: Dispatch
@@ -135,50 +97,6 @@ export default class Emoji extends Node {
};
}
inputRules({ type }: { type: NodeType }): InputRule[] {
return [
new InputRule(/^:([a-zA-Z0-9_+-]+):$/, (state, match, start, end) => {
const [okay, markup] = match;
const { tr } = state;
if (okay) {
tr.replaceWith(
start - 1,
end,
type.create({
"data-name": markup,
markup,
})
);
}
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;
}),
];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
const name = node.attrs["data-name"];
if (name) {

View File

@@ -15,6 +15,7 @@ import toggleBlockType from "../commands/toggleBlockType";
import { Command } from "../lib/Extension";
import headingToSlug, { headingToPersistenceKey } from "../lib/headingToSlug";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { FoldingHeadersPlugin } from "../plugins/FoldingHeaders";
import Node from "./Node";
export default class Heading extends Node {
@@ -264,7 +265,7 @@ export default class Heading extends Node {
},
});
return [plugin];
return [new FoldingHeadersPlugin(this.editor.props.id), plugin];
}
inputRules({ type }: { type: NodeType }) {

View File

@@ -1,19 +1,32 @@
import Token from "markdown-it/lib/token";
import { InputRule } from "prosemirror-inputrules";
import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
import { EditorState, TextSelection, Plugin } from "prosemirror-state";
import { run } from "../extensions/BlockMenuTrigger";
import {
NodeSpec,
Node as ProsemirrorNode,
NodeType,
Schema,
} from "prosemirror-model";
import { EditorState, TextSelection } from "prosemirror-state";
import Suggestion from "../extensions/Suggestion";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import isInCode from "../queries/isInCode";
import { SuggestionsMenuType } from "../plugins/Suggestions";
import mentionRule from "../rules/mention";
import { Dispatch, EventType } from "../types";
import Node from "./Node";
import { Dispatch } from "../types";
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
const OPEN_REGEX = /(?:^|\s)@([\p{L}\p{M}\d]+)?$/u;
const CLOSE_REGEX = /(?:^|\s)@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u;
export default class Mention extends Suggestion {
get type() {
return "node";
}
get defaultOptions() {
return {
type: SuggestionsMenuType.Mention,
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
openRegex: /(?:^|\s)@([\p{L}\p{M}\d]+)?$/u,
closeRegex: /(?:^|\s)@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
enabledInTable: true,
};
}
export default class Mention extends Node {
get name() {
return "mention";
}
@@ -66,61 +79,7 @@ export default class Mention extends Node {
return [mentionRule];
}
get plugins() {
return [
new Plugin({
props: {
handleClick: () => {
this.editor.events.emit(EventType.mentionMenuClose);
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.mentionMenuOpen,
match[1]
);
} else {
this.editor.events.emit(EventType.mentionMenuClose);
}
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
match ? true : null
);
}
return false;
},
},
}),
];
}
commands({ type }: { type: NodeType }) {
commands({ type }: { type: NodeType; schema: Schema }) {
return (attrs: Record<string, string>) => (
state: EditorState,
dispatch: Dispatch
@@ -141,33 +100,6 @@ export default class Mention extends Node {
};
}
inputRules(): InputRule[] {
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.editor.events.emit(EventType.mentionMenuOpen, 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.mentionMenuClose);
}
return null;
}),
];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
const mType = node.attrs.type;
const mId = node.attrs.modelId;

View File

@@ -1,7 +1,6 @@
import BlockMenuTrigger from "../extensions/BlockMenuTrigger";
import BlockMenu from "../extensions/BlockMenu";
import ClipboardTextSerializer from "../extensions/ClipboardTextSerializer";
import DateTime from "../extensions/DateTime";
import Folding from "../extensions/Folding";
import History from "../extensions/History";
import Keys from "../extensions/Keys";
import MaxLength from "../extensions/MaxLength";
@@ -106,8 +105,7 @@ export const richExtensions: Nodes = [
TableRow,
Highlight,
TemplatePlaceholder,
Folding,
BlockMenuTrigger,
BlockMenu,
Math,
MathBlock,
PreventTab,

View File

@@ -0,0 +1,67 @@
import { Plugin, PluginKey } from "prosemirror-state";
import { findBlockNodes } from "prosemirror-utils";
import { Decoration, DecorationSet } from "prosemirror-view";
import Storage from "../../utils/Storage";
import { headingToPersistenceKey } from "../lib/headingToSlug";
import findCollapsedNodes from "../queries/findCollapsedNodes";
export class FoldingHeadersPlugin extends Plugin {
constructor(documentId: string | undefined) {
const plugin = new PluginKey("folding");
let loaded = false;
super({
key: plugin,
view: (view) => {
loaded = false;
view.dispatch(view.state.tr.setMeta("folding", { loaded: true }));
return {};
},
appendTransaction: (transactions, oldState, newState) => {
if (loaded) {
return;
}
if (
!transactions.some((transaction) => transaction.getMeta("folding"))
) {
return;
}
let modified = false;
const tr = newState.tr;
const blocks = findBlockNodes(newState.doc);
for (const block of blocks) {
if (block.node.type.name === "heading") {
const persistKey = headingToPersistenceKey(block.node, documentId);
const persistedState = Storage.get(persistKey);
if (persistedState === "collapsed") {
tr.setNodeMarkup(block.pos, undefined, {
...block.node.attrs,
collapsed: true,
});
modified = true;
}
}
}
loaded = true;
return modified ? tr : null;
},
props: {
decorations: (state) => {
const { doc } = state;
const decorations: Decoration[] = findCollapsedNodes(doc).map(
(block) =>
Decoration.node(block.pos, block.pos + block.node.nodeSize, {
class: "folded-content",
})
);
return DecorationSet.create(doc, decorations);
},
},
});
}
}

View File

@@ -0,0 +1,126 @@
import { EditorState, Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import type { Editor } from "../../../app/editor";
import { EventType } from "../types";
const MAX_MATCH = 500;
export enum SuggestionsMenuType {
Emoji = "emoji",
Block = "block",
Mention = "mention",
}
type Options = {
type: SuggestionsMenuType;
openRegex: RegExp;
closeRegex: RegExp;
enabledInCode: true;
enabledInTable: true;
};
export class SuggestionsMenuPlugin extends Plugin {
constructor(editor: Editor, options: Options) {
super({
props: {
handleClick: () => {
editor.events.emit(options.type);
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 this.execute(
view,
pos,
pos,
options.openRegex,
(state, match) => {
if (match) {
editor.events.emit(EventType.SuggestionsMenuOpen, {
type: options.type,
query: match[1],
});
} else {
editor.events.emit(
EventType.SuggestionsMenuClose,
options.type
);
}
return null;
}
);
});
}
const { pos } = view.state.selection.$from;
// 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"
) {
return this.execute(
view,
pos,
pos,
options.openRegex,
(state, match) =>
// just tell Prosemirror we handled it and not to do anything
match ? true : null
);
}
return false;
},
},
});
}
// based on the input rules code in Prosemirror, here:
// https://github.com/ProseMirror/prosemirror-inputrules/blob/master/src/inputrules.js
private execute(
view: EditorView,
from: number,
to: number,
regex: RegExp,
handler: (
state: EditorState,
match: RegExpExecArray | null,
from?: number,
to?: number
) => boolean | null
) {
if (view.composing) {
return false;
}
const state = view.state;
const $from = state.doc.resolve(from);
if ($from.parent.type.spec.code) {
return false;
}
const textBefore = $from.parent.textBetween(
Math.max(0, $from.parentOffset - MAX_MATCH),
$from.parentOffset,
undefined,
"\ufffc"
);
const match = regex.exec(textBefore);
const tr = handler(state, match, match ? from - match[0].length : from, to);
if (!tr) {
return false;
}
return true;
}
}

View File

@@ -7,14 +7,9 @@ import { DefaultTheme } from "styled-components";
export type PlainTextSerializer = (node: ProsemirrorNode) => string;
export enum EventType {
blockMenuOpen = "blockMenuOpen",
blockMenuClose = "blockMenuClose",
emojiMenuOpen = "emojiMenuOpen",
emojiMenuClose = "emojiMenuClose",
linkMenuOpen = "linkMenuOpen",
linkMenuClose = "linkMenuClose",
mentionMenuOpen = "mentionMenuOpen",
mentionMenuClose = "mentionMenuClose",
SuggestionsMenuOpen = "suggestionMenuOpen",
SuggestionsMenuClose = "suggestionMenuClose",
LinkToolbarOpen = "linkMenuOpen",
}
export type MenuItem = {