@@ -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}
|
||||
|
||||
99
shared/editor/extensions/BlockMenu.tsx
Normal file
99
shared/editor/extensions/BlockMenu.tsx
Normal 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);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
41
shared/editor/extensions/Suggestion.tsx
Normal file
41
shared/editor/extensions/Suggestion.tsx
Normal 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;
|
||||
}),
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
67
shared/editor/plugins/FoldingHeaders.ts
Normal file
67
shared/editor/plugins/FoldingHeaders.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
126
shared/editor/plugins/Suggestions.ts
Normal file
126
shared/editor/plugins/Suggestions.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user