@anywhichway/nerd-editor
Version:
A JavaScript rich text editor based on and with support for custom elements.
508 lines (487 loc) • 20.8 kB
JavaScript
import {bind} from "./bind";
import {createElement} from "./create-element";
import {editors} from "./editors";
import {typeAhead} from "./type-ahead";
import {controls} from "./controls";
import {popoverDialog} from "./dialogs/popover-dialog";
import {elementEditorDialog} from "./dialogs/element-editor-dialog";
import {blockTags} from "./block-tags";
const pushHistory = (context) => {
const history = context.history,
content = context.querySelector('[slot="content"]');
if (
!history.back.length ||
history.back[history.back.length - 1] != content.innerHTML
) {
history.back.push(content.innerHTML);
}
};
function getFirstTextNode(el) {
/// Degenerate cases: either el is null, or el is already a text node
if (!el) return null;
if (el.nodeType == 3) return el;
for (let child of el.childNodes) {
if (child.nodeType == 3) {
return child;
}
else {
let textNode = getFirstTextNode(child);
if (textNode !== null) return textNode;
}
}
return null;
}
function walkRange(range) {
let ranges = [];
let el = range.startContainer;
let elsToVisit = true;
while (elsToVisit) {
let startOffset = el == range.startContainer ? range.startOffset : 0;
let endOffset = el == range.endContainer ? range.endOffset : el.textContent.length;
let r = document.createRange();
r.setStart(el, startOffset);
r.setEnd(el, endOffset);
ranges.push(r);
/// Move to the next text container in the tree order
elsToVisit = false;
while (!elsToVisit && el != range.endContainer) {
let nextEl = getFirstTextNode(el.nextSibling);
if (nextEl) {
el = nextEl;
elsToVisit = true;
}
else {
if (el.nextSibling) el = el.nextSibling;
else if (el.parentNode) el = el.parentNode;
else break;
}
}
}
return ranges;
}
function wrap(range, el,{root}={}) {
range = range.getRangeAt ? range.getRangeAt(0) : range;
let clone,firstNode, lastNode;
for (let r of walkRange(range)) {
clone = el.cloneNode(true);
firstNode ||= clone;
lastNode = clone;
document.body.appendChild(clone);
r.surroundContents(clone);
if(root) promoteToTop(root,clone);
}
return {firstNode, lastNode}
}
function promoteToTop(root,node) {
[...root.children].some((child) => {
if(child!==node && child.contains(node)) {
child.insertAdjacentElement("afterend",node);
}
})
}
function unhighlight(root,selectors=[]) {
selectors.forEach((selector) => {
root.querySelectorAll(selector).forEach(el => el.replaceWith(...el.childNodes));
})
}
self.properties({
history: {
forward: [],
back: []
},
createControl({name,innerHTML,type="button",title="",...properties}) {
const buttontemplate = this.shadowRoot.getElementById("ne-control").innerHTML.replaceAll(/button/g,type),
html = Function("properties","with(properties) { return `" + buttontemplate + "`}")({innerHTML,title});
quickComponent({html,as:`ne-control-${name}`,properties:{editor:this,...properties}});
},
createElement,
initialize() {
const content = this.querySelector('[slot="content"]'),
footer = this.querySelector('[slot="footer"]');
footer.style.display = "block";
footer.style.clear = "both";
[...this.shadowRoot.querySelectorAll("template[as]")].forEach((template) => {
quickComponent({html:template.innerHTML,as:template.getAttribute("as"),properties:{editor:this}});
});
Object.entries(controls).forEach(([name,options]) => {
this.createControl({name,...options})
});
content.addEventListener("keydown", (event) => {
if(event.keyCode===13) { // enter
event.preventDefault();
this.replaceSelection(this.createElement({tagName:"p",innerHTML:"<br>"}));
}
if(event.ctrlKey) {
const key = event.key.toUpperCase();
if(key!=="Z") {
pushHistory(this);
}
if(key==="Z") {
event.preventDefault();
event.stopImmediatePropagation();
if(event.shiftKey) {
this.redo();
return;
}
this.undo();
return;
}
} else {
pushHistory(this);
this.history.forward = [];
typeAhead();
}
});
content.addEventListener("keyup", (event) => {
typeAhead(this);
});
content.addEventListener("dblclick",(event) => {
const {target} = event,
selection = window.getSelection(),
string = selection.toString(),
{anchorNode,anchorOffset,focusNode,focusOffset} = selection,
firstCharCode = string.charCodeAt(0),
lastCharCode = string.charCodeAt(string.length-1)
if(![32,160].includes(firstCharCode) && [32,160].includes(lastCharCode)) {
selection.setBaseAndExtent(anchorNode,anchorOffset,focusNode,focusOffset - (string.length - string.trim().length))
}
if(this.editable(target)) {
this.currentSelection = target;
const popover = target.popover || editors[target.tagName.toLowerCase()]?.popover;
if(popover) {
if(typeof(popover)==="function") {
popover.call(target);
} else {
const render = target.render || editors[target.tagName.toLowerCase()]?.render;
this.dialog({target,sourceNodeOrHTML:popover,dialogNode:popoverDialog,dismissOnChange:true,render});
}
}
} else {
this.currentSelection = null;
}
});
content.setAttribute("contenteditable", "true");
content.addEventListener("mousemove",({path}) => {
if(path) {
this.editable(path[0]);
}
});
document.body.appendChild(popoverDialog);
document.body.appendChild(elementEditorDialog);
this.elementEditorDialog = elementEditorDialog;
content.addEventListener("click",(event) => {
console.log(event)
const {target} = event;
if(target.tagName==="P") {
if(target.childNodes.length===1 && target.children.length===1 && target.children[0].tagName==="BR") {
const selection = window.getSelection();
selection.setBaseAndExtent(target,0,target,1);
}
event.preventDefault();
}
});
},
connected() {
},
editSelection() {
if(!this.currentSelection) return;
this.edit(this.currentSelection);
},
edit(element) {
const editor = element.editor || editors[element.tagName.toLowerCase()]?.editor,
render = element.render || editors[element.tagName.toLowerCase()]?.render;
if(editor) {
if(typeof(editor)==="function") {
editor.call(element);
} else {
this.dialog({target:element,sourceNodeOrHTML:editor,dialogNode:this.elementEditorDialog,render});
}
}
},
editable(element) {
if(!element) return;
const tag = element.tagName.toLowerCase();
if(editors[tag]?.autoAttributes) {
Object.entries(editors[tag]?.autoAttributes).forEach(([key,value]) => {
if(key==="style" && value && typeof(value)==="object") {
Object.entries(value).forEach(([key,value]) => element.style[key] = value)
} else {
element.setAttribute(key,typeof(value)==="string" ? value : JSON.stringify(value))
}
})
}
if(element?.editor || element?.popover || editors[tag]?.editor || editors[tag]?.popover) {
element.classList.add("ne-editable");
return true;
}
},
dialog({target,sourceNodeOrHTML,dialogNode,dismissOnChange,render}) {
const updateNode = (node) => {
[...dialogNode.querySelectorAll('[name]')].forEach((input) => {
if (!["INPUT", "SELECT", "TEXTAREA"].includes(input.tagName) || input.hasAttribute("readonly") || input.hasAttribute("disabled")) return;
const [update, name, property, pvalue] = input.getAttribute("name").split("."),
type = input.tagName === "TEXTAREA" ? "text" : (input.tagName === "SELECT" ? "select" : input.getAttribute("type") || "text");
if (!["innerHTML", "innerText", "slot", "class", "attribute", "style", "element","transform"].includes(update)) {
console.warn(update + " is an unknown update type");
return;
}
if (type === "radio" && !input.checked) {
return;
}
let value;
if(type==="checkbox") {
value = input.checked;
} else if(type==="file") {
const file = input.files[0];
if(file) {
value = URL.createObjectURL(file);
}
} else {
value = input.value;
}
if(value==null) {
return;
}
if(update==="transform") {
const attributes = [...target.attributes].reduce((attributes, {name,value}) => {
attributes[name] = value;
return attributes;
},{});
target.replaceWith(this.createElement({tagName:value,attributes,innerHTML:target.innerHTML}))
} else if (update === "innerHTML") {
node.innerHTML = value;
} else if (update === "innerText") {
node.innerText = value;
} else if (update === "slot") {
if (name) {
const slot = node.querySelector(`[slot="${name}"]`);
if (slot) {
slot.innerHTML = value;
}
} else {
node.innerHTML = value;
}
} else if (update==="class") {
node.className = value;
} else if(update==="element") {
[...node.querySelectorAll(name)].forEach((el) => {
if(property==="innerHTML") {
el.innerHTML = value
} else if(property==="attribute") {
el.setAttribute(pvalue,value)
} else if(property==="style") {
el.style[pvalue] = value;
}
})
} else if(update==="attribute") {
console.log(node,name,value)
node.setAttribute(name,value);
} else if(update==="style") {
console.log(node,name,value,property)
node.style[name] = value + (property || "");
}
});
if(render) {
render.call(target);
}
};
const {top,left} = target.getBoundingClientRect(),
content = dialogNode.querySelector(".ne-dialog-content"),
editor = target.editor || editors[target.tagName.toLowerCase()]?.editor,
editControl = dialogNode.querySelector("ne-control-editor-dialog");
if(typeof(sourceNodeOrHTML)==="string") {
content.innerHTML = sourceNodeOrHTML;
} else {
content.innerHTML = "";
content.appendChild(sourceNodeOrHTML)
}
if(editControl) {
editControl.style.display = editor ? "" : "none";
}
const previewContainer = content.querySelector('[name="preview"]');
if(previewContainer) {
previewContainer.innerHTML = "";
previewContainer.appendChild(target.cloneNode(true));
}
[...dialogNode.querySelectorAll('[name]')].forEach((input) => {
if(!["INPUT","SELECT","TEXTAREA"].includes(input.tagName)) return;
const [update,name,property,value] = input.getAttribute("name").split("."),
type = input.tagName==="TEXTAREA" ? "text" : (input.tagName==="SELECT" ? "select" : input.getAttribute("type")||"text");
if(type==="file") {
input.setAttribute("placeholder",target.getAttribute(name));
} else if(update==="transform") {
const value = target.tagName.toLowerCase();
if(type==="radio") {
if(input.value===value) {
input.checked = true;
}
} else {
input.value = value;
}
} else if(update==="innerHTML") {
input.value = target.innerHTML;
} else if(update==="innerText") {
input.value = target.innerText;
} else if(update==="slot") {
if(name) {
const slot = target.querySelector(`[slot="${name}"]`);
if(slot) {
input.value = slot.innerHTML;
}
} else {
input.value = target.innerHTML;
}
} else if(update==="class") {
input.value = target.classList[0] || "";
} else if(update==="element") {
[...target.querySelectorAll(name)].forEach((el) => {
if(property==="innerHTML") {
input.value = el.innerHTML;
} else if(property==="attribute") {
input.value = el.getAttribute(value)||"";
} else if(property==="style") {
input.value = el.style[value];
}
})
} else {
if(!["attribute","style"].includes(update)) {
console.warn(update + " is an unknown update type");
return;
}
const value = update==="attribute" ? target.getAttribute(name) : (property ? parseFloat(target.style[name]) : target.style[name]);
if(update==="style" && property && isNaN(value)) {
// ignore
} else if(type==="radio") {
if(input.value===value) {
input.checked = true;
}
} else if(type==="checkbox") {
if(input.value==="" || input.value===value || (input.value==="on" && value==="true")) {
input.checked = true;
}
} else {
input.value = value;
}
}
if(dismissOnChange) {
input.onchange = () => {
dialogNode.returnValue = input.getAttribute("name");
dialogNode.close();
}
} else if(previewContainer) {
console.log("preview",input)
input.onchange = (event) => {
console.log(event)
updateNode(previewContainer.firstElementChild);
}
input.oninput= (event) => {
console.log(event)
updateNode(previewContainer.firstElementChild);
}
}
})
dialogNode.style.top = (top + 20) + "px";
dialogNode.style.left = left + "px";
dialogNode.style.margin = "0px";
dialogNode.showModal();
dialogNode.onclose = (event) => {
if(!dialogNode.returnValue || !target.isConnected) {
return;
}
pushHistory(this);
updateNode(target);
}
},
deleteSelection() {
pushHistory(this);
if(this.currentSelection) {
this.currentSelection.remove();
this.currentSelection = null;
return;
}
const selection = window.getSelection(),
range = selection.getRangeAt(0);
if(range) {
range.deleteContents();
}
},
wrapSelection(tagOrNode,{asText}={}) {
//todo if already in a tag of same type, do not wrap
const selection = window.getSelection();
if(typeof(tagOrNode)==="string") {
tagOrNode = document.createElement(tagOrNode);
}
pushHistory(this);
this.editable(tagOrNode);
if(asText) {
const dom = (new DOMParser()).parseFromString(selection.toString(),"text/html");
tagOrNode.innerText = dom.body.textContent;
this.replaceSelection(tagOrNode);
} else {
const root = blockTags.includes(tagOrNode.tagName) ? this.querySelector('[slot="content"]') : null,
{firstNode,lastNode} = wrap(selection,tagOrNode,{root});
selection.setBaseAndExtent(firstNode,0,lastNode,lastNode.childNodes.length);
if(!lastNode.nextSibling || lastNode.nextSibling.textContent.length===0) {
lastNode.insertAdjacentHTML("afterend"," ");
}
}
return tagOrNode;
},
replaceSelection(tagOrNode) {
const selection = window.getSelection(),
range = selection.getRangeAt(0);
if(typeof(tagOrNode)==="string") {
tagOrNode = document.createElement(tagOrNode);
}
pushHistory(this);
this.editable(tagOrNode);
range.deleteContents();
range.insertNode(tagOrNode);
if(blockTags.includes(tagOrNode.tagName)) {
promoteToTop(this.querySelector('[slot="content"]'),tagOrNode);
selection.setBaseAndExtent(tagOrNode,0,tagOrNode,tagOrNode.childNodes.length)
} else {
selection.setBaseAndExtent(tagOrNode,0,tagOrNode,tagOrNode.childNodes.length)
if(!tagOrNode.nextSibling || tagOrNode.nextSibling.textContent.length===0) {
tagOrNode.insertAdjacentHTML("afterend"," ");
}
}
return tagOrNode;
},
unformatSelection() {
const selection = window.getSelection(),
range = selection.getRangeAt(0);
if(!range || range.commonAncestorContainer.nodeType===Node.TEXT_NODE) {
return;
}
const dom = (new DOMParser()).parseFromString(selection.toString(),"text/html"),
text = dom.body.innerText;
pushHistory(this);
range.deleteContents();
const target = selection.anchorNode.childNodes[selection.anchorOffset-1];
if(target.nodeType===Node.ELEMENT_NODE) {
selection.anchorNode.childNodes[selection.anchorOffset-1].insertAdjacentText("afterend",text);
} else {
target.data += text;
}
return selection;
},
undo() {
const history = this.history,
content = this.querySelector('[slot="content"]');
if (!history.back.length) {
return;
}
history.forward.push(content.innerHTML);
content.innerHTML = history.back.pop();
},
redo() {
const history = this.history,
content = this.querySelector('[slot="content"]')
if (!history.forward.length) {
return;
}
history.back.push(content.innerHTML);
content.innerHTML = history.forward.pop();
}
})