diff --git a/app/assets/stylesheets/editor.scss b/app/assets/stylesheets/editor.scss
index 88c749f2..c7b50db4 100644
--- a/app/assets/stylesheets/editor.scss
+++ b/app/assets/stylesheets/editor.scss
@@ -60,7 +60,7 @@
.editor-content {
min-height: 480px;
- p, h1, h2, h3, h4, h5, h6, ul, li { outline: #ccc solid thin; }
+ p, h1, h2, h3, h4, h5, h6, ul, li, figcaption { outline: #ccc solid thin; }
strong, em, del, u, sub, sup { background: #0002; }
a { background: #13fefe50; }
[data-editor-selected] { outline: #f206f9 solid thick; }
diff --git a/app/javascript/editor/editor.ts b/app/javascript/editor/editor.ts
index f560b7e4..d660e9ea 100644
--- a/app/javascript/editor/editor.ts
+++ b/app/javascript/editor/editor.ts
@@ -5,6 +5,10 @@ import { setupButtons as setupMarksButtons } from 'editor/types/marks'
import { setupButtons as setupBlocksButtons } from 'editor/types/blocks'
import { setupButtons as setupParentBlocksButtons } from 'editor/types/parentBlocks'
import { setupAuxiliaryToolbar as setupLinkAuxiliaryToolbar } from 'editor/types/link'
+import {
+ setupAuxiliaryToolbar as setupMultimediaAuxiliaryToolbar,
+ setupButtons as setupMultimediaButtons,
+} from 'editor/types/multimedia'
import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from 'editor/types/mark'
// Esta funcion corrije errores que pueden haber como:
@@ -39,25 +43,27 @@ function fixContent (editor: Editor, node: Element = editor.contentEl): void {
const { typeName, type } = _type
- const sel = safeGetSelection(editor)
- const range = sel && safeGetRangeAt(sel)
+ if (type.allowedChildren !== 'ignore-children') {
+ const sel = safeGetSelection(editor)
+ const range = sel && safeGetRangeAt(sel)
- if (getValidChildren(node, type).length == 0) {
- if (typeof type.handleEmpty !== 'string') {
- const el = type.handleEmpty.create(editor)
- // mover cosas que pueden haber
- // por ejemplo: cuando convertís a un
, queda texto fuera del li que
- // creamos acá
- moveChildren(node, el, null)
- node.appendChild(el)
- if (range?.intersectsNode(node))
- sel?.collapse(el)
+ if (getValidChildren(node, type).length == 0) {
+ if (typeof type.handleEmpty !== 'string') {
+ const el = type.handleEmpty.create(editor)
+ // mover cosas que pueden haber
+ // por ejemplo: cuando convertís a un , queda texto fuera del li que
+ // creamos acá
+ moveChildren(node, el, null)
+ node.appendChild(el)
+ if (range?.intersectsNode(node))
+ sel?.collapse(el)
+ }
+ }
+
+ for (const child of node.childNodes) {
+ if (!(child instanceof Element)) continue
+ fixContent(editor, child)
}
- }
-
- for (const child of node.childNodes) {
- if (!(child instanceof Element)) continue
- fixContent(editor, child)
}
}
@@ -75,39 +81,41 @@ function cleanContent (editor: Editor, node: Element = editor.contentEl): void {
const { type } = _type
- for (const child of node.childNodes) {
- if (child.nodeType === Node.TEXT_NODE
- && type.allowedChildren.indexOf('text') === -1
- ) {
- node.removeChild(child)
- continue
+ if (type.allowedChildren !== 'ignore-children') {
+ for (const child of node.childNodes) {
+ if (child.nodeType === Node.TEXT_NODE
+ && !type.allowedChildren.includes('text')
+ ) {
+ node.removeChild(child)
+ continue
+ }
+
+ if (!(child instanceof Element)) continue
+
+ const childType = getType(child)
+ if (childType?.typeName === 'br') continue
+ if (!childType || !type.allowedChildren.includes(childType.typeName)) {
+ // XXX: esto extrae las cosas de adentro para que no sea destructivo
+ moveChildren(child, node, child)
+ node.removeChild(child)
+ return
+ }
+
+ cleanContent(editor, child)
}
- if (!(child instanceof Element)) continue
+ // solo contar children válido para ese nodo
+ const validChildrenLength = getValidChildren(node, type).length
- const childType = getType(child)
- if (childType?.typeName === 'br') continue
- if (!childType || type.allowedChildren.indexOf(childType.typeName) === -1) {
- // XXX: esto extrae las cosas de adentro para que no sea destructivo
- moveChildren(child, node, child)
- node.removeChild(child)
+ const sel = safeGetSelection(editor)
+ const range = sel && safeGetRangeAt(sel)
+ if (type.handleEmpty === 'remove'
+ && validChildrenLength == 0
+ //&& (!range || !range.intersectsNode(node))
+ ) {
+ node.parentNode?.removeChild(node)
return
}
-
- cleanContent(editor, child)
- }
-
- // solo contar children válido para ese nodo
- const validChildrenLength = getValidChildren(node, type).length
-
- const sel = safeGetSelection(editor)
- const range = sel && safeGetRangeAt(sel)
- if (type.handleEmpty === 'remove'
- && validChildrenLength == 0
- //&& (!range || !range.intersectsNode(node))
- ) {
- node.parentNode?.removeChild(node)
- return
}
}
@@ -137,6 +145,7 @@ export interface Editor {
fileEl: HTMLInputElement,
uploadEl: HTMLButtonElement,
altEl: HTMLInputElement,
+ removeEl: HTMLButtonElement,
},
link: {
parentEl: HTMLElement,
@@ -173,6 +182,7 @@ function setupEditor (editorEl: HTMLElement): void {
fileEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-file]'),
uploadEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-file-upload]'),
altEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-alt]'),
+ removeEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-remove]'),
},
link: {
parentEl: getSel(editorEl, '[data-editor-auxiliary=link]'),
@@ -231,8 +241,10 @@ function setupEditor (editorEl: HTMLElement): void {
setupMarksButtons(editor)
setupBlocksButtons(editor)
setupParentBlocksButtons(editor)
+ setupMultimediaButtons(editor)
setupLinkAuxiliaryToolbar(editor)
+ setupMultimediaAuxiliaryToolbar(editor)
setupMarkAuxiliaryToolbar(editor)
// Finally...
diff --git a/app/javascript/editor/types.ts b/app/javascript/editor/types.ts
index 6cc74fdd..3a178ee8 100644
--- a/app/javascript/editor/types.ts
+++ b/app/javascript/editor/types.ts
@@ -2,6 +2,7 @@ import { Editor } from 'editor/editor'
import { marks } from 'editor/types/marks'
import { blocks, li, EditorBlock } from 'editor/types/blocks'
import { parentBlocks } from 'editor/types/parentBlocks'
+import { multimedia } from 'editor/types/multimedia'
import { blockNames, parentBlockNames } from 'editor/utils'
export interface EditorNode {
@@ -9,7 +10,7 @@ export interface EditorNode {
// la string es el nombre en la gran lista de types O 'text'
// XXX: esto es un hack para no poner EditorNode dentro de EditorNode,
// quizás podemos hacer que esto sea una función que retorna bool
- allowedChildren: string[],
+ allowedChildren: string[] | 'ignore-children',
// * si es 'do-nothing', no hace nada si está vacío (esto es para cuando
// permitís 'text' entonces se puede tipear adentro, ej: párrafo vacío)
@@ -31,7 +32,7 @@ export const types: { [propName: string]: EditorNode } = {
...parentBlocks,
contentEl: {
selector: '.editor-content',
- allowedChildren: [...blockNames, ...parentBlockNames],
+ allowedChildren: [...blockNames, ...parentBlockNames, 'multimedia'],
handleEmpty: blocks.paragraph,
create: () => { throw new Error('se intentó crear contentEl') }
},
@@ -41,6 +42,7 @@ export const types: { [propName: string]: EditorNode } = {
handleEmpty: 'do-nothing',
create: () => { throw new Error('se intentó crear br') }
},
+ multimedia,
}
export function getType (node: Element): { typeName: string, type: EditorNode } | null {
@@ -51,6 +53,8 @@ export function getType (node: Element): { typeName: string, type: EditorNode }
}
export function getValidChildren (node: Element, type: EditorNode): Node[] {
+ if (type.allowedChildren === 'ignore-children')
+ throw new Error('se llamó a getValidChildren con un type que no lo permite!')
return [...node.childNodes].filter(n => {
// si permite texto y esto es un texto, es válido
if (n.nodeType === Node.TEXT_NODE)
diff --git a/app/javascript/editor/types/blocks.ts b/app/javascript/editor/types/blocks.ts
index b3bee4e6..eb57b55d 100644
--- a/app/javascript/editor/types/blocks.ts
+++ b/app/javascript/editor/types/blocks.ts
@@ -6,10 +6,6 @@ import {
} from 'editor/utils'
import { EditorNode, getType } from 'editor/types'
-// TODO: implementar multimedia como otro tipo! ya que no puede tener children
-// debe ser tratado distinto que los bloques (quizás EditorBlockWithText y
-// EditorBlock o algo así)
-
export interface EditorBlock extends EditorNode {
}
diff --git a/app/javascript/editor/types/multimedia.ts b/app/javascript/editor/types/multimedia.ts
new file mode 100644
index 00000000..dce00d59
--- /dev/null
+++ b/app/javascript/editor/types/multimedia.ts
@@ -0,0 +1,220 @@
+import { Editor } from 'editor/editor'
+import { EditorNode, getType } from 'editor/types'
+import {
+ safeGetSelection, safeGetRangeAt,
+ markNames, parentBlockNames,
+ setAuxiliaryToolbar,
+} from 'editor/utils'
+
+// TODO: tener ActiveStorage como import así no hacemos hacks
+declare global {
+ const ActiveStorage: any
+}
+
+function uploadFile (file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const upload = new ActiveStorage.DirectUpload(
+ file,
+ origin + '/rails/active_storage/direct_uploads',
+ )
+
+ upload.create((error: any, blob: any) => {
+ if (error) {
+ reject(error)
+ } else {
+ const url = `${origin}/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}`
+ resolve(url)
+ }
+ })
+ })
+}
+
+function getAlt (multimediaInnerEl: HTMLElement): string | null {
+ switch (multimediaInnerEl.tagName) {
+ case 'VIDEO':
+ case 'AUDIO':
+ return multimediaInnerEl.getAttribute('aria-label')
+ case 'IMG':
+ return (multimediaInnerEl as HTMLImageElement).alt
+ case 'IFRAME':
+ return multimediaInnerEl.title
+ default:
+ throw new Error('no pude conseguir el alt')
+ }
+}
+function setAlt (multimediaInnerEl: HTMLElement, value: string): void {
+ switch (multimediaInnerEl.tagName) {
+ case 'VIDEO':
+ case 'AUDIO':
+ multimediaInnerEl.setAttribute('aria-label', value)
+ break
+ case 'IMG':
+ (multimediaInnerEl as HTMLImageElement).alt = value
+ break
+ case 'IFRAME':
+ multimediaInnerEl.title = value
+ break
+ default:
+ throw new Error('no pude setear el alt')
+ }
+}
+
+export const multimedia: EditorNode = {
+ selector: 'figure[data-multimedia]',
+ allowedChildren: 'ignore-children',
+ handleEmpty: 'remove',
+ create: () => {
+ const figureEl = document.createElement('figure')
+ figureEl.dataset.multimedia = ''
+ figureEl.contentEditable = 'false'
+
+ const placeholderEl = document.createElement('p')
+ placeholderEl.dataset.multimediaInner = ''
+ // TODO i18n
+ placeholderEl.append('¡Clickeame para subir un archivo!')
+ figureEl.appendChild(placeholderEl)
+
+ const descriptionEl = document.createElement('figcaption')
+ descriptionEl.contentEditable = 'true'
+ // TODO i18n
+ descriptionEl.append('Escribí acá la descripción del archivo.')
+ figureEl.appendChild(descriptionEl)
+
+ return figureEl
+ },
+ onClick (editor, el) {
+ if (!(el instanceof HTMLElement))
+ throw new Error('oh no')
+ el.dataset.editorSelected = ''
+
+ const innerEl = el.querySelector('[data-multimedia-inner]')
+ if (!innerEl) throw new Error('No hay multimedia válida')
+ if (innerEl.tagName !== 'P')
+ editor.toolbar.auxiliary.multimedia.altEl.value = getAlt(innerEl) || ''
+
+ setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.multimedia.parentEl)
+ },
+}
+function createElementWithFile (url: string, type: string): HTMLElement {
+ if (type.match(/^image\/.+$/)) {
+ const el = document.createElement('img')
+ el.dataset.multimediaInner = ''
+ el.src = url
+ return el
+ } else if (type.match(/^video\/.+$/)) {
+ const el = document.createElement('video')
+ el.controls = true
+ el.dataset.multimediaInner = ''
+ el.src = url
+ return el
+ } else if (type.match(/^audio\/.+$/)) {
+ const el = document.createElement('audio')
+ el.controls = true
+ el.dataset.multimediaInner = ''
+ el.src = url
+ return el
+ } else if (type.match(/^application\/pdf$/)) {
+ const el = document.createElement('iframe')
+ el.dataset.multimediaInner = ''
+ el.src = url
+ return el
+ } else {
+ // TODO: chequear si el archivo es válido antes de subir
+ throw new Error('Tipo de archivo no reconocido')
+ }
+}
+
+export function setupAuxiliaryToolbar (editor: Editor): void {
+ editor.toolbar.auxiliary.multimedia.uploadEl.addEventListener('click', event => {
+ const files = editor.toolbar.auxiliary.multimedia.fileEl.files
+ if (!files) throw new Error('no hay archivos para subir')
+ const file = files[0]
+
+ const selectedEl = editor.contentEl
+ .querySelector('figure[data-editor-selected]')
+ if (!selectedEl)
+ throw new Error('No pude encontrar el elemento para setear el archivo')
+
+ selectedEl.dataset.editorLoading = ''
+ uploadFile(file)
+ .then(url => {
+ const innerEl = selectedEl.querySelector('[data-multimedia-inner]')
+ if (!innerEl) throw new Error('No hay multimedia a reemplazar')
+
+ const el = createElementWithFile(url, file.type)
+ setAlt(el, editor.toolbar.auxiliary.multimedia.altEl.value)
+ selectedEl.replaceChild(el, innerEl)
+
+ delete selectedEl.dataset.editorError
+ })
+ .catch(err => {
+ console.error(err)
+ // TODO: mostrar error
+ selectedEl.dataset.editorError = ''
+ })
+ .finally(() => { delete selectedEl.dataset.editorLoading })
+ })
+
+ editor.toolbar.auxiliary.multimedia.removeEl.addEventListener('click', event => {
+ const selectedEl = editor.contentEl
+ .querySelector('figure[data-editor-selected]')
+ if (!selectedEl)
+ throw new Error('No pude encontrar el elemento para borrar')
+
+ selectedEl.parentElement?.removeChild(selectedEl)
+ setAuxiliaryToolbar(editor, null)
+ })
+
+ editor.toolbar.auxiliary.multimedia.altEl.addEventListener('input', event => {
+ const selectedEl = editor.contentEl
+ .querySelector('figure[data-editor-selected]')
+ if (!selectedEl)
+ throw new Error('No pude encontrar el multimedia para setear el alt')
+
+ const innerEl = selectedEl.querySelector('[data-multimedia-inner]')
+ if (!innerEl) throw new Error('No hay multimedia a para setear el alt')
+
+ setAlt(innerEl, editor.toolbar.auxiliary.multimedia.altEl.value)
+ })
+ editor.toolbar.auxiliary.multimedia.altEl.addEventListener('keydown', event => {
+ if (event.keyCode == 13) event.preventDefault()
+ })
+}
+
+export function setupButtons (editor: Editor): void {
+ const buttonEl = editor.toolbarEl.querySelector('[data-editor-button="multimedia"]')
+ if (!buttonEl) throw new Error('No encontre el botón de multimedia')
+ buttonEl.addEventListener('click', event => {
+ event.preventDefault()
+
+ const sel = safeGetSelection(editor)
+ if (!sel) return
+ const range = safeGetRangeAt(sel)
+ if (!range) return
+
+ let blockEl = sel.anchorNode
+ while (true) {
+ if (!blockEl) throw new Error('WTF')
+ if (!blockEl.parentElement) throw new Error('No pude encontrar contentEl!')
+
+ let type = getType(blockEl.parentElement)
+ if (!type) throw new Error('La selección está en algo que no es un type!')
+
+ if (type.typeName === 'contentEl'
+ || parentBlockNames.includes(type.typeName)
+ ) break
+
+ blockEl = blockEl.parentElement
+ }
+ if (!(blockEl instanceof Element))
+ throw new Error('La selección no está en un elemento!')
+
+ const parentEl = blockEl.parentElement
+ if (!parentEl) throw new Error('Inesperado')
+
+ const el = multimedia.create(editor)
+ parentEl.insertBefore(el, blockEl.nextElementSibling)
+
+ return false
+ })
+}
diff --git a/app/javascript/editor/types/parentBlocks.ts b/app/javascript/editor/types/parentBlocks.ts
index 346ac3b0..9f780424 100644
--- a/app/javascript/editor/types/parentBlocks.ts
+++ b/app/javascript/editor/types/parentBlocks.ts
@@ -9,7 +9,7 @@ import { EditorNode, getType } from 'editor/types'
function makeParentBlock (tag: string, create: EditorNode["create"]): EditorNode {
return {
selector: tag,
- allowedChildren: blockNames,
+ allowedChildren: [...blockNames, 'multimedia'],
handleEmpty: 'remove',
create,
}
diff --git a/app/views/posts/attributes/_content.haml b/app/views/posts/attributes/_content.haml
index 08cd8a58..b35e2745 100644
--- a/app/views/posts/attributes/_content.haml
+++ b/app/views/posts/attributes/_content.haml
@@ -15,26 +15,26 @@
.editor-toolbar
.editor-primary-toolbar.scrollbar-black
- %button.btn{ data: { editor: { button: 'mark-bold' } } }= t('editor.bold')
- %button.btn{ data: { editor: { button: 'mark-italic' } } }= t('editor.italic')
- %button.btn{ data: { editor: { button: 'mark-deleted' } } }= t('editor.deleted')
- %button.btn{ data: { editor: { button: 'mark-underline' } } }= t('editor.underline')
- %button.btn{ data: { editor: { button: 'mark-super' } } }= t('editor.super')
- %button.btn{ data: { editor: { button: 'mark-sub' } } }= t('editor.sub')
- %button.btn{ data: { editor: { button: 'mark-mark' } } }= t('editor.mark')
- %button.btn{ data: { editor: { button: 'mark-link' } } }= t('editor.link')
- %button.btn{ data: { editor: { button: 'block-h1' } } }= t('editor.h1')
- %button.btn{ data: { editor: { button: 'block-h2' } } }= t('editor.h2')
- %button.btn{ data: { editor: { button: 'block-h3' } } }= t('editor.h3')
- %button.btn{ data: { editor: { button: 'block-h4' } } }= t('editor.h4')
- %button.btn{ data: { editor: { button: 'block-h5' } } }= t('editor.h5')
- %button.btn{ data: { editor: { button: 'block-h6' } } }= t('editor.h6')
- %button.btn{ data: { editor: { button: 'block-unordered_list' } } }= t('editor.ul')
- %button.btn{ data: { editor: { button: 'block-ordered_list' } } }= t('editor.ol')
- %button.btn{ data: { editor: { button: 'parentBlock-left' } } }= t('editor.left')
- %button.btn{ data: { editor: { button: 'parentBlock-center' } } }= t('editor.center')
- %button.btn{ data: { editor: { button: 'parentBlock-right' } } }= t('editor.right')
- -#%button.btn{ data: { editor: { button: 'multimedia' } } }= t('editor.multimedia')
+ %button.btn{ type: 'button', data: { editor: { button: 'mark-bold' } } }= t('editor.bold')
+ %button.btn{ type: 'button', data: { editor: { button: 'mark-italic' } } }= t('editor.italic')
+ %button.btn{ type: 'button', data: { editor: { button: 'mark-deleted' } } }= t('editor.deleted')
+ %button.btn{ type: 'button', data: { editor: { button: 'mark-underline' } } }= t('editor.underline')
+ %button.btn{ type: 'button', data: { editor: { button: 'mark-super' } } }= t('editor.super')
+ %button.btn{ type: 'button', data: { editor: { button: 'mark-sub' } } }= t('editor.sub')
+ %button.btn{ type: 'button', data: { editor: { button: 'mark-mark' } } }= t('editor.mark')
+ %button.btn{ type: 'button', data: { editor: { button: 'mark-link' } } }= t('editor.link')
+ %button.btn{ type: 'button', data: { editor: { button: 'block-h1' } } }= t('editor.h1')
+ %button.btn{ type: 'button', data: { editor: { button: 'block-h2' } } }= t('editor.h2')
+ %button.btn{ type: 'button', data: { editor: { button: 'block-h3' } } }= t('editor.h3')
+ %button.btn{ type: 'button', data: { editor: { button: 'block-h4' } } }= t('editor.h4')
+ %button.btn{ type: 'button', data: { editor: { button: 'block-h5' } } }= t('editor.h5')
+ %button.btn{ type: 'button', data: { editor: { button: 'block-h6' } } }= t('editor.h6')
+ %button.btn{ type: 'button', data: { editor: { button: 'block-unordered_list' } } }= t('editor.ul')
+ %button.btn{ type: 'button', data: { editor: { button: 'block-ordered_list' } } }= t('editor.ol')
+ %button.btn{ type: 'button', data: { editor: { button: 'parentBlock-left' } } }= t('editor.left')
+ %button.btn{ type: 'button', data: { editor: { button: 'parentBlock-center' } } }= t('editor.center')
+ %button.btn{ type: 'button', data: { editor: { button: 'parentBlock-right' } } }= t('editor.right')
+ %button.btn{ type: 'button', data: { editor: { button: 'multimedia' } } }= t('editor.multimedia')
-#
HAML cringe
@@ -45,15 +45,15 @@
%input.form-control{ type: 'color', name: 'mark-color' }/
%div{ data: { editor: { auxiliary: 'multimedia' } } }
- .row
- .col-12.col-lg.form-group.d-flex.align-items-end
- .custom-file
- %input.custom-file-input{ type: 'file', name: 'multimedia-file' }/
- %label.custom-file-label{ for: 'multimedia-file' }= t('editor.file.multimedia')
- %button.btn{ type: 'button', name: 'multimedia-file-upload' }= t('editor.file.multimedia-upload')
- .col-12.col-lg.form-group
- %label{ for: 'multimedia-alt' }= t('editor.description')
- %input.form-control{ type: 'text', name: 'multimedia-alt' }/
+ .col-12.col-lg.form-group.d-flex.align-items-end
+ .custom-file
+ %input.custom-file-input{ type: 'file', name: 'multimedia-file' }/
+ %label.custom-file-label{ for: 'multimedia-file' }= t('editor.file.multimedia')
+ %button.btn{ type: 'button', name: 'multimedia-file-upload' }= t('editor.file.multimedia-upload')
+ .col-12.col-lg.form-group
+ %label{ for: 'multimedia-alt' }= t('editor.description')
+ %input.form-control{ type: 'text', name: 'multimedia-alt' }/
+ %button.btn{ type: 'button', name: 'multimedia-remove' }= t('editor.file.multimedia-remove')
.form-group{ data: { editor: { auxiliary: 'link' } } }
%label{ for: 'link-url' }= t('editor.url')