diff --git a/app/assets/javascripts/01-types.js b/app/assets/javascripts/01-types.js new file mode 100644 index 00000000..9b0abd79 --- /dev/null +++ b/app/assets/javascripts/01-types.js @@ -0,0 +1,388 @@ +function setAuxiliaryToolbar (editorEl, toolbarName) { + const toolbarEl = editorEl.querySelector(`*[data-editor-auxiliary-toolbar]`) + for (const otherEl of toolbarEl.childNodes) { + if (otherEl.nodeType !== Node.ELEMENT_NODE) continue + otherEl.classList.remove("editor-auxiliary-tool-active") + } + if (toolbarName) { + const auxEl = editorEl.querySelector(`*[data-editor-auxiliary="${toolbarName}"]`) + auxEl.classList.add("editor-auxiliary-tool-active") + } +} + +const marks = { + bold: { + selector: "strong", + createFn: () => document.createElement("STRONG"), + }, + italic: { + selector: "em", + createFn: () => document.createElement("EM"), + }, + deleted: { + selector: "del", + createFn: () => document.createElement("DEL"), + }, + underline: { + selector: "u", + createFn: () => document.createElement("U"), + }, + mark: { + selector: "mark", + createFn: () => document.createElement("MARK"), + }, +} + +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) +} + +const blocks = { + p: { + noButton: true, + selector: "P", + setFn: tagNameSetFn("P"), + }, + h1: { + selector: "H1", + setFn: tagNameSetFn("H1"), + }, + h2: { + selector: "H2", + setFn: tagNameSetFn("H2"), + }, + h3: { + selector: "H3", + setFn: tagNameSetFn("H3"), + }, + h4: { + selector: "H4", + setFn: tagNameSetFn("H4"), + }, + h5: { + selector: "H5", + setFn: tagNameSetFn("H5"), + }, + h6: { + selector: "H6", + setFn: tagNameSetFn("H6"), + }, + ul: { + selector: "UL", + setFn: tagNameSetFn("UL"), + }, + ol: { + selector: "OL", + setFn: tagNameSetFn("OL"), + }, + img: { + selector: "IMG", + createFn: editorEl => { + const el = document.createElement("IMG") + el.src = "/public/placeholder.png" + el.alt = "" + return el + }, + }, + figure: { + selector: "FIGURE", + noButton: true + }, + figcaption: { + selector: "FIGCAPTION", + noButton: true, + }, + audio: { + selector: "AUDIO", + createFn: editorEl => { + const el = document.createElement("FIGURE") + + el.appendChild(document.createElement("AUDIO")) + el.appendChild(document.createElement("FIGCAPTION")) + + el.children[0].controls = true + el.children[1].innerText = "Toca el borde para subir un archivo de audio" + + return el + }, + }, + video: { + selector: "VIDEO", + createFn: editorEl => { + const el = document.createElement("VIDEO") + el.poster = "/public/placeholder.png" + // Para poder seleccionar el video tenemos que sacarle los + // controles, pero queremos poder verlos para reproducir el video. + // Al hacer click le damos los controles y al salir se los sacamos + // para poder hacer click de vuelta + el.addEventListener('click', event => event.target.controls = true) + el.addEventListener('focusout', event => event.target.controls = false) + return el + }, + }, + // PDF + pdf: { + selector: "IFRAME", + createFn: editorEl => { + const el = document.createElement("FIGURE") + + el.appendChild(document.createElement("IFRAME")) + el.appendChild(document.createElement("FIGCAPTION")) + + el.children[1].innerText = "Toca el borde para subir un archivo PDF" + + return el + }, + }, +} + +const divWithStyleCreateFn = styleFn => () => { + const el = document.createElement("DIV") + styleFn(el) + return el +} + +const parentBlocks = { + left: { + selector: "div[data-align=left]", + createFn: divWithStyleCreateFn(el => el.dataset.align = "left"), + }, + center: { + selector: "div[data-align=center]", + createFn: divWithStyleCreateFn(el => el.dataset.align = "center"), + }, + right: { + selector: "div[data-align=right]", + createFn: divWithStyleCreateFn(el => el.dataset.align = "right"), + }, +} + +// https://stackoverflow.com/a/3627747 +// TODO: cambiar por una solución más copada +function rgb2hex(rgb) { + rgb = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); + function hex(x) { + return ("0" + parseInt(x).toString(16)).slice(-2); + } + return "#" + hex(rgb[1]) + hex(rgb[2]) + hex(rgb[3]); +} + +const getSelected = contentEl => contentEl.querySelector(".selected") + +const typesWithProperties = { + mark: { + selector: marks.mark.selector, + updateInput (el, editorEl) { + setAuxiliaryToolbar(editorEl, "mark") + + const markColorInputEl = editorEl.querySelector(`*[data-prop="mark-color"]`) + markColorInputEl.disabled = false + markColorInputEl.value = el.style.backgroundColor ? rgb2hex(el.style.backgroundColor) : "#f206f9" + }, + disableInput (editorEl) { + const markColorInputEl = editorEl.querySelector(`*[data-prop="mark-color"]`) + markColorInputEl.disabled = true + markColorInputEl.value = "#000000" + }, + setupInput (editorEl, contentEl) { + const markColorInputEl = editorEl.querySelector(`*[data-prop="mark-color"]`) + markColorInputEl.addEventListener("change", event => { + const markEl = contentEl.querySelector(marks.mark.selector + ".selected") + if (markEl) markEl.style.backgroundColor = markColorInputEl.value + }, false) + }, + }, + img: { + selector: blocks.img.selector, + updateInput (el, editorEl) { + setAuxiliaryToolbar(editorEl, "img") + + const imgFileEl = editorEl.querySelector(`*[data-prop="img-file"]`) + imgFileEl.disabled = false + // XXX: No se puede cambiar el texto, ¡esto puede ser confuso! + + const imgAltEl = editorEl.querySelector(`*[data-prop="img-alt"]`) + imgAltEl.disabled = false + imgAltEl.value = el.alt + }, + disableInput (editorEl) { + const imgFileEl = editorEl.querySelector(`*[data-prop="img-file"]`) + imgFileEl.disabled = true + + const imgAltEl = editorEl.querySelector(`*[data-prop="img-alt"]`) + imgAltEl.disabled = true + imgAltEl.value = "" + }, + setupInput (editorEl, contentEl) { + const imgFileEl = editorEl.querySelector(`*[data-prop="img-file"]`) + imgFileEl.addEventListener("input", event => { + const imgEl = contentEl.querySelector("img.selected") + if (!imgEl) return + + const file = imgFileEl.files[0] + + imgEl.src = URL.createObjectURL(file) + imgEl.dataset.editorLoading = true + uploadFile(file) + .then(url => { + imgEl.src = url + delete imgEl.dataset.editorError + }) + .catch(err => { + // TODO: mostrar error + console.error(err) + imgEl.dataset.editorError = true + }) + .finally(() => { + delete imgEl.dataset.editorLoading + }) + }, false) + + const imgAltEl = editorEl.querySelector(`*[data-prop="img-alt"]`) + imgAltEl.addEventListener("input", event => { + const imgEl = contentEl.querySelector("img.selected") + if (imgEl) imgEl.alt = imgAltEl.value + }, false) + }, + }, + figure: { + selector: blocks.figure.selector, + actualInput (el) { + // TODO: Cuando tengamos otros iframes hay que seleccionarlos de + // otra forma. + const tag = el.children[0].tagName.toLowerCase() + + return typesWithProperties[(tag === 'iframe' ? 'pdf' : tag)] + }, + updateInput (el, editorEl) { + typesWithProperties.figure.actualInput(el).updateInput(el.children[0], editorEl) + }, + disableInput (editorEl) {}, + setupInput (editorEl, contentEl) {}, + }, + audio: { + selector: blocks.audio.selector, + updateInput (el, editorEl) { + setAuxiliaryToolbar(editorEl, "audio") + + const audioFileEl = editorEl.querySelector(`*[data-prop="audio-file"]`) + audioFileEl.disabled = false + // XXX: No se puede cambiar el texto, ¡esto puede ser confuso! + }, + disableInput (editorEl) { + const audioFileEl = editorEl.querySelector(`*[data-prop="audio-file"]`) + audioFileEl.disabled = true + }, + setupInput (editorEl, contentEl) { + const audioFileEl = editorEl.querySelector(`*[data-prop="audio-file"]`) + audioFileEl.addEventListener("input", event => { + const figureEl = getSelected(contentEl) + if (!figureEl) return + + const file = audioFileEl.files[0] + + const audioEl = figureEl.querySelector('audio') + + audioEl.src = URL.createObjectURL(file) + audioEl.dataset.editorLoading = true + uploadFile(file) + .then(url => { + audioEl.src = url + delete audioEl.dataset.editorError + }) + .catch(err => { + // TODO: mostrar error + console.error(err) + audioEl.dataset.editorError = true + }) + .finally(() => { + delete audioEl.dataset.editorLoading + }) + }, false) + }, + }, + video: { + selector: blocks.video.selector, + updateInput (el, editorEl) { + setAuxiliaryToolbar(editorEl, "video") + + const videoFileEl = editorEl.querySelector(`*[data-prop="video-file"]`) + videoFileEl.disabled = false + // XXX: No se puede cambiar el texto, ¡esto puede ser confuso! + }, + disableInput (editorEl) { + const videoFileEl = editorEl.querySelector(`*[data-prop="video-file"]`) + videoFileEl.disabled = true + }, + setupInput (editorEl, contentEl) { + const videoFileEl = editorEl.querySelector(`*[data-prop="video-file"]`) + videoFileEl.addEventListener("input", event => { + const videoEl = getSelected(contentEl) + if (!videoEl) return + + const file = videoFileEl.files[0] + + videoEl.poster = "" + videoEl.src = URL.createObjectURL(file) + videoEl.dataset.editorLoading = true + uploadFile(file) + .then(url => { + videoEl.src = url + delete videoEl.dataset.editorError + }) + .catch(err => { + // TODO: mostrar error + console.error(err) + videoEl.dataset.editorError = true + }) + .finally(() => { + delete videoEl.dataset.editorLoading + }) + }, false) + }, + }, + pdf: { + selector: blocks.pdf.selector, + updateInput (el, editorEl) { + setAuxiliaryToolbar(editorEl, "pdf") + + const pdfFileEl = editorEl.querySelector(`*[data-prop="pdf-file"]`) + pdfFileEl.disabled = false + // XXX: No se puede cambiar el texto, ¡esto puede ser confuso! + }, + disableInput (editorEl) { + const pdfFileEl = editorEl.querySelector(`*[data-prop="pdf-file"]`) + pdfFileEl.disabled = true + }, + setupInput (editorEl, contentEl) { + const pdfFileEl = editorEl.querySelector(`*[data-prop="pdf-file"]`) + pdfFileEl.addEventListener("input", event => { + const figureEl = getSelected(contentEl) + if (!figureEl) return + + const file = pdfFileEl.files[0] + const pdfEl = figureEl.children[0] + + pdfEl.src = URL.createObjectURL(file) + pdfEl.dataset.editorLoading = true + uploadFile(file) + .then(url => { + pdfEl.src = url + delete pdfEl.dataset.editorError + }) + .catch(err => { + // TODO: mostrar error + console.error(err) + pdfEl.dataset.editorError = true + }) + .finally(() => { + delete pdfEl.dataset.editorLoading + }) + }, false) + }, + }, +} diff --git a/app/assets/javascripts/02-editor.js b/app/assets/javascripts/02-editor.js new file mode 100644 index 00000000..2d6b9f66 --- /dev/null +++ b/app/assets/javascripts/02-editor.js @@ -0,0 +1,488 @@ +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 + * * Borra inline styles no autorizados + * * Borra propiedades de IMG no autorizadas + * * Borra y