const origin = "http://panel.sutty.local:3000" || location.origin function uploadFile (file) { return new Promise((resolve, reject) => { const upload = new ActiveStorage.DirectUpload( file, origin + '/rails/active_storage/direct_uploads', ) upload.create((error, blob) => { if (error) { reject(error) } else { const url = `${origin}/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}` resolve(url) } }) }) } function moveChildren (from, to, toRef) { while (from.firstChild) to.insertBefore(from.firstChild, toRef); } function isDirectChild (node, supposedChild) { for (const child of node.childNodes) { if (child == supposedChild) return true } } function isChildSelection (sel, el) { return ( (el.contains(sel.anchorNode) || el.contains(sel.focusNode)) && !(sel.anchorNode == el || sel.focusNode == el) ) } function getElementParent (node) { let parentEl = node while (parentEl.nodeType != Node.ELEMENT_NODE) parentEl = parentEl.parentElement return parentEl } function splitNode (node, range) { const [left, right] = [ { range: document.createRange(), node: node.cloneNode(false) }, { range: document.createRange(), node: node.cloneNode(false) }, ] left.range.setStartBefore(node.firstChild) left.range.setEnd(range.startContainer, range.startOffset) left.range.surroundContents(left.node) right.range.setStart(range.endContainer, range.endOffset) right.range.setEndAfter(node.lastChild) right.range.surroundContents(right.node) //left.node.appendChild(left.range.extractContents()) //left.range.insertNode(left.node) //right.node.appendChild(right.range.extractContents()) //right.range.insertNode(right.node) moveChildren(node, node.parentNode, node) node.parentNode.removeChild(node) return [left, right] } /* Configura un botón que hace una acción inline (ej: negrita). * Parametros: * * button: el botón * * mark: un objeto que representa el tipo de acción (ver types.js) * * contentEl: el elemento de contenido del editor. */ function setupMarkButton (button, mark, contentEl) { button.addEventListener("click", event => { event.preventDefault() const sel = window.getSelection() if (!isChildSelection(sel, contentEl)) return let parentEl = getElementParent(sel.anchorNode) //if (sel.anchorNode == parentEl) parentEl = parentEl.firstChild const parentChildren = Array.from(parentEl.childNodes) const range = sel.getRangeAt(0) if (mark.checkFn(parentEl)) { const [left, right] = splitNode(parentEl, range) right.range.insertNode(range.extractContents()) const selectionRange = document.createRange() selectionRange.setStartAfter(left.node) selectionRange.setEndBefore(right.node) sel.removeAllRanges() sel.addRange(selectionRange) } else { for (const child of parentChildren) { if (mark.checkFn(child) && sel.containsNode(child)) { moveChildren(child, parentEl, child) parentEl.removeChild(child) // TODO: agregar a selección return } } const tagEl = mark.createFn() try { range.surroundContents(tagEl) } catch (error) { // TODO: mostrar error return console.error("No puedo marcar cosas a través de distintos bloques!") } for (const child of tagEl.childNodes) { if (mark.checkFn(child)) { moveChildren(child, tagEl, child) tagEl.removeChild(child) } } range.insertNode(tagEl) range.selectNode(tagEl) } }) } /* Igual que `setupMarkButton` pero para bloques. */ function setupBlockButton (button, block, contentEl, editorEl) { button.addEventListener("click", event => { event.preventDefault() const sel = window.getSelection() // TODO: mostrar error if ( !contentEl.contains(sel.anchorNode) || !contentEl.contains(sel.focusNode) || sel.anchorNode == contentEl || sel.focusNode == contentEl ) return const range = sel.getRangeAt(0) let parentEl = sel.anchorNode while (!isDirectChild(contentEl, parentEl)) parentEl = parentEl.parentElement if (block.setFn) { if (block.checkFn(parentEl)) { tagNameSetFn("P")(parentEl) } else { block.setFn(parentEl) } } else if (block.createFn) { const newEl = block.createFn(editorEl) parentEl.parentElement.insertBefore(newEl, parentEl.nextSibling) } }) } /* Igual que `setupBlockButton` pero para bloques parientes. */ function setupParentBlockButton (button, parentBlock, contentEl) { button.addEventListener("click", event => { event.preventDefault() const sel = window.getSelection() if ( !contentEl.contains(sel.anchorNode) || !contentEl.contains(sel.focusNode) || sel.anchorNode == contentEl || sel.focusNode == contentEl ) return const range = sel.getRangeAt(0) let parentEl = sel.anchorNode while (!isDirectChild(contentEl, parentEl)) parentEl = parentEl.parentElement if (parentBlock.checkFn(parentEl)) { moveChildren(parentEl, parentEl.parentElement, parentEl) parentEl.parentElement.removeChild(parentEl) } else if (elementIsParentBlock(parentEl)) { const el = parentBlock.createFn() moveChildren(parentEl, el, null) parentEl.parentElement.insertBefore(el, parentEl) parentEl.parentElement.removeChild(parentEl) } else { const el = parentBlock.createFn() parentEl.parentElement.insertBefore(el, parentEl) el.appendChild(parentEl) } }) } const elementIsTypes = types => element => { for (const type of Object.values(types)) { if (type.checkFn(element)) return true } return false } const elementIsBlock = elementIsTypes(blocks) const elementIsParentBlock = elementIsTypes(parentBlocks) function hasContent (element) { if (element.firstElementChild) return true for (const child of element.childNodes) { if (child.nodeType === Node.TEXT_NODE && child.data.length > 0) return true else if (child.hasChildNodes() && hasContent(child)) return true } // TODO: verificar que los elementos tiene contenido if (element.tagName === "IMG" || element.tagName === "AUDIO" || element.tagName === "VIDEO" || element.tagName === "IFRAME") return true return false } /* Limpia el elemento de contenido del editor * Por ahora: * * Cambia el tag de los bloques no reconocidos (ver `elementIsBlock`) * * Hace lo que hace cleanNode */ function cleanContent (contentEl) { const sel = window.getSelection() cleanNode(contentEl) for (const child of contentEl.childNodes) { if (child.tagName) { if (elementIsParentBlock(child)) { cleanContent(child) } else if (!elementIsBlock(child)) { child.tagName = "P" } } } } /* Arregla cosas en el elemento de contendo del editor * Por ahora: * * Crea un p y inserta la selección si no hay elementos * * Wrappea el contenido de un UL o OL en un LI si no lo está */ function fixContent (contentEl) { if (!contentEl.firstChild) { const newEl = document.createElement("p") contentEl.appendChild(newEl) window.getSelection().collapse(newEl) } for (const child of contentEl.childNodes) { if (child.tagName) { if (elementIsParentBlock(child)) { fixContent(child) } else if (child.tagName === "UL" || child.tagName === "OL") { let notItems = [] for (const item of child.childNodes) { if (item.tagName !== "LI") notItems.push(item) } if (notItems.length) { const item = document.createElement("li") item.append(...notItems) child.appendChild(item) } } } } } /* Recursivamente "limpia" los nodos a partir del llamado. * Por ahora: * * Junta nodos de texto que están al lado * * Junta nodos de la misma "mark" que están al lado * * Borra elementos sin contenido (ver `hasContent`) y no están seleccionados */ function cleanNode (node) { for (const child of node.childNodes) { if (child.nodeType === Node.TEXT_NODE) { if (child.nextSibling && child.nextSibling.nodeType === Node.TEXT_NODE) { // Juntar nodos child.data += child.nextSibling.data child.parentNode.removeChild(child.nextSibling) } } else if (child.nodeType === Node.ELEMENT_NODE) { if (!hasContent(child) && !window.getSelection().getRangeAt(0).intersectsNode(child)) child.parentNode.removeChild(child) for (const mark of Object.values(marks)) { if (mark.checkFn(child) && child.nextSibling && mark.checkFn(child.nextSibling)) { moveChildren(child.nextSibling, child, null) child.nextSibling.parentNode.removeChild(child.nextSibling) } } } cleanNode(child) } } function setupEditor (editorEl) { const contentEl = editorEl.querySelector(".editor-content") contentEl.addEventListener("keydown", event => { if (event.keyCode === 13) { // Enter event.preventDefault() const sel = window.getSelection() let parentEl = sel.anchorNode if (parentEl == contentEl) { const newEl = document.createElement("p") contentEl.appendChild(newEl) sel.collapse(newEl) } else if (contentEl.contains(parentEl)) { while ((parentEl.nodeType == Node.TEXT_NODE || !elementIsBlock(parentEl)) && parentEl != contentEl) parentEl = parentEl.parentElement if (parentEl == contentEl) parentEl = parentEl.firstChild console.log(parentEl) let newEl switch (parentEl.tagName) { case "UL": case "OL": newEl = document.createElement("li") let itemEl = sel.anchorNode while (itemEl.tagName !== "LI" && parentEl.contains(itemEl)) itemEl = itemEl.parentElement parentEl.insertBefore(newEl, itemEl && parentEl.contains(itemEl) && itemEl.nextSibling || null) break default: newEl = document.createElement("p") contentEl.insertBefore(newEl, parentEl.nextSibling || null) } sel.collapse(newEl) } } else if (event.keyCode === 32) { // Espacio event.preventDefault() const sel = window.getSelection() const range = sel.getRangeAt(0) range.insertNode(document.createTextNode("\xa0")) cleanContent(contentEl) range.collapse() } }) document.addEventListener("selectionchange", event => { cleanContent(contentEl) const sel = window.getSelection() const range = sel.getRangeAt(0) for (const [name, type] of Object.entries(typesWithProperties)) { let result try { result = findRecursiveChild(type.checkFn, range.commonAncestorContainer) } catch (err) { // Permission denied or something... // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Property_access_denied console.log("Error raro", err) break } if (result) { type.updateInput(result, editorEl) result.classList.add("selected") break } else { if (!contentEl.contains(range.commonAncestorContainer)) return type.disableInput(editorEl) for (const el of contentEl.querySelectorAll(".selected")) { el.classList.remove("selected") } } } }) const htmlEl = editorEl.querySelector("textarea") const observer = new MutationObserver((mutationList, observer) => { cleanContent(contentEl) htmlEl.value = contentEl.innerHTML fixContent(contentEl) }) observer.observe(contentEl, { childList: true, attributes: true, subtree: true, characterData: true, }) const editorBtn = id => editorEl.querySelector(`*[data-button="${id}"]`) const tagNameSetFn = tagName => el => { const newEl = document.createElement(tagName) moveChildren(el, newEl, null) el.parentNode.insertBefore(newEl, el) el.parentNode.removeChild(el) window.getSelection().collapse(newEl, 0) } // == SETUP BUTTONS == for (const [name, mark] of Object.entries(marks)) { setupMarkButton(editorBtn(name), mark, contentEl) } for (const [name, block] of Object.entries(blocks)) { if (block.noButton) continue setupBlockButton(editorBtn(name), block, contentEl, editorEl) } for (const [name, parentBlock] of Object.entries(parentBlocks)) { if (parentBlock.noButton) continue setupParentBlockButton(editorBtn(name), parentBlock, contentEl) } for (const [name, type] of Object.entries(typesWithProperties)) { type.setupInput(editorEl, contentEl) } cleanContent(contentEl) htmlEl.value = contentEl.innerHTML } // TODO: por ahora confiamos, quizás queremos filtrar estilos? function stringifyAllowedStyle (element) { return element.style.cssText } document.addEventListener("DOMContentLoaded", () => { for (const editorEl of document.querySelectorAll(".editor")) { setupEditor(editorEl) } })