diff --git a/app/assets/javascripts/01-types.js b/app/assets/javascripts/01-types.js index 260f8072..9b0abd79 100644 --- a/app/assets/javascripts/01-types.js +++ b/app/assets/javascripts/01-types.js @@ -88,11 +88,25 @@ const blocks = { return el }, }, + figure: { + selector: "FIGURE", + noButton: true + }, + figcaption: { + selector: "FIGCAPTION", + noButton: true, + }, audio: { selector: "AUDIO", createFn: editorEl => { - const el = document.createElement("AUDIO") - el.controls = true + 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 }, }, @@ -100,7 +114,13 @@ const blocks = { selector: "VIDEO", createFn: editorEl => { const el = document.createElement("VIDEO") - el.controls = true + 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 }, }, @@ -108,7 +128,13 @@ const blocks = { pdf: { selector: "IFRAME", createFn: editorEl => { - const el = document.createElement("IFRAME") + 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 }, }, @@ -223,6 +249,21 @@ const typesWithProperties = { }, 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) { @@ -239,11 +280,13 @@ const typesWithProperties = { setupInput (editorEl, contentEl) { const audioFileEl = editorEl.querySelector(`*[data-prop="audio-file"]`) audioFileEl.addEventListener("input", event => { - const audioEl = getSelected(contentEl) - if (!audioEl) return + 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) @@ -283,6 +326,7 @@ const typesWithProperties = { const file = videoFileEl.files[0] + videoEl.poster = "" videoEl.src = URL.createObjectURL(file) videoEl.dataset.editorLoading = true uploadFile(file) @@ -317,10 +361,11 @@ const typesWithProperties = { setupInput (editorEl, contentEl) { const pdfFileEl = editorEl.querySelector(`*[data-prop="pdf-file"]`) pdfFileEl.addEventListener("input", event => { - const pdfEl = getSelected(contentEl) - if (!pdfEl) return + 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 diff --git a/app/assets/javascripts/02-editor.js b/app/assets/javascripts/02-editor.js index 87d47758..ec4cf34b 100644 --- a/app/assets/javascripts/02-editor.js +++ b/app/assets/javascripts/02-editor.js @@ -471,6 +471,12 @@ function setupEditor (editorEl) { 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) @@ -483,6 +489,8 @@ function stringifyAllowedStyle (element) { document.addEventListener("turbolinks:load", () => { for (const editorEl of document.querySelectorAll(".editor")) { + if (!editorEl.querySelector('.editor-toolbar')) continue + setupEditor(editorEl) } }) diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 537f57a3..a3fe5eec 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -23,6 +23,14 @@ $component-active-bg: $magenta; @import "bootstrap"; @import "editor"; +.editor { + .editor-content { + figure { + border: 1px solid transparentize($magenta, 0.3) + } + } +} + :root { --foreground: #{$black}; --background: #{$white}; diff --git a/app/assets/stylesheets/editor.scss b/app/assets/stylesheets/editor.scss index e5eced70..4b89cf1a 100644 --- a/app/assets/stylesheets/editor.scss +++ b/app/assets/stylesheets/editor.scss @@ -14,9 +14,14 @@ .selected { outline: #f206f9 solid medium; } iframe { + border: 0; min-height: 480px; } + figure { + padding: .5rem; + } + img, video, iframe, audio { width: 100%; max-width: 600px; @@ -45,12 +50,12 @@ } } - .editor-content { - 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; } - } + 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; diff --git a/app/models/metadata_content.rb b/app/models/metadata_content.rb index a14e19f7..4105ffcc 100644 --- a/app/models/metadata_content.rb +++ b/app/models/metadata_content.rb @@ -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 diff --git a/app/models/metadata_template.rb b/app/models/metadata_template.rb index a85b84a5..1a8f93e4 100644 --- a/app/models/metadata_template.rb +++ b/app/models/metadata_template.rb @@ -7,7 +7,6 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, :value, :help, :required, :errors, :post, :layout, keyword_init: true) do - attr_reader :value_was def value=(new_value) @@ -133,7 +132,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, 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].freeze + @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 diff --git a/app/views/posts/attributes/_content.haml b/app/views/posts/attributes/_content.haml index 1e6fd414..b4d1ad2a 100644 --- a/app/views/posts/attributes/_content.haml +++ b/app/views/posts/attributes/_content.haml @@ -34,10 +34,10 @@ -# HAML cringe TODO: generar IDs para labels - .editor-auxiliary-toolbar.scrollbar-black{ data: { editor: { auxiliary: { toolbar: '' } } } } + .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{ type: 'color', data: { prop: 'mark-color' } }/ + %input.form-control{ type: 'color', data: { prop: 'mark-color' } }/ %div{ data: { editor: { auxiliary: 'img' } } } .row @@ -52,12 +52,12 @@ -# 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' }/ + %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/mp4,video/ogg,video/webm' }/ + %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' } } } @@ -69,5 +69,5 @@ %label{ for: 'link-href' }= t('editor.url') %input.form-control{ type: 'url', data: { prop: 'link-href' } }/ - .editor-content.form-control.h-auto{ contenteditable: 'true' } + .editor-content.form-control.h-auto.mt-1{ contenteditable: 'true' } = metadata.value.html_safe diff --git a/app/views/posts/show.haml b/app/views/posts/show.haml index 405b9c7e..9b4fb8a0 100644 --- a/app/views/posts/show.haml +++ b/app/views/posts/show.haml @@ -33,5 +33,5 @@ - @post.attributes.each do |attr| - next if @post.send(attr).front_matter? - %section{ id: attr, dir: dir } + %section.editor{ id: attr, dir: dir } = @post.send(attr).to_s.html_safe