UNPKG

@anywhichway/nerd-editor

Version:

A JavaScript rich text editor based on and with support for custom elements.

508 lines (487 loc) 20.8 kB
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","&nbsp;"); } } 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","&nbsp;"); } } 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(); } })