chore: Editor 'plugin' -> 'extension'
They've always been called extensions, not sure why the folder was plugins. Part of ongoing spring cleaning
This commit is contained in:
@@ -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;
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "../lib/Extension";
|
||||
import textBetween from "../lib/textBetween";
|
||||
|
||||
/**
|
||||
* A plugin that allows overriding the default behavior of the editor to allow
|
||||
* copying text for nodes that do not inherently have text children by defining
|
||||
* a `toPlainText` method in the node spec.
|
||||
*/
|
||||
export default class ClipboardTextSerializer extends Extension {
|
||||
get name() {
|
||||
return "clipboardTextSerializer";
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const textSerializers = Object.fromEntries(
|
||||
Object.entries(this.editor.schema.nodes)
|
||||
.filter(([, node]) => node.spec.toPlainText)
|
||||
.map(([name, node]) => [name, node.spec.toPlainText])
|
||||
);
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("clipboardTextSerializer"),
|
||||
props: {
|
||||
clipboardTextSerializer: () => {
|
||||
const { doc, selection } = this.editor.view.state;
|
||||
const { ranges } = selection;
|
||||
const from = Math.min(...ranges.map((range) => range.$from.pos));
|
||||
const to = Math.max(...ranges.map((range) => range.$to.pos));
|
||||
|
||||
return textBetween(doc, from, to, textSerializers);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { InputRule } from "prosemirror-inputrules";
|
||||
import { Schema } from "prosemirror-model";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import {
|
||||
getCurrentDateAsString,
|
||||
getCurrentDateTimeAsString,
|
||||
getCurrentTimeAsString,
|
||||
} from "../../utils/date";
|
||||
import Extension from "../lib/Extension";
|
||||
import { Dispatch, EventType } from "../types";
|
||||
|
||||
/**
|
||||
* An editor extension that adds commands to insert the current date and time.
|
||||
*/
|
||||
export default class DateTime extends Extension {
|
||||
get name() {
|
||||
return "date_time";
|
||||
}
|
||||
|
||||
inputRules() {
|
||||
return [
|
||||
// Note: There is a space at the end of the pattern here otherwise the
|
||||
// /datetime rule can never be matched.
|
||||
// these extra input patterns are needed until the block menu matches
|
||||
// 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);
|
||||
return tr;
|
||||
}),
|
||||
new InputRule(/\/time\s$/, ({ tr }, _match, start, end) => {
|
||||
tr.delete(start, end).insertText(getCurrentTimeAsString() + " ");
|
||||
this.editor.events.emit(EventType.blockMenuClose);
|
||||
return tr;
|
||||
}),
|
||||
new InputRule(/\/datetime\s$/, ({ tr }, _match, start, end) => {
|
||||
tr.delete(start, end).insertText(`${getCurrentDateTimeAsString()} `);
|
||||
this.editor.events.emit(EventType.blockMenuClose);
|
||||
return tr;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
commands(_options: { schema: Schema }) {
|
||||
return {
|
||||
date: () => (state: EditorState, dispatch: Dispatch) => {
|
||||
dispatch(state.tr.insertText(getCurrentDateAsString() + " "));
|
||||
return true;
|
||||
},
|
||||
time: () => (state: EditorState, dispatch: Dispatch) => {
|
||||
dispatch(state.tr.insertText(getCurrentTimeAsString() + " "));
|
||||
return true;
|
||||
},
|
||||
datetime: () => (state: EditorState, dispatch: Dispatch) => {
|
||||
dispatch(state.tr.insertText(getCurrentDateTimeAsString() + " "));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { history, undo, redo } from "prosemirror-history";
|
||||
import { undoInputRule } from "prosemirror-inputrules";
|
||||
import Extension from "../lib/Extension";
|
||||
|
||||
export default class History extends Extension {
|
||||
get name() {
|
||||
return "history";
|
||||
}
|
||||
|
||||
keys() {
|
||||
return {
|
||||
"Mod-z": undo,
|
||||
"Mod-y": redo,
|
||||
"Shift-Mod-z": redo,
|
||||
Backspace: undoInputRule,
|
||||
};
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
return [history()];
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { GapCursor } from "prosemirror-gapcursor";
|
||||
import {
|
||||
Plugin,
|
||||
Selection,
|
||||
AllSelection,
|
||||
TextSelection,
|
||||
EditorState,
|
||||
} from "prosemirror-state";
|
||||
import Extension, { Command } from "../lib/Extension";
|
||||
import isInCode from "../queries/isInCode";
|
||||
|
||||
export default class Keys extends Extension {
|
||||
get name() {
|
||||
return "keys";
|
||||
}
|
||||
|
||||
keys(): Record<string, Command> {
|
||||
const onCancel = () => {
|
||||
if (this.editor.props.onCancel) {
|
||||
this.editor.props.onCancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
// Shortcuts for when editor has separate edit mode
|
||||
"Mod-Escape": onCancel,
|
||||
"Shift-Escape": onCancel,
|
||||
"Mod-s": () => {
|
||||
if (this.editor.props.onSave) {
|
||||
this.editor.props.onSave({ done: false });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
"Mod-Enter": (state: EditorState) => {
|
||||
if (!isInCode(state) && this.editor.props.onSave) {
|
||||
this.editor.props.onSave({ done: true });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
Escape: () => {
|
||||
(this.editor.view.dom as HTMLElement).blur();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
// we can't use the keys bindings for this as we want to preventDefault
|
||||
// on the original keyboard event when handled
|
||||
handleKeyDown: (view, event) => {
|
||||
if (view.state.selection instanceof AllSelection) {
|
||||
if (event.key === "ArrowUp") {
|
||||
const selection = Selection.atStart(view.state.doc);
|
||||
view.dispatch(view.state.tr.setSelection(selection));
|
||||
return true;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
const selection = Selection.atEnd(view.state.doc);
|
||||
view.dispatch(view.state.tr.setSelection(selection));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// edge case where horizontal gap cursor does nothing if Enter key
|
||||
// is pressed. Insert a newline and then move the cursor into it.
|
||||
if (view.state.selection instanceof GapCursor) {
|
||||
if (event.key === "Enter") {
|
||||
view.dispatch(
|
||||
view.state.tr.insert(
|
||||
view.state.selection.from,
|
||||
view.state.schema.nodes.paragraph.create({})
|
||||
)
|
||||
);
|
||||
view.dispatch(
|
||||
view.state.tr.setSelection(
|
||||
TextSelection.near(
|
||||
view.state.doc.resolve(view.state.selection.from),
|
||||
-1
|
||||
)
|
||||
)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { MathView } from "@benrbray/prosemirror-math";
|
||||
import { Node as ProseNode } from "prosemirror-model";
|
||||
import { Plugin, PluginKey, PluginSpec } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
|
||||
export interface IMathPluginState {
|
||||
macros: { [cmd: string]: string };
|
||||
activeNodeViews: MathView[];
|
||||
prevCursorPos: number;
|
||||
}
|
||||
|
||||
const MATH_PLUGIN_KEY = new PluginKey<IMathPluginState>("prosemirror-math");
|
||||
|
||||
export function createMathView(displayMode: boolean) {
|
||||
return (
|
||||
node: ProseNode,
|
||||
view: EditorView,
|
||||
getPos: boolean | (() => number)
|
||||
): MathView => {
|
||||
// dynamically load katex styles and fonts
|
||||
import("katex/dist/katex.min.css");
|
||||
|
||||
const pluginState = MATH_PLUGIN_KEY.getState(view.state);
|
||||
if (!pluginState) {
|
||||
throw new Error("no math plugin!");
|
||||
}
|
||||
const nodeViews = pluginState.activeNodeViews;
|
||||
|
||||
// set up NodeView
|
||||
const nodeView = new MathView(
|
||||
node,
|
||||
view,
|
||||
getPos as () => number,
|
||||
{
|
||||
katexOptions: {
|
||||
displayMode,
|
||||
output: "html",
|
||||
macros: pluginState.macros,
|
||||
},
|
||||
},
|
||||
MATH_PLUGIN_KEY,
|
||||
() => {
|
||||
nodeViews.splice(nodeViews.indexOf(nodeView));
|
||||
}
|
||||
);
|
||||
|
||||
nodeViews.push(nodeView);
|
||||
return nodeView;
|
||||
};
|
||||
}
|
||||
|
||||
const mathPluginSpec: PluginSpec<IMathPluginState> = {
|
||||
key: MATH_PLUGIN_KEY,
|
||||
state: {
|
||||
init() {
|
||||
return {
|
||||
macros: {},
|
||||
activeNodeViews: [],
|
||||
prevCursorPos: 0,
|
||||
};
|
||||
},
|
||||
apply(tr, value, oldState) {
|
||||
return {
|
||||
activeNodeViews: value.activeNodeViews,
|
||||
macros: value.macros,
|
||||
prevCursorPos: oldState.selection.from,
|
||||
};
|
||||
},
|
||||
},
|
||||
props: {
|
||||
nodeViews: {
|
||||
math_inline: createMathView(false),
|
||||
math_block: createMathView(true),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default new Plugin(mathPluginSpec);
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Plugin, Transaction } from "prosemirror-state";
|
||||
import Extension from "../lib/Extension";
|
||||
|
||||
export default class MaxLength extends Extension {
|
||||
get name() {
|
||||
return "maxlength";
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
filterTransaction: (tr: Transaction) => {
|
||||
if (this.options.maxLength) {
|
||||
const result = tr.doc && tr.doc.nodeSize > this.options.maxLength;
|
||||
return !result;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
import { Plugin, PluginKey, Transaction } from "prosemirror-state";
|
||||
import { findBlockNodes } from "prosemirror-utils";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
type MermaidState = {
|
||||
decorationSet: DecorationSet;
|
||||
diagramVisibility: Record<number, boolean>;
|
||||
isDark: boolean;
|
||||
};
|
||||
|
||||
function getNewState({
|
||||
doc,
|
||||
name,
|
||||
pluginState,
|
||||
}: {
|
||||
doc: Node;
|
||||
name: string;
|
||||
pluginState: MermaidState;
|
||||
}) {
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
// Find all blocks that represent Mermaid diagrams
|
||||
const blocks: { node: Node; pos: number }[] = findBlockNodes(doc).filter(
|
||||
(item) =>
|
||||
item.node.type.name === name && item.node.attrs.language === "mermaidjs"
|
||||
);
|
||||
|
||||
blocks.forEach((block) => {
|
||||
const diagramDecorationPos = block.pos + block.node.nodeSize;
|
||||
const existingDecorations = pluginState.decorationSet.find(
|
||||
block.pos,
|
||||
diagramDecorationPos
|
||||
);
|
||||
|
||||
// Attempt to find the existing diagramId from the decoration, or assign
|
||||
// a new one if none exists yet.
|
||||
let diagramId = existingDecorations[0]?.spec["diagramId"];
|
||||
if (diagramId === undefined) {
|
||||
diagramId = uuidv4();
|
||||
}
|
||||
|
||||
// Make the diagram visible by default if it contains source code
|
||||
if (pluginState.diagramVisibility[diagramId] === undefined) {
|
||||
pluginState.diagramVisibility[diagramId] = !!block.node.textContent;
|
||||
}
|
||||
|
||||
const diagramDecoration = Decoration.widget(
|
||||
block.pos + block.node.nodeSize,
|
||||
() => {
|
||||
const elementId = "mermaid-diagram-wrapper-" + diagramId;
|
||||
const element =
|
||||
document.getElementById(elementId) || document.createElement("div");
|
||||
element.id = elementId;
|
||||
element.classList.add("mermaid-diagram-wrapper");
|
||||
|
||||
if (pluginState.diagramVisibility[diagramId] === false) {
|
||||
element.classList.add("diagram-hidden");
|
||||
return element;
|
||||
} else {
|
||||
element.classList.remove("diagram-hidden");
|
||||
}
|
||||
|
||||
import("mermaid").then((module) => {
|
||||
module.default.initialize({
|
||||
startOnLoad: true,
|
||||
flowchart: {
|
||||
htmlLabels: false,
|
||||
},
|
||||
// TODO: Make dynamic based on the width of the editor or remove in
|
||||
// the future if Mermaid is able to handle this automatically.
|
||||
gantt: {
|
||||
useWidth: 700,
|
||||
},
|
||||
theme: pluginState.isDark ? "dark" : "default",
|
||||
fontFamily: "inherit",
|
||||
});
|
||||
try {
|
||||
module.default.render(
|
||||
"mermaid-diagram-" + diagramId,
|
||||
block.node.textContent,
|
||||
(svgCode) => {
|
||||
element.innerHTML = svgCode;
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
const errorNode = document.getElementById(
|
||||
"d" + "mermaid-diagram-" + diagramId
|
||||
);
|
||||
if (errorNode) {
|
||||
element.appendChild(errorNode);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return element;
|
||||
},
|
||||
{
|
||||
diagramId,
|
||||
}
|
||||
);
|
||||
|
||||
const attributes = { "data-diagram-id": "" + diagramId };
|
||||
if (pluginState.diagramVisibility[diagramId] !== false) {
|
||||
attributes["class"] = "code-hidden";
|
||||
}
|
||||
|
||||
const diagramIdDecoration = Decoration.node(
|
||||
block.pos,
|
||||
block.pos + block.node.nodeSize,
|
||||
attributes,
|
||||
{
|
||||
diagramId,
|
||||
}
|
||||
);
|
||||
|
||||
decorations.push(diagramDecoration);
|
||||
decorations.push(diagramIdDecoration);
|
||||
});
|
||||
|
||||
return {
|
||||
decorationSet: DecorationSet.create(doc, decorations),
|
||||
diagramVisibility: pluginState.diagramVisibility,
|
||||
isDark: pluginState.isDark,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Mermaid({
|
||||
name,
|
||||
isDark,
|
||||
}: {
|
||||
name: string;
|
||||
isDark: boolean;
|
||||
}) {
|
||||
let diagramShown = false;
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey("mermaid"),
|
||||
state: {
|
||||
init: (_: Plugin, { doc }) => {
|
||||
const pluginState: MermaidState = {
|
||||
decorationSet: DecorationSet.create(doc, []),
|
||||
diagramVisibility: {},
|
||||
isDark,
|
||||
};
|
||||
return pluginState;
|
||||
},
|
||||
apply: (
|
||||
transaction: Transaction,
|
||||
pluginState: MermaidState,
|
||||
oldState,
|
||||
state
|
||||
) => {
|
||||
const nodeName = state.selection.$head.parent.type.name;
|
||||
const previousNodeName = oldState.selection.$head.parent.type.name;
|
||||
const codeBlockChanged =
|
||||
transaction.docChanged && [nodeName, previousNodeName].includes(name);
|
||||
const ySyncEdit = !!transaction.getMeta("y-sync$");
|
||||
const mermaidMeta = transaction.getMeta("mermaid");
|
||||
const themeMeta = transaction.getMeta("theme");
|
||||
const diagramToggled = mermaidMeta?.toggleDiagram !== undefined;
|
||||
const themeToggled = themeMeta?.isDark !== undefined;
|
||||
|
||||
if (themeToggled) {
|
||||
pluginState.isDark = themeMeta.isDark;
|
||||
}
|
||||
|
||||
if (diagramToggled) {
|
||||
pluginState.diagramVisibility[
|
||||
mermaidMeta.toggleDiagram
|
||||
] = !pluginState.diagramVisibility[mermaidMeta.toggleDiagram];
|
||||
}
|
||||
|
||||
if (
|
||||
!diagramShown ||
|
||||
themeToggled ||
|
||||
codeBlockChanged ||
|
||||
diagramToggled ||
|
||||
ySyncEdit
|
||||
) {
|
||||
diagramShown = true;
|
||||
return getNewState({
|
||||
doc: transaction.doc,
|
||||
name,
|
||||
pluginState,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
decorationSet: pluginState.decorationSet.map(
|
||||
transaction.mapping,
|
||||
transaction.doc
|
||||
),
|
||||
diagramVisibility: pluginState.diagramVisibility,
|
||||
isDark: pluginState.isDark,
|
||||
};
|
||||
},
|
||||
},
|
||||
view: (view) => {
|
||||
if (!diagramShown) {
|
||||
// we don't draw diagrams on code blocks on the first render as part of mounting
|
||||
// as it's expensive (relative to the rest of the document). Instead let
|
||||
// it render without a diagram and then trigger a defered render of Mermaid
|
||||
// by updating the plugins metadata
|
||||
setTimeout(() => {
|
||||
view.dispatch(view.state.tr.setMeta("mermaid", { loaded: true }));
|
||||
}, 10);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state).decorationSet;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { toggleMark } from "prosemirror-commands";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { isInTable } from "prosemirror-tables";
|
||||
import { isUrl } from "../../utils/urls";
|
||||
import Extension from "../lib/Extension";
|
||||
import isMarkdown from "../lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "../lib/markdown/normalize";
|
||||
import isInCode from "../queries/isInCode";
|
||||
import isInList from "../queries/isInList";
|
||||
import { LANGUAGES } from "./Prism";
|
||||
|
||||
function isDropboxPaper(html: string): boolean {
|
||||
// The best we have to detect if a paste is likely coming from Paper
|
||||
// In this case it's actually better to use the text version
|
||||
return html?.includes("usually-unique-id");
|
||||
}
|
||||
|
||||
export default class PasteHandler extends Extension {
|
||||
get name() {
|
||||
return "markdown-paste";
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
transformPastedHTML(html: string) {
|
||||
if (isDropboxPaper(html)) {
|
||||
// Fixes double paragraphs when pasting from Dropbox Paper
|
||||
html = html.replace(/<div><br><\/div>/gi, "<p></p>");
|
||||
}
|
||||
return html;
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_, event) => {
|
||||
if (event.key === "Shift") {
|
||||
this.shiftKey = true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
keyup: (_, event) => {
|
||||
if (event.key === "Shift") {
|
||||
this.shiftKey = false;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
handlePaste: (view, event: ClipboardEvent) => {
|
||||
// Do nothing if the document isn't currently editable
|
||||
if (view.props.editable && !view.props.editable(view.state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default behavior if there is nothing on the clipboard or were
|
||||
// special pasting with no formatting (Shift held)
|
||||
if (!event.clipboardData || this.shiftKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
const html = event.clipboardData.getData("text/html");
|
||||
const vscode = event.clipboardData.getData("vscode-editor-data");
|
||||
const { state, dispatch } = view;
|
||||
|
||||
// first check if the clipboard contents can be parsed as a single
|
||||
// url, this is mainly for allowing pasted urls to become embeds
|
||||
if (isUrl(text)) {
|
||||
// just paste the link mark directly onto the selected text
|
||||
if (!state.selection.empty) {
|
||||
toggleMark(this.editor.schema.marks.link, { href: text })(
|
||||
state,
|
||||
dispatch
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Is this link embeddable? Create an embed!
|
||||
const { embeds } = this.editor.props;
|
||||
|
||||
if (
|
||||
embeds &&
|
||||
!isInTable(state) &&
|
||||
!isInCode(state) &&
|
||||
!isInList(state)
|
||||
) {
|
||||
for (const embed of embeds) {
|
||||
const matches = embed.matcher(text);
|
||||
if (matches) {
|
||||
this.editor.commands.embed({
|
||||
href: text,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// well, it's not an embed and there is no text selected – so just
|
||||
// go ahead and insert the link directly
|
||||
const transaction = view.state.tr
|
||||
.insertText(text, state.selection.from, state.selection.to)
|
||||
.addMark(
|
||||
state.selection.from,
|
||||
state.selection.to + text.length,
|
||||
state.schema.marks.link.create({ href: text })
|
||||
);
|
||||
view.dispatch(transaction);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the users selection is currently in a code block then paste
|
||||
// as plain text, ignore all formatting and HTML content.
|
||||
if (isInCode(view.state)) {
|
||||
event.preventDefault();
|
||||
|
||||
view.dispatch(view.state.tr.insertText(text));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Because VSCode is an especially popular editor that places metadata
|
||||
// on the clipboard, we can parse it to find out what kind of content
|
||||
// was pasted.
|
||||
const vscodeMeta = vscode ? JSON.parse(vscode) : undefined;
|
||||
const pasteCodeLanguage = vscodeMeta?.mode;
|
||||
|
||||
if (pasteCodeLanguage && pasteCodeLanguage !== "markdown") {
|
||||
event.preventDefault();
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
.replaceSelectionWith(
|
||||
view.state.schema.nodes.code_fence.create({
|
||||
language: Object.keys(LANGUAGES).includes(vscodeMeta.mode)
|
||||
? vscodeMeta.mode
|
||||
: null,
|
||||
})
|
||||
)
|
||||
.insertText(text)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the HTML on the clipboard is from Prosemirror then the best
|
||||
// compatability is to just use the HTML parser, regardless of
|
||||
// whether it "looks" like Markdown, see: outline/outline#2416
|
||||
if (html?.includes("data-pm-slice")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the text on the clipboard looks like Markdown OR there is no
|
||||
// html on the clipboard then try to parse content as Markdown
|
||||
if (
|
||||
(isMarkdown(text) && !isDropboxPaper(html)) ||
|
||||
html.length === 0 ||
|
||||
pasteCodeLanguage === "markdown"
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
const paste = this.editor.pasteParser.parse(
|
||||
normalizePastedMarkdown(text)
|
||||
);
|
||||
const slice = paste.slice(0);
|
||||
|
||||
const transaction = view.state.tr.replaceSelection(slice);
|
||||
view.dispatch(transaction);
|
||||
return true;
|
||||
}
|
||||
|
||||
// otherwise use the default HTML parser which will handle all paste
|
||||
// "from the web" events
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** Tracks whether the Shift key is currently held down */
|
||||
private shiftKey = false;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import Extension from "../lib/Extension";
|
||||
|
||||
export default class Placeholder extends Extension {
|
||||
get name() {
|
||||
return "empty-placeholder";
|
||||
}
|
||||
|
||||
get defaultOptions() {
|
||||
return {
|
||||
emptyNodeClass: "placeholder",
|
||||
placeholder: "",
|
||||
};
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
const { doc } = state;
|
||||
const decorations: Decoration[] = [];
|
||||
const completelyEmpty =
|
||||
doc.textContent === "" &&
|
||||
doc.childCount <= 1 &&
|
||||
doc.content.size <= 2;
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!completelyEmpty) {
|
||||
return;
|
||||
}
|
||||
if (pos !== 0 || node.type.name !== "paragraph") {
|
||||
return;
|
||||
}
|
||||
|
||||
const decoration = Decoration.node(pos, pos + node.nodeSize, {
|
||||
class: this.options.emptyNodeClass,
|
||||
"data-empty-text": this.options.placeholder,
|
||||
});
|
||||
decorations.push(decoration);
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import Extension, { Command } from "../lib/Extension";
|
||||
|
||||
export default class PreventTab extends Extension {
|
||||
get name() {
|
||||
return "preventTab";
|
||||
}
|
||||
|
||||
keys(): Record<string, Command> {
|
||||
return {
|
||||
// No-ops prevent Tab escaping the editor bounds
|
||||
Tab: () => true,
|
||||
"Shift-Tab": () => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import { flattenDeep, padStart } from "lodash";
|
||||
import { Node } from "prosemirror-model";
|
||||
import { Plugin, PluginKey, Transaction } from "prosemirror-state";
|
||||
import { findBlockNodes } from "prosemirror-utils";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import refractor from "refractor/core";
|
||||
|
||||
export const LANGUAGES = {
|
||||
none: "None", // additional entry to disable highlighting
|
||||
bash: "Bash",
|
||||
css: "CSS",
|
||||
clike: "C",
|
||||
csharp: "C#",
|
||||
elixir: "Elixir",
|
||||
erlang: "Erlang",
|
||||
go: "Go",
|
||||
graphql: "GraphQL",
|
||||
groovy: "Groovy",
|
||||
haskell: "Haskell",
|
||||
markup: "HTML",
|
||||
ini: "INI",
|
||||
java: "Java",
|
||||
javascript: "JavaScript",
|
||||
json: "JSON",
|
||||
kotlin: "Kotlin",
|
||||
lisp: "Lisp",
|
||||
lua: "Lua",
|
||||
mermaidjs: "Mermaid Diagram",
|
||||
nix: "Nix",
|
||||
objectivec: "Objective-C",
|
||||
ocaml: "OCaml",
|
||||
perl: "Perl",
|
||||
php: "PHP",
|
||||
powershell: "Powershell",
|
||||
python: "Python",
|
||||
ruby: "Ruby",
|
||||
rust: "Rust",
|
||||
scala: "Scala",
|
||||
sql: "SQL",
|
||||
solidity: "Solidity",
|
||||
swift: "Swift",
|
||||
toml: "TOML",
|
||||
typescript: "TypeScript",
|
||||
vb: "Visual Basic",
|
||||
yaml: "YAML",
|
||||
zig: "Zig",
|
||||
};
|
||||
|
||||
type ParsedNode = {
|
||||
text: string;
|
||||
classes: string[];
|
||||
};
|
||||
|
||||
const cache: Record<number, { node: Node; decorations: Decoration[] }> = {};
|
||||
|
||||
function getDecorations({
|
||||
doc,
|
||||
name,
|
||||
lineNumbers,
|
||||
}: {
|
||||
/** The prosemirror document to operate on. */
|
||||
doc: Node;
|
||||
/** The node name. */
|
||||
name: string;
|
||||
/** Whether to include decorations representing line numbers */
|
||||
lineNumbers?: boolean;
|
||||
}) {
|
||||
const decorations: Decoration[] = [];
|
||||
const blocks: { node: Node; pos: number }[] = findBlockNodes(doc).filter(
|
||||
(item) => item.node.type.name === name
|
||||
);
|
||||
|
||||
function parseNodes(
|
||||
nodes: refractor.RefractorNode[],
|
||||
classNames: string[] = []
|
||||
): any {
|
||||
return nodes.map((node) => {
|
||||
if (node.type === "element") {
|
||||
const classes = [...classNames, ...(node.properties.className || [])];
|
||||
return parseNodes(node.children, classes);
|
||||
}
|
||||
|
||||
return {
|
||||
text: node.value,
|
||||
classes: classNames,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
blocks.forEach((block) => {
|
||||
let startPos = block.pos + 1;
|
||||
const language = block.node.attrs.language;
|
||||
if (!language || language === "none" || !refractor.registered(language)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lineDecorations = [];
|
||||
|
||||
if (!cache[block.pos] || !cache[block.pos].node.eq(block.node)) {
|
||||
if (lineNumbers) {
|
||||
const lineCount =
|
||||
(block.node.textContent.match(/\n/g) || []).length + 1;
|
||||
const gutterWidth = String(lineCount).length;
|
||||
|
||||
const lineCountText = new Array(lineCount)
|
||||
.fill(0)
|
||||
.map((_, i) => padStart(`${i + 1}`, gutterWidth, " "))
|
||||
.join(" \n");
|
||||
|
||||
lineDecorations.push(
|
||||
Decoration.node(
|
||||
block.pos,
|
||||
block.pos + block.node.nodeSize,
|
||||
{
|
||||
"data-line-numbers": `${lineCountText} `,
|
||||
style: `--line-number-gutter-width: ${gutterWidth};`,
|
||||
},
|
||||
{
|
||||
key: `line-${lineCount}-gutter`,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const nodes = refractor.highlight(block.node.textContent, language);
|
||||
const newDecorations = flattenDeep(parseNodes(nodes))
|
||||
.map((node: ParsedNode) => {
|
||||
const from = startPos;
|
||||
const to = from + node.text.length;
|
||||
|
||||
startPos = to;
|
||||
|
||||
return {
|
||||
...node,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
})
|
||||
.filter((node) => node.classes && node.classes.length)
|
||||
.map((node) =>
|
||||
Decoration.inline(node.from, node.to, {
|
||||
class: node.classes.join(" "),
|
||||
})
|
||||
)
|
||||
.concat(lineDecorations);
|
||||
|
||||
cache[block.pos] = {
|
||||
node: block.node,
|
||||
decorations: newDecorations,
|
||||
};
|
||||
}
|
||||
|
||||
cache[block.pos].decorations.forEach((decoration) => {
|
||||
decorations.push(decoration);
|
||||
});
|
||||
});
|
||||
|
||||
Object.keys(cache)
|
||||
.filter((pos) => !blocks.find((block) => block.pos === Number(pos)))
|
||||
.forEach((pos) => {
|
||||
delete cache[Number(pos)];
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
|
||||
export default function Prism({
|
||||
name,
|
||||
lineNumbers,
|
||||
}: {
|
||||
/** The node name. */
|
||||
name: string;
|
||||
/** Whether to include decorations representing line numbers */
|
||||
lineNumbers?: boolean;
|
||||
}) {
|
||||
let highlighted = false;
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey("prism"),
|
||||
state: {
|
||||
init: (_: Plugin, { doc }) => DecorationSet.create(doc, []),
|
||||
apply: (transaction: Transaction, decorationSet, oldState, state) => {
|
||||
const nodeName = state.selection.$head.parent.type.name;
|
||||
const previousNodeName = oldState.selection.$head.parent.type.name;
|
||||
const codeBlockChanged =
|
||||
transaction.docChanged && [nodeName, previousNodeName].includes(name);
|
||||
const ySyncEdit = !!transaction.getMeta("y-sync$");
|
||||
|
||||
if (!highlighted || codeBlockChanged || ySyncEdit) {
|
||||
highlighted = true;
|
||||
return getDecorations({ doc: transaction.doc, name, lineNumbers });
|
||||
}
|
||||
|
||||
return decorationSet.map(transaction.mapping, transaction.doc);
|
||||
},
|
||||
},
|
||||
view: (view) => {
|
||||
if (!highlighted) {
|
||||
// we don't highlight code blocks on the first render as part of mounting
|
||||
// as it's expensive (relative to the rest of the document). Instead let
|
||||
// it render un-highlighted and then trigger a defered render of Prism
|
||||
// by updating the plugins metadata
|
||||
setTimeout(() => {
|
||||
view.dispatch(view.state.tr.setMeta("prism", { loaded: true }));
|
||||
}, 10);
|
||||
}
|
||||
return {};
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { ellipsis, smartQuotes, InputRule } from "prosemirror-inputrules";
|
||||
import Extension from "../lib/Extension";
|
||||
|
||||
const rightArrow = new InputRule(/->$/, "→");
|
||||
const oneHalf = new InputRule(/1\/2$/, "½");
|
||||
const threeQuarters = new InputRule(/3\/4$/, "¾");
|
||||
const copyright = new InputRule(/\(c\)$/, "©️");
|
||||
const registered = new InputRule(/\(r\)$/, "®️");
|
||||
const trademarked = new InputRule(/\(tm\)$/, "™️");
|
||||
|
||||
export default class SmartText extends Extension {
|
||||
get name() {
|
||||
return "smart_text";
|
||||
}
|
||||
|
||||
inputRules() {
|
||||
return [
|
||||
rightArrow,
|
||||
oneHalf,
|
||||
threeQuarters,
|
||||
copyright,
|
||||
registered,
|
||||
trademarked,
|
||||
ellipsis,
|
||||
...smartQuotes,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { NodeType } from "prosemirror-model";
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "../lib/Extension";
|
||||
|
||||
export default class TrailingNode extends Extension {
|
||||
get name() {
|
||||
return "trailing_node";
|
||||
}
|
||||
|
||||
get defaultOptions() {
|
||||
return {
|
||||
node: "paragraph",
|
||||
notAfter: ["paragraph", "heading"],
|
||||
};
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const plugin = new PluginKey(this.name);
|
||||
const disabledNodes = Object.entries(this.editor.schema.nodes)
|
||||
.map(([, value]) => value)
|
||||
.filter((node: NodeType) => this.options.notAfter.includes(node.name));
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: plugin,
|
||||
view: () => ({
|
||||
update: (view) => {
|
||||
const { state } = view;
|
||||
const insertNodeAtEnd = plugin.getState(state);
|
||||
|
||||
if (!insertNodeAtEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { doc, schema, tr } = state;
|
||||
const type = schema.nodes[this.options.node];
|
||||
const transaction = tr.insert(doc.content.size, type.create());
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
}),
|
||||
state: {
|
||||
init: (_, state) => {
|
||||
const lastNode = state.tr.doc.lastChild;
|
||||
return lastNode ? !disabledNodes.includes(lastNode.type) : false;
|
||||
},
|
||||
apply: (tr, value) => {
|
||||
if (!tr.docChanged) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const lastNode = tr.doc.lastChild;
|
||||
return lastNode ? !disabledNodes.includes(lastNode.type) : false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user