@pageboard/pagecut
Version:
Extensible web content editor
673 lines (622 loc) • 18.4 kB
JavaScript
/* eslint-disable no-underscore-dangle */
import { DiffDOM } from 'diff-dom';
const innerDiff = new DiffDOM({
filterOuterDiff(a, b, diffs) {
const cname = a.attributes?.['block-content'];
if (cname) {
a.innerDone = true;
}
},
preDiffApply(info) {
if (info.diff.action.endsWith("Attribute") && info.diff.name.startsWith("block-")) {
return true;
}
},
caseSensitive: false
});
export {
flagDom, findContent, tryJSON, domAttrsMap, saveDomAttrs, staticHtml
};
export class RootNodeView {
constructor(node, view, getPos, decorations) {
this.view = view;
this.element = node.type.spec.element;
this.domModel = node.type.spec.domModel;
this.getPos = typeof getPos == "function" ? getPos : null;
this.id = node.attrs.id;
if (!this.id && node.type.name == view.state.doc.type.name) {
this.id = node.attrs.id = view.dom.getAttribute('block-id');
}
let block;
if (this.id) {
if (this.element.inplace) {
delete node.attrs.id;
delete this.id;
} else {
block = view.blocks.get(this.id);
}
}
if (!block) {
if (node.attrs.id) {
delete node.attrs.id;
delete this.id;
}
block = view.blocks.fromAttrs(node.attrs);
}
if (!this.element.inplace && !this.id) {
view.blocks.set(block);
this.id = node.attrs.id = block.id;
}
if (block.focused) delete block.focused;
setupView(this, node);
this.update(node);
}
selectNode() {
this.selected = true;
this.dom.classList.add('ProseMirror-selectednode');
}
deselectNode() {
this.selected = false;
this.dom.classList.remove('ProseMirror-selectednode');
}
update(node, decorations) {
if (this.element.name != node.attrs.type) {
return false;
}
const oldBlock = this.oldBlock;
// TODO update instances of other standalone blocks !
if (node.attrs.id != this.id) {
return false;
}
const view = this.view;
const uBlock = view.blocks.fromAttrs(node.attrs);
let block;
if (this.element.inplace) {
block = uBlock;
} else {
block = view.blocks.get(this.id);
if (!block) {
// eslint-disable-next-line no-console
console.warn("block should exist", node);
return true;
}
}
if (uBlock.data) block.data = uBlock.data;
if (uBlock.expr) block.expr = uBlock.expr;
if (uBlock.lock) block.lock = uBlock.lock;
if (uBlock.content) Object.assign(block.content, uBlock.content);
// consider it's the same data when it's initializing
let sameData = false;
if (oldBlock) {
sameData = view.utils.equal(oldBlock.data ?? {}, block.data ?? {});
if (sameData && block.expr) {
sameData = view.utils.equal(oldBlock.expr ?? {}, block.expr ?? {});
}
if (uBlock.content) {
if (!oldBlock.content) {
sameData = false;
} else for (const [name, attr] of Object.entries(uBlock.content)) {
if (oldBlock.content?.[name] != attr) sameData = false;
}
}
}
const sameFocus = oldBlock?.focused == node.attrs.focused;
if (!sameData || !sameFocus) {
this.oldBlock = view.blocks.copy(block);
this.oldBlock.focused = node.attrs.focused;
if (node.attrs.focused) block.focused = node.attrs.focused;
else delete block.focused;
let dom = view.render(block, { type: node.attrs.type, merge: false });
if (dom?.nodeType == Node.DOCUMENT_FRAGMENT_NODE && dom?.children.length == 1) {
dom = dom.children[0];
}
const tr = view.state.tr;
mutateAttributes(this.dom, dom);
if (!sameData) {
const nobj = flagDom(this.element, dom);
try {
mutateNodeView(tr, this.getPos ? this.getPos() : null, node, this, nobj);
} catch (ex) {
return true;
}
}
// pay attention to the risk of looping over and over
if (oldBlock && this.getPos && tr.docChanged) {
view.dispatch(tr);
}
if (view.explicit && !node.type.spec.wrapper && this.contentDOM && !this.element.inline) {
setAncestorsAttr(this.dom, this.contentDOM, 'element-content', true);
}
if (this.contentDOM && node.isTextblock) {
this.contentDOM.setAttribute('block-text', 'true');
}
if (this.selected) {
this.selectNode();
}
} else {
// no point in calling render
}
const cname = node.type.spec.contentName;
if (cname != null) {
const cdom = this.contentDOM;
if (!block.content) block.content = {};
if (block.content[cname] != cdom) {
block.content[cname] = cdom;
}
}
return !(this.virtualContent && node.childCount == 0 && this.dom.isConnected);
}
ignoreMutation(record) {
if (record.type == "attributes") {
const dom = record.target;
let obj = dom.pcUiAttrs;
if (!obj) obj = dom.pcUiAttrs = {};
const name = record.attributeName;
const val = dom.getAttribute(name);
if (name == "class") {
if (record.oldValue != val) {
const oldClass = mapOfClass(record.oldValue);
const newClass = mapOfClass(val);
const diffClass = obj[name] ?? {};
for (const [k, vk] of Object.entries(newClass)) {
if (vk && !oldClass[k]) diffClass[k] = true;
}
for (const [k, vk] of Object.entries(oldClass)) {
if (vk && !newClass[k]) diffClass[k] = false;
}
obj[name] = diffClass;
}
} else if (name == "style") {
const oldStyle = mapOfStyle(record.oldValue);
const newStyle = mapOfStyle(dom.style);
const diffStyle = obj[name] ?? {};
for (const [j, vj] of Object.entries(newStyle)) {
if (vj && oldStyle[j] != vj) diffStyle[j] = vj;
}
for (const [j, vj] of Object.entries(oldStyle)) {
if (vj && !newStyle[j]) diffStyle[j] = '';
}
obj[name] = diffStyle;
} else {
obj[name] = val;
}
return true;
} else if (record.type == "childList" && record.addedNodes.length > 0 && !Array.prototype.some.call(record.addedNodes, (node) => {
if (node.nodeType != Node.ELEMENT_NODE) return true;
return node.getAttribute('contenteditable') != "false";
})) {
return true;
} else if (record.target == this.contentDOM && record.type == "childList") {
return false;
} else if (record.type != "selection") {
return true;
}
}
}
export class WrapNodeView {
constructor(node, view, getPos, decorations) {
this.view = view;
this.getPos = typeof getPos == "function" ? getPos : null;
this.element = node.type.spec.element;
this.domModel = node.type.spec.domModel;
setupView(this, node);
this.update(node);
}
update(node, decorations) {
if (!this.id) {
this.id = node.attrs._id;
} else if (this.id != node.attrs._id) {
return false;
}
restoreDomAttrs(tryJSON(node.attrs._json), this.dom);
return true;
}
ignoreMutation(record) {
// always ignore mutation
if (record.type != "selection") return true;
}
}
export class ConstNodeView {
constructor(node, view, getPos, decorations) {
this.view = view;
this.getPos = typeof getPos == "function" ? getPos : null;
this.element = node.type.spec.element;
this.domModel = node.type.spec.domModel;
setupView(this, node);
this.dom.setAttribute("contenteditable", "false");
this.update(node);
}
update(node, decorations) {
if (!this.id) {
this.id = node.attrs._id;
} else if (this.id != node.attrs._id) {
return false;
}
restoreDomAttrs(tryJSON(node.attrs._json), this.dom);
if (this.view.explicit) {
this.dom.innerHTML = '';
} else {
innerDiff.apply(this.dom, innerDiff.diff(this.dom, node.attrs._html));
}
return true;
}
ignoreMutation(record) {
// always ignore mutation, even selection
return true;
}
}
export class ContainerNodeView {
constructor(node, view, getPos, decorations) {
this.view = view;
this.element = node.type.spec.element;
this.domModel = node.type.spec.domModel;
setupView(this, node);
this.update(node);
}
update(node, decorations) {
const contentName = node.type.spec.contentName;
if (contentName != this.contentName) {
return false;
}
restoreDomAttrs(tryJSON(node.attrs._json), this.dom);
if (this.view.explicit) {
setAncestorsAttr(this.dom, this.contentDOM, 'element-content', true);
} else if (node.attrs._html) {
const diffs = innerDiff.diff(this.dom, node.attrs._html);
innerDiff.apply(this.dom, diffs);
}
if (node.isTextblock) {
this.contentDOM.setAttribute('block-text', 'true');
}
if (!this.id) this.id = node.attrs._id;
else if (this.id != node.attrs._id) return false;
const block = this.view.blocks.get(this.id);
if (!block) {
// eslint-disable-next-line no-console
console.warn("container has no root node id", this, node);
return false;
}
if (!block.content) block.content = {};
if (block.content[contentName] != this.contentDOM) {
block.content[contentName] = this.contentDOM;
}
return !(this.virtualContent && node.childCount == 0 && this.dom.isConnected);
}
ignoreMutation(record) {
if (record.target == this.contentDOM && record.type == "childList") {
return false;
} else if (record.type != "selection") {
return true;
}
}
}
/*
Nota Bene: nodes between obj.dom and obj.contentDOM (included) can be modified
by front-end. So when applying a new rendered DOM, one only wants to apply
diff between initial rendering and new rendering, leaving user modifications
untouched.
*/
function mutateNodeView(tr, pos, pmNode, obj, nobj) {
const dom = obj.dom;
const initial = !obj.pcInit;
if (initial) obj.pcInit = true;
if (dom && nobj.dom.nodeName != dom.nodeName) {
const emptyDom = nobj.dom.cloneNode(false);
const sameContentDOM = obj.contentDOM == obj.dom;
if (dom.parentNode) {
// workaround: nodeView cannot change their dom node
const desc = emptyDom.pmViewDesc = dom.pmViewDesc;
desc.nodeDOM = desc.contentDOM = desc.dom = emptyDom;
dom.parentNode.replaceChild(emptyDom, dom);
}
obj.dom = emptyDom;
while (dom.firstChild) emptyDom.appendChild(dom.firstChild);
if (sameContentDOM) obj.contentDOM = obj.dom;
}
if (nobj.children.length) {
// pmNode's contentDOM.children may be wrap, container, const
let curpos = pos + 1;
nobj.children.forEach((objChild, i) => {
const pmChild = pmNode.child(i);
const newAttrs = {
...pmChild.attrs,
_json: saveDomAttrs(objChild.dom)
};
const type = pmChild.type.spec.typeName;
if (type != "root") {
if (pmNode.attrs.id) {
newAttrs._id = pmNode.attrs.id;
}
if (type == "const" || type == "container") {
newAttrs._html = staticHtml(objChild.dom);
}
}
if (!Number.isNaN(curpos)) {
// updates that are incompatible with schema might happen (e.g. popup(title + content))
tr.setNodeMarkup(curpos, null, newAttrs);
// however, this transaction is going to happen right now,
// before all rootNodeView children have been updated with *old* state
pmChild.attrs = newAttrs; // so we must change pmNode right now !
if (objChild.children.length) {
const domChild = obj.contentDOM?.children[i];
const desc = domChild?.pmViewDesc ?? {};
mutateNodeView(tr, curpos, pmChild, desc, objChild);
}
curpos += pmChild.nodeSize;
}
});
}
if (!obj.dom) return;
// first upgrade attributes
mutateAttributes(obj.dom, nobj.dom);
// then upgrade descendants
let parent, node;
if (!obj.contentDOM) {
// remove all _pcElt
parent = obj.dom;
node = parent.firstChild;
let cur;
while (node) {
if (node.pcElt || initial) {
cur = node;
} else {
cur = null;
}
node = node.nextSibling;
if (cur) parent.removeChild(cur);
}
node = nobj.dom.firstChild;
while (node) {
node.pcElt = true;
cur = node;
node = node.nextSibling;
parent.appendChild(cur);
}
return;
} else if (obj.dom == obj.contentDOM) {
// our job is done
return;
}
// there is something between dom and contentDOM
let cont = obj.contentDOM;
let ncont = nobj.contentDOM;
while (cont != obj.dom) {
mutateAttributes(cont, ncont);
parent = cont.parentNode;
node = cont;
while (node.previousSibling) {
if (node.previousSibling.pcElt || initial) {
parent.removeChild(node.previousSibling);
} else {
node = node.previousSibling;
}
}
node = cont;
while (node.nextSibling) {
if (node.nextSibling.pcElt || initial) {
parent.removeChild(node.nextSibling);
} else {
node = node.nextSibling;
}
}
while ((node = ncont.parentNode.firstChild) != ncont) {
node.pcElt = true;
parent.insertBefore(node, cont);
}
node = ncont;
while (node.nextSibling) {
node.nextSibling.pcElt = true;
parent.appendChild(node.nextSibling);
}
cont = parent;
ncont = ncont.parentNode;
}
}
function mutateAttributes(dom, ndom) {
restoreDomAttrs(attrsObj(ndom.attributes), dom);
}
function attrsObj(atts) {
const obj = {};
for (let k = 0; k < atts.length; k++) {
obj[atts[k].name] = atts[k].value;
}
return obj;
}
const styleHelper = document.createElement('div');
function mapOfStyle(style) {
const map = {};
if (!style) return map;
if (typeof style == "string") {
styleHelper.setAttribute('style', style);
style = styleHelper.style;
}
let name, val;
for (let k = 0; k < style.length; k++) {
name = style.item(k);
val = style.getPropertyValue(name);
if (val != null && val != "") map[name] = val;
}
return map;
}
function mapOfClass(att) {
const map = {};
for (let str of (att || '').split(' ')) {
str = str.trim();
if (str) map[str] = true;
}
return map;
}
function restoreDomAttrs(srcAtts, dom) {
if (!srcAtts || !dom) return;
// attributes that are set by mutations
let uiAtts = dom.pcUiAttrs;
if (!uiAtts) {
uiAtts = dom.pcUiAttrs = {};
}
const dstAtts = Array.from(dom.attributes); // immutable copy
for (const [name, srcVal] of Object.entries(srcAtts)) {
if (name == "contenteditable") continue;
const dstVal = dom.getAttribute(name);
if (name == "class") {
const list = [];
for (const [k, v] of Object.entries(Object.assign(mapOfClass(srcVal), uiAtts[name]))) {
if (v) list.push(k);
}
dom.setAttribute(name, list.join(' '));
} else if (name == "style") {
const srcStyle = mapOfStyle(srcVal);
const diffStyle = uiAtts[name];
const style = Object.entries(Object.assign(srcStyle, diffStyle))
.map(([k, v]) => `${k}:${v}`).join(';');
dom.setAttribute('style', style);
} else if (srcVal != dstVal) {
dom.setAttribute(name, srcVal);
}
}
for (const attr of dstAtts) {
const name = attr.name;
if (name == "block-content" || name == "contenteditable") continue;
// remove attribute if not in srcAtts unless it is set in uiAtts
if (srcAtts[name] == null && uiAtts[name] == null) {
dom.removeAttribute(name);
}
}
}
function flagDom(elt, dom, iterate, parent) {
if (!dom) return;
if (dom.nodeType == Node.TEXT_NODE) {
return {text: dom.nodeValue};
}
if (dom.nodeType != Node.ELEMENT_NODE) return;
if (!parent) parent = {};
let type;
if (!parent.type) type = "root";
else if (parent.type == "root") type = ["container", "wrap"];
else if (parent.type == "wrap") type = "container";
const obj = {
dom: dom,
contentDOM: findContent(elt, dom, type)
};
if (!obj.children) obj.children = [];
if (!dom.parentNode) {
obj.type = 'root';
} else if (obj.contentDOM) {
if (obj.contentDOM.hasAttribute('block-content')) {
if (parent.type != 'container') {
obj.type = 'container';
}
} else {
obj.type = 'wrap';
}
} else if (obj.dom && parent.type == 'wrap') {
obj.type = 'const';
}
if (obj.contentDOM) {
const contentDOM = obj.contentDOM.cloneNode(false);
for (const node of Array.from(obj.contentDOM.childNodes)) {
const child = flagDom(elt, node, iterate, obj);
if (!child) continue;
if (["wrap", "container", "const"].includes(child.type)) {
obj.children.push(child);
contentDOM.appendChild(node.cloneNode(true));
} else {
// ignore it, it is used as default content by viewer
}
}
if (obj.contentDOM != obj.dom) {
obj.contentDOM.parentNode.replaceChild(contentDOM, obj.contentDOM);
} else {
obj.dom = contentDOM;
}
obj.contentDOM = contentDOM;
}
if (obj.type) iterate?.(obj);
return obj;
}
function getImmediateContents(root, list) {
if (root.hasAttribute('block-content')) {
list.push(root);
} else for (const node of root.childNodes) {
if (node.nodeType == Node.ELEMENT_NODE) getImmediateContents(node, list);
}
}
function findContent(elt, dom, type) {
if (elt.leaf) return;
let node;
if (elt.inline || elt.contents.unnamed) {
if (type == "root") node = dom;
} else {
const list = [];
getImmediateContents(dom, list);
if (!list.length) return;
node = list.ancestor();
}
if (node?.nodeName == "TEMPLATE" && node?.content.childNodes.length && node?.childNodes.length == 0) {
node.appendChild(node.content);
}
return node;
}
function setupView(me, node) {
if (me.view && node.type.name == me.view.state.doc.type.name) {
me.dom = me.contentDOM = me.view.dom;
} else {
me.dom = me.domModel.cloneNode(true);
me.contentDOM = findContent(me.element, me.dom, node.type.spec.typeName);
}
me.contentName = node.type.spec.contentName;
const def = me.element.contents.find(me.contentName);
me.virtualContent = def?.virtual;
if (!me.contentDOM || me.contentDOM == me.dom) return;
if (['span'].indexOf(me.contentDOM.nodeName.toLowerCase()) < 0) return;
me.contentDOM.setAttribute("contenteditable", "true");
me.dom.setAttribute("contenteditable", "false");
for (const type of [
'focus',
'selectionchange',
// 'DOMCharacterDataModified'
]) {
me.contentDOM.addEventListener(type, (e) => {
me.view.dom.dispatchEvent(new e.constructor(e.type, e));
}, false);
}
}
function staticHtml(dom) {
const copy = dom.cloneNode(true);
const content = copy.hasAttribute('block-content') ? copy : copy.querySelector('[block-content]');
if (content) content.textContent = '';
return copy.outerHTML;
}
function saveDomAttrs(dom) {
const map = domAttrsMap(dom);
if (Object.keys(map).length == 0) return;
return JSON.stringify(map);
}
function domAttrsMap(dom) {
const map = {};
const atts = dom.attributes;
let att;
for (let k = 0; k < atts.length; k++) {
att = atts[k];
if (att.value && !att.name.startsWith('block-')) map[att.name] = att.value;
}
return map;
}
function tryJSON(str) {
if (!str) return;
let obj;
try {
obj = JSON.parse(str);
} catch(ex) {
// eslint-disable-next-line no-console
console.debug("Bad attributes", str);
}
return obj;
}
function setAncestorsAttr(p, c, name, val) {
let node = c;
while (node) {
node.setAttribute(name, val);
if (node == p) break;
node = node.parentNode;
}
}