@pageboard/pagecut
Version:
Extensible web content editor
302 lines (280 loc) • 8.27 kB
JavaScript
import * as State from "prosemirror-state";
import * as Transform from "prosemirror-transform";
import * as View from "prosemirror-view";
import * as Model from "prosemirror-model";
import { keymap } from "prosemirror-keymap";
import * as Commands from "prosemirror-commands";
import * as Setup from "prosemirror-example-setup";
import { dropCursor } from "prosemirror-dropcursor";
import { gapCursor } from "prosemirror-gapcursor";
import * as History from "prosemirror-history";
import OrderedMap from "orderedmap";
import IdPlugin from "./id-plugin.js";
import FocusPlugin from "./focus-plugin.js";
import KeymapPlugin from "./keymap-plugin.js";
import InputPlugin from "./input-plugin.js";
import Utils from "./utils.js";
import DefineSpecs from "./specs.js";
import BlocksEdit from "./blocks-edit.js";
import { MenuItem, MenuBar } from "./menubar.js";
const mac = typeof navigator != "undefined" ? /Mac/.test(navigator.platform) : false;
class Editor extends View.EditorView {
#utils;
static defaults = {
nodes: OrderedMap.from({}),
marks: OrderedMap.from({}),
mapKeys: {
"Mod-z": History.undo,
"Shift-Mod-z": History.redo,
"Mod-y": !mac && History.redo || null
},
elements: {
_: {
priority: -Infinity,
title: "Empty",
name: '_',
group: "block mail_block",
inplace: true,
draggable: false,
html: '<pagecut-placeholder>'
}
}
};
static filteredSerializer(spec, obj) {
if (typeof obj == "function") obj = {filter: obj};
const ser = Model.DOMSerializer.fromSchema(new Model.Schema(spec));
function replaceOutputSpec(fun) {
return function(node) {
let out = fun(node);
const mod = obj.filter(node, out);
if (mod !== undefined) out = mod;
return out;
};
}
for (const name of Object.keys(ser.nodes)) {
if (spec.nodes.get(name).typeName != null) {
ser.nodes[name] = replaceOutputSpec(ser.nodes[name]);
}
}
for (const name of Object.keys(ser.marks)) {
if (spec.marks.get(name).typeName != null) {
ser.marks[name] = replaceOutputSpec(ser.marks[name]);
}
}
return ser;
}
static configure(viewer, { topNode, jsonContent, content, plugins = [] }) {
const { elements } = viewer;
const spec = {
topNode,
nodes: Editor.defaults.nodes,
marks: Editor.defaults.marks
};
const nodeViews = {};
const elSet = new Set();
for (const name of Object.keys(Editor.defaults.elements)) {
elSet.add(name);
}
for (const group of elements[topNode].groups) {
for (const name of viewer.groups.get(group)) {
if (name != topNode && elements[name].group == 'page') continue;
elSet.add(name);
for (const sub of elements[name].bundle) {
elSet.add(sub);
}
}
}
const text = {
name: 'text',
priority: -1001,
inline: true,
group: 'inline'
};
const re = /\b(?<group>[a-zA-Z0-9]+_inline)\b/g;
const elemsList = Array.from(elSet)
.map(name => {
const el = viewer.element(name);
if (el.inline && el.group) {
const { group } = re.exec(el.group)?.groups || {};
if (group) text.group = group;
}
return el;
})
.sort((a, b) => {
return (b.priority || 0) - (a.priority || 0);
});
elemsList.unshift(text);
for (const el of elemsList) {
DefineSpecs(viewer, el, spec, nodeViews);
}
const schema = new Model.Schema(spec);
const domParser = Model.DOMParser.fromSchema(schema);
const clipboardSerializer = this.filteredSerializer(spec, (node, out) => {
if (node.type.name == "_") return "";
const attrs = out[1];
if (node.attrs.data) attrs['block-data'] = node.attrs.data;
if (node.attrs.expr) attrs['block-expr'] = node.attrs.expr;
if (node.attrs.lock) attrs['block-lock'] = node.attrs.lock;
if (node.attrs.standalone) attrs['block-standalone'] = 'true';
delete attrs['block-focused'];
});
clipboardSerializer.serializeFragment = (function(meth) {
return function(frag, opts, top) {
const tmpl = top?.nodeName == "TEMPLATE";
const ret = meth.call(this, frag, opts, tmpl ? top.content : top);
if (tmpl) return top;
else return ret;
};
})(clipboardSerializer.serializeFragment);
const clipboardParser = Model.DOMParser.fromSchema(schema);
const viewSerializer = this.filteredSerializer(spec, (node, out) => {
if (node.type.name == "_") return "";
const obj = out[1];
if (typeof obj != "object") return;
// delete obj['block-root_id'];
});
const pluginKeys = {};
plugins = [
IdPlugin,
KeymapPlugin,
FocusPlugin,
InputPlugin,
Setup.buildInputRules(schema),
keymap(Editor.defaults.mapKeys),
keymap(Commands.baseKeymap),
History.history({
preserveItems: true // or else cancel does not keep selected node
}),
gapCursor(),
dropCursor({
width: 2,
class: 'ProseMirror-dropcursor'
}),
...plugins
].map(plugin => {
if (plugin instanceof State.Plugin) return plugin;
if (plugin.prototype) plugin = new plugin();
if (plugin.update || plugin.destroy ) {
plugin = {
view: function (editor) {
this.editor = editor;
return this;
}.bind(plugin)
};
}
if (typeof plugin?.key == "string") {
plugin.key = pluginKeys[plugin.key] = new State.PluginKey(plugin.key);
}
return new State.Plugin(plugin);
});
let doc;
try {
doc = jsonContent && schema.nodeFromJSON(jsonContent)
|| content && domParser.parse(content);
} catch (err) {
console.error("Cannot import document", err);
}
return {
attributes: {
spellcheck: 'false'
},
state: State.EditorState.create({
schema, plugins, doc
}),
domParser,
clipboardParser,
clipboardSerializer,
viewSerializer,
nodeViews,
viewer,
dispatchTransaction: function (tr) {
this.updateState(this.state.apply(tr));
}
};
}
constructor(opts) {
const { viewer } = opts;
viewer.blocks = new BlocksEdit(viewer, opts);
for (const [name, elt] of Object.entries(Editor.defaults.elements)) {
elt.name = name;
viewer.setElement({ ...elt, ...viewer.elements[name] });
}
super({
mount: typeof opts.place == "string" ?
document.querySelector(opts.place) :
opts.place ?? opts.content
}, Editor.configure(viewer, opts));
if (opts.scope) this.scope = opts.scope;
if (opts.explicit) this.explicit = true;
this.cssChecked = true;
this.dom.addEventListener('click', this, true);
this.dom.addEventListener('submit', this, true);
this.dom.addEventListener('invalid', this, true);
}
get utils() {
if (!this.#utils) this.#utils = new Utils(this);
return this.#utils;
}
get blocks() {
if (!this.elements) {
// this is a trick to do post-super initializing
const viewer = this.props.viewer;
viewer.blocks.view = this;
Object.defineProperty(this, 'blocks', {
writable: true,
value: viewer.blocks
});
Object.assign(this, viewer);
}
return this.blocks;
}
parseFromClipboard(html, $pos) {
// eslint-disable-next-line no-underscore-dangle
return View.__parseFromClipboard(this, null, html, null, $pos);
}
to(blocks) {
return this.blocks.to(blocks);
}
getPlugin(key) {
return new State.PluginKey(key).get(this.state);
}
handleEvent(e) {
if (this.closed) return;
if (e.type == "submit") {
e.preventDefault();
e.stopImmediatePropagation();
} else if (e.type == "click") {
const editor = this;
if (editor.closed) return;
const node = e.target.closest('a[href],input,button,textarea,label[for]');
if (!node) return;
e.preventDefault();
const isInput = node.matches('input,textarea,select');
if (!isInput) return;
const parent = node.closest('[block-type]');
const sel = editor.utils.select(parent);
if (sel) {
editor.focus();
editor.dispatch(editor.state.tr.setSelection(sel));
}
}
}
close() {
this.closed = true;
this.dom.removeEventListener('click', this, true);
this.dom.removeEventListener('submit', this, true);
this.dom.removeEventListener('invalid', this, true);
}
render(...args) {
return this._props.viewer.render(...args);
}
element(...args) {
return this._props.viewer.element(...args);
}
from(...args) {
return this._props.viewer.from(...args);
}
}
export {
Editor, View, Model, State, Transform, Commands, keymap, MenuItem, MenuBar
};