5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-07-05 21:55:46 +00:00
panel/app/assets/javascripts/02-editor.js
2020-11-18 22:51:28 -03:00

501 lines
14 KiB
JavaScript

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 <FONT> y <STYLE>
*/
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)
} 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 = "/public/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)
}
for (const style of Object.values(child.style)) {
const value = child.style[style]
switch (style) {
case 'background-color':
if (child.tagName === "MARK") break
default:
child.style[style] = ""
}
}
}
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()
}
})
contentEl.addEventListener("paste", event => {
contentEl.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)
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"))
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)
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")) {
if (!editorEl.querySelector('.editor-toolbar')) continue
setupEditor(editorEl)
}
})