const origin = 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 range = sel.getRangeAt(0) if (parentEl.matches(mark.selector)) { 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 parentEl.childNodes) { if ( (child instanceof Element) && child.matches(mark.selector) && 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 (child instanceof Element && child.matches(mark.selector)) { 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 (parentEl.matches(block.selector)) { 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 (parentEl.matches(parentBlock.selector)) { 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 (element.matches(type.selector)) 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, contentEl) for (const child of contentEl.childNodes) { if (child.tagName) { if (elementIsParentBlock(child)) { cleanContent(child) } else if (!elementIsBlock(child)) { child.tagName = "P" } } else if (child.nodeType === Node.TEXT_NODE) { const wasSelected = sel.getRangeAt(0).intersectsNode(child) const el = document.createElement("p") el.appendChild(child) contentEl.insertBefore(el, child.nextSibling) if (wasSelected) sel.collapse(el, child.data.length) } } } /* 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) { 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, contentEl) { 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 ( child.matches(mark.selector) && child.nextSibling && (child.nextSibling instanceof Element) && child.nextSibling.matches(mark.selector) ) { moveChildren(child.nextSibling, child, null) child.nextSibling.parentNode.removeChild(child.nextSibling) } } if (child.tagName === "LI") { let parentEl = child while ( parentEl && !(parentEl.nodeType == Node.ELEMENT_NODE && elementIsBlock(parentEl)) && contentEl.contains(parentEl) ) parentEl = parentEl.parentElement if ( parentEl && contentEl.contains(parentEl) && parentEl.tagName !== "UL" && parentEl.tagName !== "OL" ) moveChildren(child, parentEl, child.nextSibling) } } cleanNode(child, contentEl) } } /* Generar el clickListener para este editor. */ function generateClickListener (editorEl, contentEl) { /* El event listener para los typesWithProperties. */ return function clickListener (event) { // Borrar todas las selecciones for (const el of contentEl.querySelectorAll(".selected")) { el.classList.remove("selected") } setAuxiliaryToolbar(editorEl) let selectedType let selectedEl for (const [name, type] of Object.entries(typesWithProperties)) { type.disableInput(editorEl) let el = event.target while (el && !el.matches(type.selector)) el = el.parentElement if (el && contentEl.contains(el)) { selectedType = type selectedEl = el } } if (selectedType) { event.preventDefault() selectedType.updateInput(selectedEl, editorEl) event.target.classList.add("selected") return false } } } function setupEditor (editorEl) { // XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor? document.execCommand('defaultParagraphSeparator', false, 'p') const contentEl = editorEl.querySelector(".editor-content") contentEl.addEventListener("keydown", event => { 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 clickListener = generateClickListener(editorEl, contentEl) contentEl.addEventListener("click", clickListener, true) 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) } document.addEventListener(editorBtn("mark"), () => setAuxiliaryToolbar(editorEl, "mark")) document.addEventListener(editorBtn("img"), () => setAuxiliaryToolbar(editorEl, "img")) document.addEventListener(editorBtn("audio"), () => setAuxiliaryToolbar(editorEl, "audio")) document.addEventListener(editorBtn("video"), () => setAuxiliaryToolbar(editorEl, "video")) document.addEventListener(editorBtn("pdf"), () => setAuxiliaryToolbar(editorEl, "pdf")) cleanContent(contentEl) htmlEl.value = contentEl.innerHTML fixContent(contentEl) } // TODO: por ahora confiamos, quizás queremos filtrar estilos? function stringifyAllowedStyle (element) { return element.style.cssText } document.addEventListener("turbolinks:load", () => { for (const editorEl of document.querySelectorAll(".editor")) { setupEditor(editorEl) } })