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:
Tom Moor
2023-04-09 17:24:49 -04:00
parent 2f9a56aa6f
commit 75aea90972
22 changed files with 20 additions and 21 deletions

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

@@ -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);
},
},
}),
];
}
}

View File

@@ -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;
},
};
}
}

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

@@ -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()];
}
}

View File

@@ -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;
},
},
}),
];
}
}

View File

@@ -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);

View File

@@ -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;
},
}),
];
}
}

View File

@@ -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;
},
},
});
}

View File

@@ -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;
}

View File

@@ -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);
},
},
}),
];
}
}

View File

@@ -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,
};
}
}

View File

@@ -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);
},
},
});
}

View File

@@ -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,
];
}
}

View File

@@ -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;
},
},
}),
];
}
}