5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-22 16:16:21 +00:00

Merge branch 'editor-nuevo' into rails

This commit is contained in:
f 2020-11-17 20:46:55 -03:00
commit 3516d910b2
20 changed files with 2343 additions and 150 deletions

View file

@ -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)
},
},
}

View file

@ -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 <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") {
child.parentElement.removeChild(child)
return
}
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()
}
})
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)
}
})

View file

@ -0,0 +1,942 @@
(function(global, factory) {
typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define([ "exports" ], factory) : factory(global.ActiveStorage = {});
})(this, function(exports) {
"use strict";
function createCommonjsModule(fn, module) {
return module = {
exports: {}
}, fn(module, module.exports), module.exports;
}
var sparkMd5 = createCommonjsModule(function(module, exports) {
(function(factory) {
{
module.exports = factory();
}
})(function(undefined) {
var hex_chr = [ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f" ];
function md5cycle(x, k) {
var a = x[0], b = x[1], c = x[2], d = x[3];
a += (b & c | ~b & d) + k[0] - 680876936 | 0;
a = (a << 7 | a >>> 25) + b | 0;
d += (a & b | ~a & c) + k[1] - 389564586 | 0;
d = (d << 12 | d >>> 20) + a | 0;
c += (d & a | ~d & b) + k[2] + 606105819 | 0;
c = (c << 17 | c >>> 15) + d | 0;
b += (c & d | ~c & a) + k[3] - 1044525330 | 0;
b = (b << 22 | b >>> 10) + c | 0;
a += (b & c | ~b & d) + k[4] - 176418897 | 0;
a = (a << 7 | a >>> 25) + b | 0;
d += (a & b | ~a & c) + k[5] + 1200080426 | 0;
d = (d << 12 | d >>> 20) + a | 0;
c += (d & a | ~d & b) + k[6] - 1473231341 | 0;
c = (c << 17 | c >>> 15) + d | 0;
b += (c & d | ~c & a) + k[7] - 45705983 | 0;
b = (b << 22 | b >>> 10) + c | 0;
a += (b & c | ~b & d) + k[8] + 1770035416 | 0;
a = (a << 7 | a >>> 25) + b | 0;
d += (a & b | ~a & c) + k[9] - 1958414417 | 0;
d = (d << 12 | d >>> 20) + a | 0;
c += (d & a | ~d & b) + k[10] - 42063 | 0;
c = (c << 17 | c >>> 15) + d | 0;
b += (c & d | ~c & a) + k[11] - 1990404162 | 0;
b = (b << 22 | b >>> 10) + c | 0;
a += (b & c | ~b & d) + k[12] + 1804603682 | 0;
a = (a << 7 | a >>> 25) + b | 0;
d += (a & b | ~a & c) + k[13] - 40341101 | 0;
d = (d << 12 | d >>> 20) + a | 0;
c += (d & a | ~d & b) + k[14] - 1502002290 | 0;
c = (c << 17 | c >>> 15) + d | 0;
b += (c & d | ~c & a) + k[15] + 1236535329 | 0;
b = (b << 22 | b >>> 10) + c | 0;
a += (b & d | c & ~d) + k[1] - 165796510 | 0;
a = (a << 5 | a >>> 27) + b | 0;
d += (a & c | b & ~c) + k[6] - 1069501632 | 0;
d = (d << 9 | d >>> 23) + a | 0;
c += (d & b | a & ~b) + k[11] + 643717713 | 0;
c = (c << 14 | c >>> 18) + d | 0;
b += (c & a | d & ~a) + k[0] - 373897302 | 0;
b = (b << 20 | b >>> 12) + c | 0;
a += (b & d | c & ~d) + k[5] - 701558691 | 0;
a = (a << 5 | a >>> 27) + b | 0;
d += (a & c | b & ~c) + k[10] + 38016083 | 0;
d = (d << 9 | d >>> 23) + a | 0;
c += (d & b | a & ~b) + k[15] - 660478335 | 0;
c = (c << 14 | c >>> 18) + d | 0;
b += (c & a | d & ~a) + k[4] - 405537848 | 0;
b = (b << 20 | b >>> 12) + c | 0;
a += (b & d | c & ~d) + k[9] + 568446438 | 0;
a = (a << 5 | a >>> 27) + b | 0;
d += (a & c | b & ~c) + k[14] - 1019803690 | 0;
d = (d << 9 | d >>> 23) + a | 0;
c += (d & b | a & ~b) + k[3] - 187363961 | 0;
c = (c << 14 | c >>> 18) + d | 0;
b += (c & a | d & ~a) + k[8] + 1163531501 | 0;
b = (b << 20 | b >>> 12) + c | 0;
a += (b & d | c & ~d) + k[13] - 1444681467 | 0;
a = (a << 5 | a >>> 27) + b | 0;
d += (a & c | b & ~c) + k[2] - 51403784 | 0;
d = (d << 9 | d >>> 23) + a | 0;
c += (d & b | a & ~b) + k[7] + 1735328473 | 0;
c = (c << 14 | c >>> 18) + d | 0;
b += (c & a | d & ~a) + k[12] - 1926607734 | 0;
b = (b << 20 | b >>> 12) + c | 0;
a += (b ^ c ^ d) + k[5] - 378558 | 0;
a = (a << 4 | a >>> 28) + b | 0;
d += (a ^ b ^ c) + k[8] - 2022574463 | 0;
d = (d << 11 | d >>> 21) + a | 0;
c += (d ^ a ^ b) + k[11] + 1839030562 | 0;
c = (c << 16 | c >>> 16) + d | 0;
b += (c ^ d ^ a) + k[14] - 35309556 | 0;
b = (b << 23 | b >>> 9) + c | 0;
a += (b ^ c ^ d) + k[1] - 1530992060 | 0;
a = (a << 4 | a >>> 28) + b | 0;
d += (a ^ b ^ c) + k[4] + 1272893353 | 0;
d = (d << 11 | d >>> 21) + a | 0;
c += (d ^ a ^ b) + k[7] - 155497632 | 0;
c = (c << 16 | c >>> 16) + d | 0;
b += (c ^ d ^ a) + k[10] - 1094730640 | 0;
b = (b << 23 | b >>> 9) + c | 0;
a += (b ^ c ^ d) + k[13] + 681279174 | 0;
a = (a << 4 | a >>> 28) + b | 0;
d += (a ^ b ^ c) + k[0] - 358537222 | 0;
d = (d << 11 | d >>> 21) + a | 0;
c += (d ^ a ^ b) + k[3] - 722521979 | 0;
c = (c << 16 | c >>> 16) + d | 0;
b += (c ^ d ^ a) + k[6] + 76029189 | 0;
b = (b << 23 | b >>> 9) + c | 0;
a += (b ^ c ^ d) + k[9] - 640364487 | 0;
a = (a << 4 | a >>> 28) + b | 0;
d += (a ^ b ^ c) + k[12] - 421815835 | 0;
d = (d << 11 | d >>> 21) + a | 0;
c += (d ^ a ^ b) + k[15] + 530742520 | 0;
c = (c << 16 | c >>> 16) + d | 0;
b += (c ^ d ^ a) + k[2] - 995338651 | 0;
b = (b << 23 | b >>> 9) + c | 0;
a += (c ^ (b | ~d)) + k[0] - 198630844 | 0;
a = (a << 6 | a >>> 26) + b | 0;
d += (b ^ (a | ~c)) + k[7] + 1126891415 | 0;
d = (d << 10 | d >>> 22) + a | 0;
c += (a ^ (d | ~b)) + k[14] - 1416354905 | 0;
c = (c << 15 | c >>> 17) + d | 0;
b += (d ^ (c | ~a)) + k[5] - 57434055 | 0;
b = (b << 21 | b >>> 11) + c | 0;
a += (c ^ (b | ~d)) + k[12] + 1700485571 | 0;
a = (a << 6 | a >>> 26) + b | 0;
d += (b ^ (a | ~c)) + k[3] - 1894986606 | 0;
d = (d << 10 | d >>> 22) + a | 0;
c += (a ^ (d | ~b)) + k[10] - 1051523 | 0;
c = (c << 15 | c >>> 17) + d | 0;
b += (d ^ (c | ~a)) + k[1] - 2054922799 | 0;
b = (b << 21 | b >>> 11) + c | 0;
a += (c ^ (b | ~d)) + k[8] + 1873313359 | 0;
a = (a << 6 | a >>> 26) + b | 0;
d += (b ^ (a | ~c)) + k[15] - 30611744 | 0;
d = (d << 10 | d >>> 22) + a | 0;
c += (a ^ (d | ~b)) + k[6] - 1560198380 | 0;
c = (c << 15 | c >>> 17) + d | 0;
b += (d ^ (c | ~a)) + k[13] + 1309151649 | 0;
b = (b << 21 | b >>> 11) + c | 0;
a += (c ^ (b | ~d)) + k[4] - 145523070 | 0;
a = (a << 6 | a >>> 26) + b | 0;
d += (b ^ (a | ~c)) + k[11] - 1120210379 | 0;
d = (d << 10 | d >>> 22) + a | 0;
c += (a ^ (d | ~b)) + k[2] + 718787259 | 0;
c = (c << 15 | c >>> 17) + d | 0;
b += (d ^ (c | ~a)) + k[9] - 343485551 | 0;
b = (b << 21 | b >>> 11) + c | 0;
x[0] = a + x[0] | 0;
x[1] = b + x[1] | 0;
x[2] = c + x[2] | 0;
x[3] = d + x[3] | 0;
}
function md5blk(s) {
var md5blks = [], i;
for (i = 0; i < 64; i += 4) {
md5blks[i >> 2] = s.charCodeAt(i) + (s.charCodeAt(i + 1) << 8) + (s.charCodeAt(i + 2) << 16) + (s.charCodeAt(i + 3) << 24);
}
return md5blks;
}
function md5blk_array(a) {
var md5blks = [], i;
for (i = 0; i < 64; i += 4) {
md5blks[i >> 2] = a[i] + (a[i + 1] << 8) + (a[i + 2] << 16) + (a[i + 3] << 24);
}
return md5blks;
}
function md51(s) {
var n = s.length, state = [ 1732584193, -271733879, -1732584194, 271733878 ], i, length, tail, tmp, lo, hi;
for (i = 64; i <= n; i += 64) {
md5cycle(state, md5blk(s.substring(i - 64, i)));
}
s = s.substring(i - 64);
length = s.length;
tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ];
for (i = 0; i < length; i += 1) {
tail[i >> 2] |= s.charCodeAt(i) << (i % 4 << 3);
}
tail[i >> 2] |= 128 << (i % 4 << 3);
if (i > 55) {
md5cycle(state, tail);
for (i = 0; i < 16; i += 1) {
tail[i] = 0;
}
}
tmp = n * 8;
tmp = tmp.toString(16).match(/(.*?)(.{0,8})$/);
lo = parseInt(tmp[2], 16);
hi = parseInt(tmp[1], 16) || 0;
tail[14] = lo;
tail[15] = hi;
md5cycle(state, tail);
return state;
}
function md51_array(a) {
var n = a.length, state = [ 1732584193, -271733879, -1732584194, 271733878 ], i, length, tail, tmp, lo, hi;
for (i = 64; i <= n; i += 64) {
md5cycle(state, md5blk_array(a.subarray(i - 64, i)));
}
a = i - 64 < n ? a.subarray(i - 64) : new Uint8Array(0);
length = a.length;
tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ];
for (i = 0; i < length; i += 1) {
tail[i >> 2] |= a[i] << (i % 4 << 3);
}
tail[i >> 2] |= 128 << (i % 4 << 3);
if (i > 55) {
md5cycle(state, tail);
for (i = 0; i < 16; i += 1) {
tail[i] = 0;
}
}
tmp = n * 8;
tmp = tmp.toString(16).match(/(.*?)(.{0,8})$/);
lo = parseInt(tmp[2], 16);
hi = parseInt(tmp[1], 16) || 0;
tail[14] = lo;
tail[15] = hi;
md5cycle(state, tail);
return state;
}
function rhex(n) {
var s = "", j;
for (j = 0; j < 4; j += 1) {
s += hex_chr[n >> j * 8 + 4 & 15] + hex_chr[n >> j * 8 & 15];
}
return s;
}
function hex(x) {
var i;
for (i = 0; i < x.length; i += 1) {
x[i] = rhex(x[i]);
}
return x.join("");
}
if (hex(md51("hello")) !== "5d41402abc4b2a76b9719d911017c592") ;
if (typeof ArrayBuffer !== "undefined" && !ArrayBuffer.prototype.slice) {
(function() {
function clamp(val, length) {
val = val | 0 || 0;
if (val < 0) {
return Math.max(val + length, 0);
}
return Math.min(val, length);
}
ArrayBuffer.prototype.slice = function(from, to) {
var length = this.byteLength, begin = clamp(from, length), end = length, num, target, targetArray, sourceArray;
if (to !== undefined) {
end = clamp(to, length);
}
if (begin > end) {
return new ArrayBuffer(0);
}
num = end - begin;
target = new ArrayBuffer(num);
targetArray = new Uint8Array(target);
sourceArray = new Uint8Array(this, begin, num);
targetArray.set(sourceArray);
return target;
};
})();
}
function toUtf8(str) {
if (/[\u0080-\uFFFF]/.test(str)) {
str = unescape(encodeURIComponent(str));
}
return str;
}
function utf8Str2ArrayBuffer(str, returnUInt8Array) {
var length = str.length, buff = new ArrayBuffer(length), arr = new Uint8Array(buff), i;
for (i = 0; i < length; i += 1) {
arr[i] = str.charCodeAt(i);
}
return returnUInt8Array ? arr : buff;
}
function arrayBuffer2Utf8Str(buff) {
return String.fromCharCode.apply(null, new Uint8Array(buff));
}
function concatenateArrayBuffers(first, second, returnUInt8Array) {
var result = new Uint8Array(first.byteLength + second.byteLength);
result.set(new Uint8Array(first));
result.set(new Uint8Array(second), first.byteLength);
return returnUInt8Array ? result : result.buffer;
}
function hexToBinaryString(hex) {
var bytes = [], length = hex.length, x;
for (x = 0; x < length - 1; x += 2) {
bytes.push(parseInt(hex.substr(x, 2), 16));
}
return String.fromCharCode.apply(String, bytes);
}
function SparkMD5() {
this.reset();
}
SparkMD5.prototype.append = function(str) {
this.appendBinary(toUtf8(str));
return this;
};
SparkMD5.prototype.appendBinary = function(contents) {
this._buff += contents;
this._length += contents.length;
var length = this._buff.length, i;
for (i = 64; i <= length; i += 64) {
md5cycle(this._hash, md5blk(this._buff.substring(i - 64, i)));
}
this._buff = this._buff.substring(i - 64);
return this;
};
SparkMD5.prototype.end = function(raw) {
var buff = this._buff, length = buff.length, i, tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ], ret;
for (i = 0; i < length; i += 1) {
tail[i >> 2] |= buff.charCodeAt(i) << (i % 4 << 3);
}
this._finish(tail, length);
ret = hex(this._hash);
if (raw) {
ret = hexToBinaryString(ret);
}
this.reset();
return ret;
};
SparkMD5.prototype.reset = function() {
this._buff = "";
this._length = 0;
this._hash = [ 1732584193, -271733879, -1732584194, 271733878 ];
return this;
};
SparkMD5.prototype.getState = function() {
return {
buff: this._buff,
length: this._length,
hash: this._hash
};
};
SparkMD5.prototype.setState = function(state) {
this._buff = state.buff;
this._length = state.length;
this._hash = state.hash;
return this;
};
SparkMD5.prototype.destroy = function() {
delete this._hash;
delete this._buff;
delete this._length;
};
SparkMD5.prototype._finish = function(tail, length) {
var i = length, tmp, lo, hi;
tail[i >> 2] |= 128 << (i % 4 << 3);
if (i > 55) {
md5cycle(this._hash, tail);
for (i = 0; i < 16; i += 1) {
tail[i] = 0;
}
}
tmp = this._length * 8;
tmp = tmp.toString(16).match(/(.*?)(.{0,8})$/);
lo = parseInt(tmp[2], 16);
hi = parseInt(tmp[1], 16) || 0;
tail[14] = lo;
tail[15] = hi;
md5cycle(this._hash, tail);
};
SparkMD5.hash = function(str, raw) {
return SparkMD5.hashBinary(toUtf8(str), raw);
};
SparkMD5.hashBinary = function(content, raw) {
var hash = md51(content), ret = hex(hash);
return raw ? hexToBinaryString(ret) : ret;
};
SparkMD5.ArrayBuffer = function() {
this.reset();
};
SparkMD5.ArrayBuffer.prototype.append = function(arr) {
var buff = concatenateArrayBuffers(this._buff.buffer, arr, true), length = buff.length, i;
this._length += arr.byteLength;
for (i = 64; i <= length; i += 64) {
md5cycle(this._hash, md5blk_array(buff.subarray(i - 64, i)));
}
this._buff = i - 64 < length ? new Uint8Array(buff.buffer.slice(i - 64)) : new Uint8Array(0);
return this;
};
SparkMD5.ArrayBuffer.prototype.end = function(raw) {
var buff = this._buff, length = buff.length, tail = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ], i, ret;
for (i = 0; i < length; i += 1) {
tail[i >> 2] |= buff[i] << (i % 4 << 3);
}
this._finish(tail, length);
ret = hex(this._hash);
if (raw) {
ret = hexToBinaryString(ret);
}
this.reset();
return ret;
};
SparkMD5.ArrayBuffer.prototype.reset = function() {
this._buff = new Uint8Array(0);
this._length = 0;
this._hash = [ 1732584193, -271733879, -1732584194, 271733878 ];
return this;
};
SparkMD5.ArrayBuffer.prototype.getState = function() {
var state = SparkMD5.prototype.getState.call(this);
state.buff = arrayBuffer2Utf8Str(state.buff);
return state;
};
SparkMD5.ArrayBuffer.prototype.setState = function(state) {
state.buff = utf8Str2ArrayBuffer(state.buff, true);
return SparkMD5.prototype.setState.call(this, state);
};
SparkMD5.ArrayBuffer.prototype.destroy = SparkMD5.prototype.destroy;
SparkMD5.ArrayBuffer.prototype._finish = SparkMD5.prototype._finish;
SparkMD5.ArrayBuffer.hash = function(arr, raw) {
var hash = md51_array(new Uint8Array(arr)), ret = hex(hash);
return raw ? hexToBinaryString(ret) : ret;
};
return SparkMD5;
});
});
var classCallCheck = function(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
};
var createClass = function() {
function defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
return function(Constructor, protoProps, staticProps) {
if (protoProps) defineProperties(Constructor.prototype, protoProps);
if (staticProps) defineProperties(Constructor, staticProps);
return Constructor;
};
}();
var fileSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
var FileChecksum = function() {
createClass(FileChecksum, null, [ {
key: "create",
value: function create(file, callback) {
var instance = new FileChecksum(file);
instance.create(callback);
}
} ]);
function FileChecksum(file) {
classCallCheck(this, FileChecksum);
this.file = file;
this.chunkSize = 2097152;
this.chunkCount = Math.ceil(this.file.size / this.chunkSize);
this.chunkIndex = 0;
}
createClass(FileChecksum, [ {
key: "create",
value: function create(callback) {
var _this = this;
this.callback = callback;
this.md5Buffer = new sparkMd5.ArrayBuffer();
this.fileReader = new FileReader();
this.fileReader.addEventListener("load", function(event) {
return _this.fileReaderDidLoad(event);
});
this.fileReader.addEventListener("error", function(event) {
return _this.fileReaderDidError(event);
});
this.readNextChunk();
}
}, {
key: "fileReaderDidLoad",
value: function fileReaderDidLoad(event) {
this.md5Buffer.append(event.target.result);
if (!this.readNextChunk()) {
var binaryDigest = this.md5Buffer.end(true);
var base64digest = btoa(binaryDigest);
this.callback(null, base64digest);
}
}
}, {
key: "fileReaderDidError",
value: function fileReaderDidError(event) {
this.callback("Error reading " + this.file.name);
}
}, {
key: "readNextChunk",
value: function readNextChunk() {
if (this.chunkIndex < this.chunkCount || this.chunkIndex == 0 && this.chunkCount == 0) {
var start = this.chunkIndex * this.chunkSize;
var end = Math.min(start + this.chunkSize, this.file.size);
var bytes = fileSlice.call(this.file, start, end);
this.fileReader.readAsArrayBuffer(bytes);
this.chunkIndex++;
return true;
} else {
return false;
}
}
} ]);
return FileChecksum;
}();
function getMetaValue(name) {
var element = findElement(document.head, 'meta[name="' + name + '"]');
if (element) {
return element.getAttribute("content");
}
}
function findElements(root, selector) {
if (typeof root == "string") {
selector = root;
root = document;
}
var elements = root.querySelectorAll(selector);
return toArray$1(elements);
}
function findElement(root, selector) {
if (typeof root == "string") {
selector = root;
root = document;
}
return root.querySelector(selector);
}
function dispatchEvent(element, type) {
var eventInit = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
var disabled = element.disabled;
var bubbles = eventInit.bubbles, cancelable = eventInit.cancelable, detail = eventInit.detail;
var event = document.createEvent("Event");
event.initEvent(type, bubbles || true, cancelable || true);
event.detail = detail || {};
try {
element.disabled = false;
element.dispatchEvent(event);
} finally {
element.disabled = disabled;
}
return event;
}
function toArray$1(value) {
if (Array.isArray(value)) {
return value;
} else if (Array.from) {
return Array.from(value);
} else {
return [].slice.call(value);
}
}
var BlobRecord = function() {
function BlobRecord(file, checksum, url) {
var _this = this;
classCallCheck(this, BlobRecord);
this.file = file;
this.attributes = {
filename: file.name,
content_type: file.type || "application/octet-stream",
byte_size: file.size,
checksum: checksum
};
this.xhr = new XMLHttpRequest();
this.xhr.open("POST", url, true);
this.xhr.responseType = "json";
this.xhr.setRequestHeader("Content-Type", "application/json");
this.xhr.setRequestHeader("Accept", "application/json");
this.xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
var csrfToken = getMetaValue("csrf-token");
if (csrfToken != undefined) {
this.xhr.setRequestHeader("X-CSRF-Token", csrfToken);
}
this.xhr.addEventListener("load", function(event) {
return _this.requestDidLoad(event);
});
this.xhr.addEventListener("error", function(event) {
return _this.requestDidError(event);
});
}
createClass(BlobRecord, [ {
key: "create",
value: function create(callback) {
this.callback = callback;
this.xhr.send(JSON.stringify({
blob: this.attributes
}));
}
}, {
key: "requestDidLoad",
value: function requestDidLoad(event) {
if (this.status >= 200 && this.status < 300) {
var response = this.response;
var direct_upload = response.direct_upload;
delete response.direct_upload;
this.attributes = response;
this.directUploadData = direct_upload;
this.callback(null, this.toJSON());
} else {
this.requestDidError(event);
}
}
}, {
key: "requestDidError",
value: function requestDidError(event) {
this.callback('Error creating Blob for "' + this.file.name + '". Status: ' + this.status);
}
}, {
key: "toJSON",
value: function toJSON() {
var result = {};
for (var key in this.attributes) {
result[key] = this.attributes[key];
}
return result;
}
}, {
key: "status",
get: function get$$1() {
return this.xhr.status;
}
}, {
key: "response",
get: function get$$1() {
var _xhr = this.xhr, responseType = _xhr.responseType, response = _xhr.response;
if (responseType == "json") {
return response;
} else {
return JSON.parse(response);
}
}
} ]);
return BlobRecord;
}();
var BlobUpload = function() {
function BlobUpload(blob) {
var _this = this;
classCallCheck(this, BlobUpload);
this.blob = blob;
this.file = blob.file;
var _blob$directUploadDat = blob.directUploadData, url = _blob$directUploadDat.url, headers = _blob$directUploadDat.headers;
this.xhr = new XMLHttpRequest();
this.xhr.open("PUT", url, true);
this.xhr.responseType = "text";
for (var key in headers) {
this.xhr.setRequestHeader(key, headers[key]);
}
this.xhr.addEventListener("load", function(event) {
return _this.requestDidLoad(event);
});
this.xhr.addEventListener("error", function(event) {
return _this.requestDidError(event);
});
}
createClass(BlobUpload, [ {
key: "create",
value: function create(callback) {
this.callback = callback;
this.xhr.send(this.file.slice());
}
}, {
key: "requestDidLoad",
value: function requestDidLoad(event) {
var _xhr = this.xhr, status = _xhr.status, response = _xhr.response;
if (status >= 200 && status < 300) {
this.callback(null, response);
} else {
this.requestDidError(event);
}
}
}, {
key: "requestDidError",
value: function requestDidError(event) {
this.callback('Error storing "' + this.file.name + '". Status: ' + this.xhr.status);
}
} ]);
return BlobUpload;
}();
var id = 0;
var DirectUpload = function() {
function DirectUpload(file, url, delegate) {
classCallCheck(this, DirectUpload);
this.id = ++id;
this.file = file;
this.url = url;
this.delegate = delegate;
}
createClass(DirectUpload, [ {
key: "create",
value: function create(callback) {
var _this = this;
FileChecksum.create(this.file, function(error, checksum) {
if (error) {
callback(error);
return;
}
var blob = new BlobRecord(_this.file, checksum, _this.url);
notify(_this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr);
blob.create(function(error) {
if (error) {
callback(error);
} else {
var upload = new BlobUpload(blob);
notify(_this.delegate, "directUploadWillStoreFileWithXHR", upload.xhr);
upload.create(function(error) {
if (error) {
callback(error);
} else {
callback(null, blob.toJSON());
}
});
}
});
});
}
} ]);
return DirectUpload;
}();
function notify(object, methodName) {
if (object && typeof object[methodName] == "function") {
for (var _len = arguments.length, messages = Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) {
messages[_key - 2] = arguments[_key];
}
return object[methodName].apply(object, messages);
}
}
var DirectUploadController = function() {
function DirectUploadController(input, file) {
classCallCheck(this, DirectUploadController);
this.input = input;
this.file = file;
this.directUpload = new DirectUpload(this.file, this.url, this);
this.dispatch("initialize");
}
createClass(DirectUploadController, [ {
key: "start",
value: function start(callback) {
var _this = this;
var hiddenInput = document.createElement("input");
hiddenInput.type = "hidden";
hiddenInput.name = this.input.name;
this.input.insertAdjacentElement("beforebegin", hiddenInput);
this.dispatch("start");
this.directUpload.create(function(error, attributes) {
if (error) {
hiddenInput.parentNode.removeChild(hiddenInput);
_this.dispatchError(error);
} else {
hiddenInput.value = attributes.signed_id;
}
_this.dispatch("end");
callback(error);
});
}
}, {
key: "uploadRequestDidProgress",
value: function uploadRequestDidProgress(event) {
var progress = event.loaded / event.total * 100;
if (progress) {
this.dispatch("progress", {
progress: progress
});
}
}
}, {
key: "dispatch",
value: function dispatch(name) {
var detail = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
detail.file = this.file;
detail.id = this.directUpload.id;
return dispatchEvent(this.input, "direct-upload:" + name, {
detail: detail
});
}
}, {
key: "dispatchError",
value: function dispatchError(error) {
var event = this.dispatch("error", {
error: error
});
if (!event.defaultPrevented) {
alert(error);
}
}
}, {
key: "directUploadWillCreateBlobWithXHR",
value: function directUploadWillCreateBlobWithXHR(xhr) {
this.dispatch("before-blob-request", {
xhr: xhr
});
}
}, {
key: "directUploadWillStoreFileWithXHR",
value: function directUploadWillStoreFileWithXHR(xhr) {
var _this2 = this;
this.dispatch("before-storage-request", {
xhr: xhr
});
xhr.upload.addEventListener("progress", function(event) {
return _this2.uploadRequestDidProgress(event);
});
}
}, {
key: "url",
get: function get$$1() {
return this.input.getAttribute("data-direct-upload-url");
}
} ]);
return DirectUploadController;
}();
var inputSelector = "input[type=file][data-direct-upload-url]:not([disabled])";
var DirectUploadsController = function() {
function DirectUploadsController(form) {
classCallCheck(this, DirectUploadsController);
this.form = form;
this.inputs = findElements(form, inputSelector).filter(function(input) {
return input.files.length;
});
}
createClass(DirectUploadsController, [ {
key: "start",
value: function start(callback) {
var _this = this;
var controllers = this.createDirectUploadControllers();
var startNextController = function startNextController() {
var controller = controllers.shift();
if (controller) {
controller.start(function(error) {
if (error) {
callback(error);
_this.dispatch("end");
} else {
startNextController();
}
});
} else {
callback();
_this.dispatch("end");
}
};
this.dispatch("start");
startNextController();
}
}, {
key: "createDirectUploadControllers",
value: function createDirectUploadControllers() {
var controllers = [];
this.inputs.forEach(function(input) {
toArray$1(input.files).forEach(function(file) {
var controller = new DirectUploadController(input, file);
controllers.push(controller);
});
});
return controllers;
}
}, {
key: "dispatch",
value: function dispatch(name) {
var detail = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
return dispatchEvent(this.form, "direct-uploads:" + name, {
detail: detail
});
}
} ]);
return DirectUploadsController;
}();
var processingAttribute = "data-direct-uploads-processing";
var submitButtonsByForm = new WeakMap();
var started = false;
function start() {
if (!started) {
started = true;
document.addEventListener("click", didClick, true);
document.addEventListener("submit", didSubmitForm);
document.addEventListener("ajax:before", didSubmitRemoteElement);
}
}
function didClick(event) {
var target = event.target;
if ((target.tagName == "INPUT" || target.tagName == "BUTTON") && target.type == "submit" && target.form) {
submitButtonsByForm.set(target.form, target);
}
}
function didSubmitForm(event) {
handleFormSubmissionEvent(event);
}
function didSubmitRemoteElement(event) {
if (event.target.tagName == "FORM") {
handleFormSubmissionEvent(event);
}
}
function handleFormSubmissionEvent(event) {
var form = event.target;
if (form.hasAttribute(processingAttribute)) {
event.preventDefault();
return;
}
var controller = new DirectUploadsController(form);
var inputs = controller.inputs;
if (inputs.length) {
event.preventDefault();
form.setAttribute(processingAttribute, "");
inputs.forEach(disable);
controller.start(function(error) {
form.removeAttribute(processingAttribute);
if (error) {
inputs.forEach(enable);
} else {
submitForm(form);
}
});
}
}
function submitForm(form) {
var button = submitButtonsByForm.get(form) || findElement(form, "input[type=submit], button[type=submit]");
if (button) {
var _button = button, disabled = _button.disabled;
button.disabled = false;
button.focus();
button.click();
button.disabled = disabled;
} else {
button = document.createElement("input");
button.type = "submit";
button.style.display = "none";
form.appendChild(button);
button.click();
form.removeChild(button);
}
submitButtonsByForm.delete(form);
}
function disable(input) {
input.disabled = true;
}
function enable(input) {
input.disabled = false;
}
function autostart() {
if (window.ActiveStorage) {
start();
}
}
setTimeout(autostart, 1);
exports.start = start;
exports.DirectUpload = DirectUpload;
Object.defineProperty(exports, "__esModule", {
value: true
});
});

View file

@ -1,38 +0,0 @@
//
// Provides a drop-in pointer for the default Trix stylesheet that will
// format the toolbar and the trix-editor content (whether displayed or
// under editing). Feel free to incorporate this inclusion directly in
// any other asset bundle and remove this file.
//
//= require trix/dist/trix
// We need to override trix.csss image gallery styles to accommodate
// the <action-text-attachment> element we wrap around attachments.
// Otherwise, images in galleries will be squished by the max-width:
// 33%; rule.
.trix-content {
.attachment-gallery {
> action-text-attachment,
> .attachment {
flex: 1 0 33%;
padding: 0 0.5em;
max-width: 33%;
}
&.attachment-gallery--2,
&.attachment-gallery--4 {
> action-text-attachment,
> .attachment {
flex-basis: 50%;
max-width: 50%;
}
}
}
action-text-attachment {
.attachment {
padding: 0 !important;
max-width: 100% !important;
}
}
}

View file

@ -1,11 +1,16 @@
//= require_tree .
$black: black;
$white: white;
$grey: grey;
$cyan: #13fefe;
$magenta: #f206f9;
$colors: (
"black": $black,
"white": $white,
"cyan": $cyan,
"magenta": $magenta
);
// Redefinir variables de Bootstrap
$primary: $magenta;
$jumbotron-bg: transparent;
@ -16,6 +21,15 @@ $form-feedback-icon-valid-color: $black;
$component-active-bg: $magenta;
@import "bootstrap";
@import "editor";
.editor {
.editor-content {
figure {
border: 1px solid transparentize($magenta, 0.3)
}
}
}
:root {
--foreground: #{$black};
@ -29,18 +43,6 @@ $component-active-bg: $magenta;
--background: #{$black};
--color: #{$cyan};
}
trix-toolbar {
.trix-button--icon {
background-color: var(--color);
}
}
}
trix-toolbar {
background-color: var(--background);
position: sticky;
top: 0;
}
// TODO: Encontrar la forma de generar esto desde los locales de Rails
@ -298,3 +300,191 @@ svg {
.vh-100 {
height: 100vh !important;
}
// Viene de sutty-base-jekyll-theme
$prefixes: ("", "-webkit-", "-ms-", "-o-", "-moz-");
$overflows: auto, hidden, scroll;
/*
* Usar en animaciones, empiezan rápido y desaceleran hacia el final.
*/
$bezier: cubic-bezier(0.75, 0, 0.25, 1);
/*
* Ocultar la barra de scroll, útil para sliders horizontales.
*/
.no-scrollbar {
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar { display: none; }
}
@each $cursor in (pointer none) {
.cursor-#{$cursor} {
cursor: $cursor;
}
}
@each $direction in (top, right, bottom, left) {
.#{$direction}-0 {
#{$direction}: 0
}
}
@each $value in $overflows {
.overflow-#{$value} { overflow: $value !important; }
}
@each $axis in (y, x) {
@each $value in $overflows {
.overflow-#{$axis}-#{$value} { overflow-#{$axis}: $value !important; }
}
}
/*
* Poder aumentar o disminuir el alto de la tipografía, se usa de la
* misma forma que los modificadores de padding y margin.
*/
@each $size, $length in $spacers {
.f-#{$size} {
font-size: $length !important;
}
.text-column-#{$size} {
column-count: $size;
}
}
/*
* Modificadores de Bootstrap que no tienen versión responsive.
*/
@each $grid-breakpoint, $_ in $grid-breakpoints {
@include media-breakpoint-up($grid-breakpoint) {
// border
.border-#{$grid-breakpoint} { border: $border-width solid $border-color !important; }
.border-#{$grid-breakpoint}-top { border-top: $border-width solid $border-color !important; }
.border-#{$grid-breakpoint}-right { border-right: $border-width solid $border-color !important; }
.border-#{$grid-breakpoint}-bottom { border-bottom: $border-width solid $border-color !important; }
.border-#{$grid-breakpoint}-left { border-left: $border-width solid $border-color !important; }
.border-#{$grid-breakpoint}-0 { border: 0 !important; }
.border-#{$grid-breakpoint}-top-0 { border-top: 0 !important; }
.border-#{$grid-breakpoint}-right-0 { border-right: 0 !important; }
.border-#{$grid-breakpoint}-bottom-0 { border-bottom: 0 !important; }
.border-#{$grid-breakpoint}-left-0 { border-left: 0 !important; }
// alineación
.text-#{$grid-breakpoint}-left { text-align: left !important; }
.text-#{$grid-breakpoint}-right { text-align: right !important; }
.text-#{$grid-breakpoint}-center { text-align: center !important; }
// posición
@each $position in $positions {
.position-#{$grid-breakpoint}-#{$position} { position: $position !important; }
}
// anchos y altos
@each $prop, $abbrev in (width: w, height: h) {
@each $size, $length in $sizes {
.#{$abbrev}-#{$grid-breakpoint}-#{$size} { #{$prop}: $length !important; }
}
}
// versión responsive de f
@each $size, $length in $spacers {
.f-#{$grid-breakpoint}-#{$size} {
font-size: $length !important;
}
.text-column-#{$grid-breakpoint}-#{$size} {
column-count: $size;
}
}
}
}
/*
* Crea una propiedad con prefijos de navegador
*/
@mixin vendor-prefix($property, $definition...) {
@each $prefix in $prefixes {
#{$prefix}$property: $definition;
}
}
/*
* Crea clases para asignar colores según la lista de colores.
*/
@each $color, $_ in $colors {
.background-#{$color} {
background-color: var(--#{$color});
&:focus {
background-color: var(--#{$color});
}
}
.scrollbar-#{$color} {
scrollbar-color: var(--#{$color}) transparent;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 5px;
height: 8px;
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--#{$color});
}
}
.border-#{$color} {
border-color: var(--#{$color}) !important;
}
.hover-bg-#{$color} {
&:hover {
background-color: var(--#{$color});
}
}
.hover-#{$color} {
&:hover {
color: var(--#{$color});
}
}
.#{$color} {
color: var(--#{$color});
&:focus {
color: var(--#{$color});
}
::-moz-selection,
::selection {
background: var(--#{$color});
color: white;
}
svg {
* {
fill: var(--#{$color});
}
}
.form-control {
border-color: var(--#{$color});
color: var(--#{$color});
}
hr {
border-color: var(--#{$color});
}
a {
color: var(--#{$color});
}
}
}

View file

@ -0,0 +1,67 @@
.editor {
box-sizing: border-box;
*, *::before, *::after { box-sizing: inherit; }
h1, h2, h3, h4, h5, h6, p, li {
min-height: 1.5rem;
}
mark {
background: #f206f9;
padding: 0;
}
.selected { outline: #f206f9 solid medium; }
iframe {
border: 0;
min-height: 480px;
}
figure {
padding: .5rem;
}
img, video, iframe, audio {
width: 100%;
max-width: 600px;
display: block;
margin: 0 auto;
}
.editor-toolbar {
position: sticky;
top: 0px;
background: white;
}
.editor-primary-toolbar, .editor-auxiliary-toolbar {
display: block;
overflow-x: auto;
white-space: nowrap;
}
.editor-auxiliary-toolbar {
& > * {
display: none;
}
.editor-auxiliary-tool-active {
display: block;
}
}
word-wrap: break-word;
div[data-align="left"] { text-align: left; }
div[data-align="center"] { text-align: center; }
div[data-align="right"] { text-align: right; }
min-height: 480px;
*[data-editor-loading] {
opacity: 0.5;
}
*[data-editor-error] {
filter: grayscale(100%);
}
}

View file

@ -15,9 +15,6 @@
// const images = require.context('../images', true)
// const imagePath = (name) => images(name, true)
require("trix")
require("@rails/actiontext")
import { Application } from 'stimulus'
import { definitionsFromContext } from "stimulus/webpack-helpers"

View file

@ -14,4 +14,44 @@ class MetadataContent < MetadataTemplate
def front_matter?
false
end
private
# Limpiar el HTML que recibimos
#
# TODO: En lugar de comprobar el Content Type acá, restringir los
# tipos de archivo a aceptar en ActiveStorage.
def sanitize(html_string)
html = Nokogiri::HTML.fragment(super html_string)
elements = 'img,audio,video,iframe'
# Eliminar elementos sin src y comprobar su origen
html.css(elements).each do |element|
unless element['src']
element.remove
next
end
begin
uri = URI element['src']
# No permitimos recursos externos
element.remove unless uri.hostname.end_with? Site.domain
rescue URI::Error
element.remove
end
end
# Eliminar figure sin contenido
html.css('figure').each do |figure|
figure.remove if figure.css(elements).empty?
end
# Los videos y audios necesitan controles
html.css('audio,video').each do |resource|
resource['controls'] = true
end
html.to_s.html_safe
end
end

View file

@ -15,7 +15,6 @@ class MetadataString < MetadataTemplate
sanitizer.sanitize(string.strip,
tags: [],
attributes: [],
scrubber: scrubber).strip.html_safe
attributes: []).strip.html_safe
end
end

View file

@ -7,8 +7,6 @@
MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
:value, :help, :required, :errors, :post,
:layout, keyword_init: true) do
include ActionText::ContentHelper
attr_reader :value_was
# Queremos que los artículos nuevos siempre cacheen, si usamos el UUID
@ -144,8 +142,19 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
sanitizer.sanitize(string.tr("\r", ''),
tags: allowed_tags,
attributes: allowed_attributes + %w[data-trix-attachment],
scrubber: scrubber).strip.html_safe
attributes: allowed_attributes).strip.html_safe
end
def sanitizer
@sanitizer ||= Rails::Html::Sanitizer.safe_list_sanitizer.new
end
def allowed_attributes
@allowed_attributes ||= %w[style href src alt controls data-align].freeze
end
def allowed_tags
@allowed_tags ||= %w[strong em del u mark p h1 h2 h3 h4 h5 h6 ul ol li img iframe audio video div figure figcaption].freeze
end
# Decifra el valor

View file

@ -1,74 +0,0 @@
- if request.referer
= render 'layouts/breadcrumb', crumbs: [ link_to(t('help.markdown.back'), request.referer) ]
.row
.col
%table.table.table-responsive-md
%thead
%tr
%th= t('help.markdown.input')
%th= t('help.markdown.output')
%tbody
%tr
%td
%code= "**#{t('help.markdown.bold')}**"
%td
%strong= t('help.markdown.bold')
%tr
%td
%code= "_#{t('help.markdown.italic')}_"
%td
%em= t('help.markdown.italic')
- 7.times do |i|
- next if i == 0
%tr
%td
%code= "#{'#' * i} #{t('help.markdown.heading')} #{i}"
%td= raw "<h#{i}>#{t('help.markdown.heading')} #{i}</h#{i}>"
%tr
%td
%code= "[#{t('help.markdown.link.text')}](#{t('help.markdown.link.url')})"
%td= link_to t('help.markdown.link.text'), t('help.markdown.link.url')
%tr
%td
%code= "> #{t('help.markdown.quote')}"
%td
%blockquote.blockquote= t('help.markdown.quote')
%tr
%td
%code
- 3.times do
= "* #{t('help.markdown.ul')}"
%br
%td
%ul
- 3.times do
%li= t('help.markdown.ul')
%tr
%td
%code
- 3.times do |i|
= "#{i}. #{t('help.markdown.ol')}"
%br
%td
%ol
- 3.times do
%li= t('help.markdown.ol')
%tr
%td{colspan: 2}= t('help.markdown.dir')
%tr
%td
%code= "[#{t('help.markdown.ltr')}]{dir=ltr lang=es}"
%td
%span{dir: 'ltr', lang: 'es'}= t('help.markdown.ltr')
%tr
%td
%code= "[#{t('help.markdown.rtl')}]{dir=rtl lang=es}"
%td
%span{dir: 'rtl', lang: 'es'}= t('help.markdown.rtl')
%tr
%td
%code= "![#{t('help.markdown.img.text')}](#{t('help.markdown.img.url')})"
%td
%img{alt: t('help.markdown.img.text'), src: t('help.markdown.img.url')}

View file

@ -2,6 +2,72 @@
= label_tag "post_#{attribute}", post_label_t(attribute, post: post)
= render 'posts/attribute_feedback',
post: post, attribute: attribute, metadata: metadata
= rich_text_area_tag "post[#{attribute}]", metadata.value,
**field_options(attribute, metadata), class: '', dir: dir,
lang: locale
.editor
= text_area_tag "post[#{attribute}]", '',
dir: dir, lang: locale,
**field_options(attribute, metadata), class: 'd-none'
.editor-toolbar
.editor-primary-toolbar.scrollbar-black
%button.btn{ data: { button: 'bold' } }= t('editor.bold')
%button.btn{ data: { button: 'italic' } }= t('editor.italic')
%button.btn{ data: { button: 'deleted' } }= t('editor.deleted')
%button.btn{ data: { button: 'underline' } }= t('editor.underline')
%button.btn{ data: { button: 'mark' } }= t('editor.mark')
%button.btn{ data: { button: 'h1' } }= t('editor.h1')
%button.btn{ data: { button: 'h2' } }= t('editor.h2')
%button.btn{ data: { button: 'h3' } }= t('editor.h3')
%button.btn{ data: { button: 'h4' } }= t('editor.h4')
%button.btn{ data: { button: 'h5' } }= t('editor.h5')
%button.btn{ data: { button: 'h6' } }= t('editor.h6')
%button.btn{ data: { button: 'ul' } }= t('editor.ul')
%button.btn{ data: { button: 'ol' } }= t('editor.ol')
%button.btn{ data: { button: 'left' } }= t('editor.left')
%button.btn{ data: { button: 'center' } }= t('editor.center')
%button.btn{ data: { button: 'right' } }= t('editor.right')
%button.btn{ data: { button: 'img' } }= t('editor.img')
%button.btn{ data: { button: 'video' } }= t('editor.video')
%button.btn{ data: { button: 'audio' } }= t('editor.audio')
%button.btn{ data: { button: 'pdf' } }= t('editor.pdf')
-#
HAML cringe
TODO: generar IDs para labels
.editor-auxiliary-toolbar.mt-1.scrollbar-black{ data: { editor: { auxiliary: { toolbar: '' } } } }
.form-group{ data: { editor: { auxiliary: 'mark' } } }
%label{ for: 'mark-color' }= t('editor.color')
%input.form-control{ type: 'color', data: { prop: 'mark-color' } }/
%div{ data: { editor: { auxiliary: 'img' } } }
.row
.col.form-group.d-flex.align-items-end
.custom-file
%input.custom-file-input{ type: 'file', data: { prop: 'img-file' }, accept: 'image/*' }/
%label.custom-file-label{ for: 'img-file' }= t('editor.file.img')
.col.form-group
%label{ for: 'img-alt' }= t('editor.description')
%input.form-control{ type: 'text', data: { prop: 'img-alt' } }/
-# https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers
.form-group{ data: { editor: { auxiliary: 'audio' } } }
.custom-file
%input.custom-file-input{ type: 'file', data: { prop: 'audio-file' }, accept: 'audio/*' }/
%label.custom-file-label{ for: 'audio-file' }= t('editor.file.audio')
.form-group{ data: { editor: { auxiliary: 'video' } } }
.custom-file
%input.custom-file-input{ type: 'file', data: { prop: 'video-file' }, accept: 'video/*' }/
%label.custom-file-label{ for: 'video-file' }= t('editor.file.video')
.form-group{ data: { editor: { auxiliary: 'pdf' } } }
.custom-file
%input.custom-file-input{ type: 'file', data: { prop: 'pdf-file' }, accept: 'application/pdf' }/
%label.custom-file-label{ for: 'pdf-file' }= t('editor.file.pdf')
.form-group{ data: { editor: { auxiliary: 'link' } } }
%label{ for: 'link-href' }= t('editor.url')
%input.form-control{ type: 'url', data: { prop: 'link-href' } }/
.editor-content.form-control.h-auto.mt-1{ contenteditable: 'true' }
= metadata.value.html_safe

View file

@ -1,8 +1,72 @@
.form-group.markdown-content
.form-group
= label_tag "post_#{attribute}", post_label_t(attribute, post: post)
= render 'posts/attribute_feedback',
post: post, attribute: attribute, metadata: metadata
.editor
= text_area_tag "post[#{attribute}]", metadata.value,
dir: dir, lang: locale,
**field_options(attribute, metadata, class: 'content')
.editor.mt-1
**field_options(attribute, metadata), class: 'd-none'
.editor-toolbar
.editor-primary-toolbar.scrollbar-black
%button.btn{ data: { button: 'bold' } }= t('editor.bold')
%button.btn{ data: { button: 'italic' } }= t('editor.italic')
%button.btn{ data: { button: 'deleted' } }= t('editor.deleted')
%button.btn{ data: { button: 'underline' } }= t('editor.underline')
%button.btn{ data: { button: 'mark' } }= t('editor.mark')
%button.btn{ data: { button: 'h1' } }= t('editor.h1')
%button.btn{ data: { button: 'h2' } }= t('editor.h2')
%button.btn{ data: { button: 'h3' } }= t('editor.h3')
%button.btn{ data: { button: 'h4' } }= t('editor.h4')
%button.btn{ data: { button: 'h5' } }= t('editor.h5')
%button.btn{ data: { button: 'h6' } }= t('editor.h6')
%button.btn{ data: { button: 'ul' } }= t('editor.ul')
%button.btn{ data: { button: 'ol' } }= t('editor.ol')
%button.btn{ data: { button: 'left' } }= t('editor.left')
%button.btn{ data: { button: 'center' } }= t('editor.center')
%button.btn{ data: { button: 'right' } }= t('editor.right')
%button.btn{ data: { button: 'img' } }= t('editor.img')
%button.btn{ data: { button: 'video' } }= t('editor.video')
%button.btn{ data: { button: 'audio' } }= t('editor.audio')
%button.btn{ data: { button: 'pdf' } }= t('editor.pdf')
-#
HAML cringe
TODO: generar IDs para labels
.editor-auxiliary-toolbar.scrollbar-black{ data: { editor: { auxiliary: { toolbar: '' } } } }
.form-group{ data: { editor: { auxiliary: 'mark' } } }
%label{ for: 'mark-color' }= t('editor.color')
%input{ type: 'color', data: { prop: 'mark-color' } }/
%div{ data: { editor: { auxiliary: 'img' } } }
.row
.col.form-group.d-flex.align-items-end
.custom-file
%input.custom-file-input{ type: 'file', data: { prop: 'img-file' }, accept: 'image/*' }/
%label.custom-file-label{ for: 'img-file' }= t('editor.file.img')
.col.form-group
%label{ for: 'img-alt' }= t('editor.description')
%input.form-control{ type: 'text', data: { prop: 'img-alt' } }/
-# https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers
.form-group{ data: { editor: { auxiliary: 'audio' } } }
.custom-file
%input.custom-file-input{ type: 'file', data: { prop: 'audio-file' }, accept: 'audio/flac,audio/mp4,audio/ogg,audio/webm,audio/mp3' }/
%label.custom-file-label{ for: 'audio-file' }= t('editor.file.audio')
.form-group{ data: { editor: { auxiliary: 'video' } } }
.custom-file
%input.custom-file-input{ type: 'file', data: { prop: 'video-file' }, accept: 'video/mp4,video/ogg,video/webm' }/
%label.custom-file-label{ for: 'video-file' }= t('editor.file.video')
.form-group{ data: { editor: { auxiliary: 'pdf' } } }
.custom-file
%input.custom-file-input{ type: 'file', data: { prop: 'pdf-file' }, accept: 'application/pdf' }/
%label.custom-file-label{ for: 'pdf-file' }= t('editor.file.pdf')
.form-group{ data: { editor: { auxiliary: 'link' } } }
%label{ for: 'link-href' }= t('editor.url')
%input.form-control{ type: 'url', data: { prop: 'link-href' } }/
.editor-content.form-control.h-auto{ contenteditable: 'true' }

View file

@ -41,5 +41,5 @@
- next if metadata.front_matter?
- cache metadata do
%section{ id: attr, dir: dir }
%section.editor{ id: attr, dir: dir }
= @post.public_send(attr).to_s.html_safe

View file

@ -14,7 +14,7 @@ Rails.application.config.content_security_policy do |policy|
policy.script_src :self
policy.font_src :self, :https
# XXX: Los íconos de Trix se cargan vía data:
policy.img_src :self, :data, :https
policy.img_src :self, :data, :https, :blob
# Ya no usamos applets!
policy.object_src :none
if Rails.env.development?

View file

@ -553,3 +553,33 @@ en:
title: Encrypted content
description: The field contents are encrypted before being stored and won't be available on the public website or its source code. You can save private information here and it will only be readable to this site's users through Sutty's panel.
decryption_error: There was an error trying to decrypt the content, Sutty's team has been notified!
editor:
bold: Bold
italic: Emphasis
deleted: Strikethrough
underline: Underline
mark: Mark
h1: Heading 1
h2: Heading 2
h3: Heading 3
h4: Heading 4
h5: Heading 5
h6: Heading 6
ul: Unordered list
ol: Ordered list
left: Left
right: Right
center: Center
img: Image
video: Video
audio: Audio
pdf: PDF
color: Color
img: Image
file:
img: Select and upload image
video: Select and upload video
audio: Select and upload audio
pdf: Select and upload PDF
description: Description for blind people and search engines
url: Address

View file

@ -572,3 +572,33 @@ es:
title: Contenido cifrado
description: El contenido de este campo se guarda cifrado y no estará disponible en el sitio ni en su código fuente. Puedes guardar información privada aquí y sólo estará disponible para quienes tengan acceso a ese sitio en el panel de Sutty.
decryption_error: Hubo un error al decifrar la información, ¡el equipo de Sutty ya fue notificado!
editor:
bold: Fuerte
italic: Énfasis
deleted: Tachado
underline: Subrayado
mark: Resaltado
h1: Título 1
h2: Título 2
h3: Título 3
h4: Título 4
h5: Título 5
h6: Título 6
ul: Lista itemizada
ol: Lista numerada
left: Izquierda
right: Derecha
center: Centro
img: Imágen
video: Video
audio: Audio
pdf: PDF
color: Color
img: Imágen
file:
img: Seleccionar y subir imágen
video: Seleccionar y subir video
audio: Seleccionar y subir audio
pdf: Seleccionar y subir archivo PDF
description: Descripción para personas no videntes y buscadores
url: Dirección

View file

@ -13,7 +13,6 @@
"prosemirror-schema-basic": "^1.1.2",
"stimulus": "^1.1.1",
"table-dragger": "git+https://0xacab.org/sutty/table-dragger.git",
"trix": "git+https://0xacab.org/sutty/trix.git",
"zepto": "^1.2.0"
},
"devDependencies": {

BIN
public/placeholder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -6990,10 +6990,6 @@ trim-right@^1.0.1:
resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=
"trix@git+https://0xacab.org/sutty/trix.git":
version "1.2.0"
resolved "git+https://0xacab.org/sutty/trix.git#0a53f75dbef0d650acbd73344f7d50ac4aedbe16"
"true-case-path@^1.0.2":
version "1.0.3"
resolved "https://registry.yarnpkg.com/true-case-path/-/true-case-path-1.0.3.tgz#f813b5a8c86b40da59606722b144e3225799f47d"