const origin = location.origin /* * Guarda una copia local de los cambios para poder recuperarlos * después. * * Usamos la URL completa sin anchors. */ const storageKey = (editorEl) => editorEl.querySelector('[data-target="storage-key"]').value const forgetContent = (storedKey) => window.localStorage.removeItem(storedKey) const storeContent = (editorEl, contentEl) => { if (contentEl.innerText.trim().length === 0) return window.localStorage.setItem(storageKey(editorEl), contentEl.innerHTML) } const restoreContent = (editorEl, contentEl) => { const content = window.localStorage.getItem(storageKey(editorEl)) if (!content) return if (content.trim().length === 0) return contentEl.innerHTML = content } /* getRangeAt puede fallar si no hay una selección */ function safeGetRangeAt (num) { try { return window.getSelection().getRangeAt(num) } catch (error) { console.warn("No hay una selección!") return null; } } 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 = safeGetRangeAt(0) if (!range) return 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 = safeGetRangeAt(0) if (!range) return 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) newEl.click() } }) } /* 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 = safeGetRangeAt(0) if (!range) return 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" || element.tagName === "BR") 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 el = document.createElement("p") contentEl.insertBefore(el, child.nextSibling) el.appendChild(child) } } } /* 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 * * Borra inline styles no autorizados * * Borra propiedades de IMG no autorizadas * * Borra y