Merge branch 'void/editor' into rails
This commit is contained in:
commit
de5c1864c3
4 changed files with 29 additions and 877 deletions
|
@ -1,546 +0,0 @@
|
||||||
import {
|
|
||||||
moveChildren,
|
|
||||||
marks,
|
|
||||||
blocks,
|
|
||||||
parentBlocks,
|
|
||||||
typesWithProperties,
|
|
||||||
setAuxiliaryToolbar,
|
|
||||||
tagNameSetFn
|
|
||||||
} from 'editor/types'
|
|
||||||
|
|
||||||
import { setMultimedia } from 'editor/types/multimedia'
|
|
||||||
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
const safeGetRangeAt = (num) => {
|
|
||||||
try {
|
|
||||||
return window.getSelection().getRangeAt(num)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("No hay una selección!")
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const isDirectChild = (node, supposedChild) => {
|
|
||||||
for (const child of node.childNodes) {
|
|
||||||
if (child == supposedChild) return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isChildSelection = (sel, el) => {
|
|
||||||
return (
|
|
||||||
(el.contains(sel.anchorNode) || el.contains(sel.focusNode))
|
|
||||||
&& !(sel.anchorNode == el || sel.focusNode == el)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getElementParent = (node) => {
|
|
||||||
let parentEl = node
|
|
||||||
while (parentEl.nodeType != Node.ELEMENT_NODE) parentEl = parentEl.parentElement
|
|
||||||
return parentEl
|
|
||||||
}
|
|
||||||
|
|
||||||
const 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.
|
|
||||||
*/
|
|
||||||
const 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. */
|
|
||||||
const 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. */
|
|
||||||
const 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)
|
|
||||||
|
|
||||||
const 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 === "BR") return true
|
|
||||||
if (element.parentElement.tagName === "FIGURE"
|
|
||||||
&& (element.dataset.editorMultimediaElement
|
|
||||||
|| element.tagName === "FIGCAPTION")
|
|
||||||
) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Limpia el elemento de contenido del editor
|
|
||||||
* Por ahora:
|
|
||||||
* * Cambia el tag de los bloques no reconocidos (ver `elementIsBlock`)
|
|
||||||
* * Borra <figure> sin multimedia adentro
|
|
||||||
* * Hace lo que hace cleanNode
|
|
||||||
*/
|
|
||||||
const 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 (child.tagName === "FIGURE") {
|
|
||||||
if (
|
|
||||||
!child.querySelector('*[data-editor-multimedia-element]')
|
|
||||||
&& !child.dataset.editorLoading
|
|
||||||
) {
|
|
||||||
moveChildren(child, child.parentNode, child)
|
|
||||||
child.parentNode.removeChild(child)
|
|
||||||
}
|
|
||||||
} else if (!elementIsBlock(child)) {
|
|
||||||
const el = document.createElement("p")
|
|
||||||
moveChildren(child, el, null)
|
|
||||||
child.parentNode.replaceChild(el, child)
|
|
||||||
}
|
|
||||||
} else if (child.nodeType === Node.TEXT_NODE) {
|
|
||||||
const el = document.createElement("p")
|
|
||||||
contentEl.insertBefore(el, child.nextSibling)
|
|
||||||
el.appendChild(child)
|
|
||||||
|
|
||||||
sel.selectAllChildren(el)
|
|
||||||
sel.collapseToEnd()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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á
|
|
||||||
*/
|
|
||||||
const 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 <FONT> y <STYLE>
|
|
||||||
*/
|
|
||||||
const 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) {
|
|
||||||
const range = safeGetRangeAt(0)
|
|
||||||
if (!hasContent(child) && (!range || !range.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)
|
|
||||||
} else if (child.tagName === "IMG") {
|
|
||||||
if (child.getAttribute("width")) child.removeAttribute("width")
|
|
||||||
if (child.getAttribute("height")) child.removeAttribute("height")
|
|
||||||
if (child.getAttribute("vspace")) child.removeAttribute("vspace")
|
|
||||||
if (child.getAttribute("hspace")) child.removeAttribute("hspace")
|
|
||||||
if (child.align.length) child.removeAttribute("align")
|
|
||||||
if (child.name.length) child.removeAttribute("name")
|
|
||||||
if (!child.src.length) child.src = "/placeholder.png"
|
|
||||||
} else if (child.tagName === "FONT") {
|
|
||||||
moveChildren(child, child.parentElement, child.nextSiling)
|
|
||||||
child.parentElement.removeChild(child)
|
|
||||||
return
|
|
||||||
} else if (child.tagName === "STYLE") {
|
|
||||||
return child.parentElement.removeChild(child)
|
|
||||||
} else if (child.tagName === "B") {
|
|
||||||
const el = document.createElement("STRONG")
|
|
||||||
moveChildren(child, el)
|
|
||||||
child.parentElement.insertBefore(el, child)
|
|
||||||
child.parentElement.removeChild(child)
|
|
||||||
} else if (child.tagName === "I") {
|
|
||||||
const el = document.createElement("EM")
|
|
||||||
moveChildren(child, el)
|
|
||||||
child.parentElement.insertBefore(el, child)
|
|
||||||
child.parentElement.removeChild(child)
|
|
||||||
}
|
|
||||||
|
|
||||||
child.style.forEach(prop => {
|
|
||||||
const value = child.style[prop]
|
|
||||||
|
|
||||||
switch (prop) {
|
|
||||||
case 'background-color':
|
|
||||||
if (child.tagName === "MARK") return
|
|
||||||
default:
|
|
||||||
child.style[prop] = ""
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
cleanNode(child, contentEl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Generar el clickListener para este editor.
|
|
||||||
*/
|
|
||||||
const generateClickListener = (editorEl, contentEl) => {
|
|
||||||
/* El event listener para los typesWithProperties.
|
|
||||||
*/
|
|
||||||
return (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)
|
|
||||||
selectedEl.classList.add("selected")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const 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("paste", event => {
|
|
||||||
editorEl.querySelector(".editor-aviso-word").style.display = "block"
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
fixContent(contentEl)
|
|
||||||
storeContent(editorEl, contentEl)
|
|
||||||
|
|
||||||
htmlEl.value = contentEl.innerHTML
|
|
||||||
})
|
|
||||||
observer.observe(contentEl, {
|
|
||||||
childList: true,
|
|
||||||
attributes: true,
|
|
||||||
subtree: true,
|
|
||||||
characterData: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const editorBtn = id => editorEl.querySelector(`*[data-button="${id}"]`)
|
|
||||||
|
|
||||||
// == 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"))
|
|
||||||
document.addEventListener(editorBtn("a"), () => setAuxiliaryToolbar(editorEl, "a"))
|
|
||||||
|
|
||||||
for (const video of document.querySelectorAll('.editor .editor-content video')) {
|
|
||||||
video.addEventListener('click', event => event.target.controls = true)
|
|
||||||
video.addEventListener('focusout', event => event.target.controls = false)
|
|
||||||
video.controls = false
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanContent(contentEl)
|
|
||||||
fixContent(contentEl)
|
|
||||||
|
|
||||||
// Recuperar el contenido si hay algo guardado, si tuviéramos un campo
|
|
||||||
// de última edición podríamos saber si el artículo fue editado
|
|
||||||
// después o la versión local es la última.
|
|
||||||
//
|
|
||||||
// TODO: Preguntar si se lo quiere recuperar.
|
|
||||||
restoreContent(editorEl, contentEl)
|
|
||||||
|
|
||||||
htmlEl.value = contentEl.innerHTML
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: por ahora confiamos, quizás queremos filtrar estilos?
|
|
||||||
const stringifyAllowedStyle = (element) => element.style.cssText
|
|
||||||
|
|
||||||
document.addEventListener("turbolinks:load", () => {
|
|
||||||
for (const editorEl of document.querySelectorAll(".editor")) {
|
|
||||||
if (!editorEl.querySelector('.editor-toolbar')) continue
|
|
||||||
|
|
||||||
setupEditor(editorEl)
|
|
||||||
}
|
|
||||||
|
|
||||||
const flash = document.querySelector('.js-flash[data-target="editor"]')
|
|
||||||
if (!flash) return
|
|
||||||
|
|
||||||
switch (flash.dataset.action) {
|
|
||||||
case 'forget-content':
|
|
||||||
if (!flash.dataset.keys) break
|
|
||||||
|
|
||||||
try { JSON.parse(flash.dataset.keys).forEach(forgetContent) } catch(e) { undefined }
|
|
||||||
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
})
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { storeContent, restoreContent } from 'editor/storage'
|
import { storeContent, restoreContent } from 'editor/storage'
|
||||||
import { isDirectChild, moveChildren, safeGetSelection, safeGetRangeAt, setAuxiliaryToolbar } from 'editor/utils'
|
import { isDirectChild, moveChildren, safeGetSelection, safeGetRangeAt, setAuxiliaryToolbar, parentBlockNames } from 'editor/utils'
|
||||||
import { types, getValidChildren, getType } from 'editor/types'
|
import { types, getValidChildren, getType } from 'editor/types'
|
||||||
import { setupButtons as setupMarksButtons } from 'editor/types/marks'
|
import { setupButtons as setupMarksButtons } from 'editor/types/marks'
|
||||||
import { setupButtons as setupBlocksButtons } from 'editor/types/blocks'
|
import { setupButtons as setupBlocksButtons } from 'editor/types/blocks'
|
||||||
|
@ -14,7 +14,7 @@ import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from 'editor/types
|
||||||
// Esta funcion corrije errores que pueden haber como:
|
// Esta funcion corrije errores que pueden haber como:
|
||||||
// * que un nodo que no tiene 'text' permitido no tenga children (se les
|
// * que un nodo que no tiene 'text' permitido no tenga children (se les
|
||||||
// inserta un allowedChildren[0])
|
// inserta un allowedChildren[0])
|
||||||
// * TODO: que haya una imágen sin <figure> o que no esté como bloque (se ponen
|
// * que haya una imágen sin <figure> o que no esté como bloque (se ponen
|
||||||
// después del bloque en el que están como bloque de por si)
|
// después del bloque en el que están como bloque de por si)
|
||||||
// * convierte <i> y <b> en <em> y <strong>
|
// * convierte <i> y <b> en <em> y <strong>
|
||||||
// Lo hace para que siga la estructura del documento y que no se borren por
|
// Lo hace para que siga la estructura del documento y que no se borren por
|
||||||
|
@ -38,6 +38,33 @@ function fixContent (editor: Editor, node: Element = editor.contentEl): void {
|
||||||
node = el
|
node = el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node instanceof HTMLImageElement) {
|
||||||
|
node.dataset.multimediaInner = ''
|
||||||
|
const figureEl = types.multimedia.create(editor)
|
||||||
|
|
||||||
|
let targetEl = node.parentElement
|
||||||
|
if (!targetEl) throw new Error('No encontré lx objetivo')
|
||||||
|
while (true) {
|
||||||
|
const type = getType(targetEl)
|
||||||
|
if (!type) throw new Error('lx objetivo tiene tipo')
|
||||||
|
if (type.type.allowedChildren.includes('multimedia')) break
|
||||||
|
if (!targetEl.parentElement) throw new Error('No encontré lx objetivo')
|
||||||
|
targetEl = targetEl.parentElement
|
||||||
|
}
|
||||||
|
|
||||||
|
let parentEl = [...targetEl.childNodes].find(
|
||||||
|
el => el.contains(node)
|
||||||
|
)
|
||||||
|
if (!parentEl) throw new Error('no encontré lx pariente')
|
||||||
|
|
||||||
|
const innerEl = figureEl.querySelector('[data-multimedia-inner]')
|
||||||
|
if (!innerEl) throw new Error('Raro.')
|
||||||
|
figureEl.replaceChild(node, innerEl)
|
||||||
|
|
||||||
|
targetEl.insertBefore(figureEl, parentEl)
|
||||||
|
node = figureEl
|
||||||
|
}
|
||||||
|
|
||||||
const _type = getType(node)
|
const _type = getType(node)
|
||||||
if (!_type) return
|
if (!_type) return
|
||||||
|
|
||||||
|
|
|
@ -1,181 +0,0 @@
|
||||||
import multimedia from './types/multimedia'
|
|
||||||
|
|
||||||
export const 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const moveChildren = (from, to, toRef) => {
|
|
||||||
while (from.firstChild) to.insertBefore(from.firstChild, toRef);
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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"),
|
|
||||||
},
|
|
||||||
sub: {
|
|
||||||
selector: "sub",
|
|
||||||
createFn: () => document.createElement("SUB"),
|
|
||||||
},
|
|
||||||
sup: {
|
|
||||||
selector: "sup",
|
|
||||||
createFn: () => document.createElement("SUP"),
|
|
||||||
},
|
|
||||||
mark: {
|
|
||||||
selector: "mark",
|
|
||||||
createFn: () => document.createElement("MARK"),
|
|
||||||
},
|
|
||||||
a: {
|
|
||||||
selector: "a",
|
|
||||||
createFn: () => document.createElement("A"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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"),
|
|
||||||
},
|
|
||||||
multimedia: multimedia.block,
|
|
||||||
}
|
|
||||||
|
|
||||||
const divWithStyleCreateFn = styleFn => () => {
|
|
||||||
const el = document.createElement("DIV")
|
|
||||||
styleFn(el)
|
|
||||||
return el
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const hex = (x) => ("0" + parseInt(x).toString(16)).slice(-2)
|
|
||||||
|
|
||||||
// https://stackoverflow.com/a/3627747
|
|
||||||
// TODO: cambiar por una solución más copada
|
|
||||||
const rgb2hex = (rgb) => {
|
|
||||||
rgb = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
|
|
||||||
return "#" + hex(rgb[1]) + hex(rgb[2]) + hex(rgb[3]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSelected = contentEl => contentEl.querySelector(".selected")
|
|
||||||
|
|
||||||
export const typesWithProperties = {
|
|
||||||
a: {
|
|
||||||
selector: marks.a.selector,
|
|
||||||
updateInput (el, editorEl) {
|
|
||||||
setAuxiliaryToolbar(editorEl, "a")
|
|
||||||
|
|
||||||
const markAInputEl = editorEl.querySelector(`*[data-prop="a-href"]`)
|
|
||||||
markAInputEl.disabled = false
|
|
||||||
markAInputEl.value = el.href
|
|
||||||
},
|
|
||||||
disableInput (editorEl) {
|
|
||||||
const markAInputEl = editorEl.querySelector(`*[data-prop="a-href"]`)
|
|
||||||
markAInputEl.disabled = true
|
|
||||||
markAInputEl.value = ""
|
|
||||||
},
|
|
||||||
setupInput (editorEl, contentEl) {
|
|
||||||
const markAInputEl = editorEl.querySelector(`*[data-prop="a-href"]`)
|
|
||||||
markAInputEl.addEventListener("change", event => {
|
|
||||||
const markEl = contentEl.querySelector(marks.a.selector + ".selected")
|
|
||||||
if (markEl) markEl.href = markAInputEl.value
|
|
||||||
}, false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
multimedia: multimedia.typeWithProperty,
|
|
||||||
}
|
|
|
@ -1,148 +0,0 @@
|
||||||
import { setAuxiliaryToolbar } from '../types'
|
|
||||||
|
|
||||||
const 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const srcMakeElement = tag => url => {
|
|
||||||
const el = document.createElement(tag)
|
|
||||||
el.src = url
|
|
||||||
return el
|
|
||||||
}
|
|
||||||
|
|
||||||
const multimediaKinds = {
|
|
||||||
img: {
|
|
||||||
mime: /^image\/.+$/,
|
|
||||||
makeElement: srcMakeElement('img'),
|
|
||||||
},
|
|
||||||
audio: {
|
|
||||||
mime: /^audio\/.+$/,
|
|
||||||
makeElement: srcMakeElement('audio'),
|
|
||||||
},
|
|
||||||
video: {
|
|
||||||
mime: /^video\/.+$/,
|
|
||||||
makeElement: srcMakeElement('video'),
|
|
||||||
},
|
|
||||||
pdf: {
|
|
||||||
mime: /^application\/pdf$/,
|
|
||||||
makeElement: srcMakeElement('iframe'),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const setMultimedia = (figureEl, url, mimeType) => {
|
|
||||||
let kind
|
|
||||||
for (const _kind of Object.values(multimediaKinds)) {
|
|
||||||
if (mimeType.match(_kind.mime)) {
|
|
||||||
kind = _kind
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO: mostrar error
|
|
||||||
if (!kind)
|
|
||||||
throw new Error('¡Archivo no soportado!')
|
|
||||||
|
|
||||||
const currentMultimedia = figureEl.querySelector('*[data-editor-multimedia-element]')
|
|
||||||
const newMultimedia = kind.makeElement(url)
|
|
||||||
newMultimedia.dataset.editorMultimediaElement = true
|
|
||||||
if (currentMultimedia)
|
|
||||||
figureEl.replaceChild(newMultimedia, currentMultimedia)
|
|
||||||
else figureEl.appendChild(newMultimedia)
|
|
||||||
}
|
|
||||||
|
|
||||||
const block = {
|
|
||||||
selector: "FIGURE[data-editor-multimedia]",
|
|
||||||
createFn: editorEl => {
|
|
||||||
const el = document.createElement("FIGURE")
|
|
||||||
el.dataset.editorMultimedia = true
|
|
||||||
|
|
||||||
const placeholderEl = document.createElement("p")
|
|
||||||
placeholderEl.dataset.editorMultimediaElement = true
|
|
||||||
placeholderEl.append("Toca el borde para subir un archivo")
|
|
||||||
el.appendChild(placeholderEl)
|
|
||||||
|
|
||||||
const figcaptionEl = document.createElement("FIGCAPTION")
|
|
||||||
figcaptionEl.append("Escribí una descripción del archivo")
|
|
||||||
figcaptionEl.controls = true
|
|
||||||
el.appendChild(figcaptionEl)
|
|
||||||
|
|
||||||
return el
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const typeWithProperty = {
|
|
||||||
selector: block.selector,
|
|
||||||
updateInput (el, editorEl) {
|
|
||||||
setAuxiliaryToolbar(editorEl, "multimedia")
|
|
||||||
|
|
||||||
const fileEl = editorEl.querySelector(`*[data-prop="multimedia-file"]`)
|
|
||||||
fileEl.disabled = false
|
|
||||||
// XXX: No se puede cambiar el texto del archivo seleccionado,
|
|
||||||
// ¡esto puede ser confuso!
|
|
||||||
|
|
||||||
const altEl = editorEl.querySelector(`*[data-prop="multimedia-alt"]`)
|
|
||||||
altEl.disabled = false
|
|
||||||
altEl.value = el.alt
|
|
||||||
|
|
||||||
const uploadEl = editorEl.querySelector(`*[data-prop="multimedia-file-upload"]`)
|
|
||||||
uploadEl.disabled = false
|
|
||||||
},
|
|
||||||
disableInput (editorEl) {
|
|
||||||
const fileEl = editorEl.querySelector(`*[data-prop="multimedia-file"]`)
|
|
||||||
fileEl.disabled = true
|
|
||||||
|
|
||||||
const altEl = editorEl.querySelector(`*[data-prop="multimedia-alt"]`)
|
|
||||||
altEl.disabled = true
|
|
||||||
altEl.value = ""
|
|
||||||
|
|
||||||
const uploadEl = editorEl.querySelector(`*[data-prop="multimedia-file-upload"]`)
|
|
||||||
uploadEl.disabled = true
|
|
||||||
},
|
|
||||||
setupInput (editorEl, contentEl) {
|
|
||||||
const fileEl = editorEl.querySelector(`*[data-prop="multimedia-file"]`)
|
|
||||||
const uploadEl = editorEl.querySelector(`*[data-prop="multimedia-file-upload"]`)
|
|
||||||
|
|
||||||
uploadEl.addEventListener("click", event => {
|
|
||||||
const selectedEl = contentEl.querySelector("figure.selected")
|
|
||||||
if (!selectedEl) return
|
|
||||||
|
|
||||||
const file = fileEl.files[0]
|
|
||||||
|
|
||||||
selectedEl.dataset.editorLoading = true
|
|
||||||
uploadFile(file)
|
|
||||||
.then(url => {
|
|
||||||
setMultimedia(selectedEl, url, file.type)
|
|
||||||
delete selectedEl.dataset.editorError
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
// TODO: mostrar error
|
|
||||||
console.error(err)
|
|
||||||
selectedEl.dataset.editorError = true
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
delete selectedEl.dataset.editorLoading
|
|
||||||
})
|
|
||||||
}, false)
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
const altEl = editorEl.querySelector(`*[data-prop="multimedia-alt"]`)
|
|
||||||
altEl.addEventListener("input", event => {
|
|
||||||
const imgEl = contentEl.querySelector("img.selected")
|
|
||||||
if (imgEl) imgEl.alt = imgAltEl.value
|
|
||||||
}, false)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { block, typeWithProperty }
|
|
Loading…
Reference in a new issue