mirror of
https://0xacab.org/sutty/sutty
synced 2024-11-26 10:26:22 +00:00
306 lines
9.1 KiB
TypeScript
306 lines
9.1 KiB
TypeScript
import { storeContent, restoreContent, forgetContent } from 'editor/storage'
|
|
import {
|
|
isDirectChild, moveChildren, safeGetSelection, safeGetRangeAt,
|
|
setAuxiliaryToolbar, parentBlockNames, clearSelected,
|
|
} from 'editor/utils'
|
|
import { types, getValidChildren, getType } from 'editor/types'
|
|
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:
|
|
// * que un nodo que no tiene 'text' permitido no tenga children (se les
|
|
// inserta un allowedChildren[0])
|
|
// * TODO: que haya una imágen sin <figure> o que no esté como bloque (se ponen
|
|
// después del bloque en el que están como bloque de por si)
|
|
// * convierte <i> y <b> en <em> y <strong>
|
|
// Lo hace para que siga la estructura del documento y que no se borren por
|
|
// cleanContent luego.
|
|
function fixContent (editor: Editor, node: Element = editor.contentEl): void {
|
|
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') {
|
|
node.parentElement?.removeChild(node)
|
|
return
|
|
}
|
|
|
|
if (node.tagName === 'I') {
|
|
const el = document.createElement('em')
|
|
moveChildren(node, el, null)
|
|
node.parentElement?.replaceChild(el, node)
|
|
node = el
|
|
}
|
|
if (node.tagName === 'B') {
|
|
const el = document.createElement('strong')
|
|
moveChildren(node, el, null)
|
|
node.parentElement?.replaceChild(el, node)
|
|
node = el
|
|
}
|
|
|
|
if (node instanceof HTMLImageElement) {
|
|
node.dataset.multimediaInner = ''
|
|
const figureEl = types.multimedia.create(editor)
|
|
|
|
let targetEl = node.parentElement
|
|
if (!targetEl) throw new Error('No encontré lx objetivo')
|
|
while (true) {
|
|
const type = getType(targetEl)
|
|
if (!type) throw new Error('lx objetivo tiene tipo')
|
|
if (type.type.allowedChildren.includes('multimedia')) break
|
|
if (!targetEl.parentElement) throw new Error('No encontré lx objetivo')
|
|
targetEl = targetEl.parentElement
|
|
}
|
|
|
|
let parentEl = [...targetEl.childNodes].find(
|
|
el => el.contains(node)
|
|
)
|
|
if (!parentEl) throw new Error('no encontré lx pariente')
|
|
targetEl.insertBefore(figureEl, parentEl)
|
|
|
|
const innerEl = figureEl.querySelector('[data-multimedia-inner]')
|
|
if (!innerEl) throw new Error('Raro.')
|
|
figureEl.replaceChild(node, innerEl)
|
|
|
|
node = figureEl
|
|
}
|
|
|
|
const _type = getType(node)
|
|
if (!_type) return
|
|
|
|
const { typeName, type } = _type
|
|
|
|
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 <ul>, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Esta funcion hace que los elementos del editor sigan la estructura.
|
|
// TODO: nos falta borrar atributos (style, y básicamente cualquier otra cosa)
|
|
// Edge cases:
|
|
// * no borramos los <br> por que se requieren para que los navegadores
|
|
// funcionen bien al escribir. no se deberían mostrar de todas maneras
|
|
function cleanContent (editor: Editor, node: Element = editor.contentEl): void {
|
|
const _type = getType(node)
|
|
if (!_type) {
|
|
node.parentElement?.removeChild(node)
|
|
return
|
|
}
|
|
|
|
const { type } = _type
|
|
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
function routine (editor: Editor): void {
|
|
try {
|
|
fixContent(editor)
|
|
cleanContent(editor)
|
|
storeContent(editor)
|
|
|
|
editor.htmlEl.value = editor.contentEl.innerHTML
|
|
} catch (error) {
|
|
console.error('Hubo un problema corriendo la rutina', editor, error)
|
|
}
|
|
}
|
|
|
|
export interface Editor {
|
|
editorEl: HTMLElement,
|
|
toolbarEl: HTMLElement,
|
|
toolbar: {
|
|
auxiliary: {
|
|
mark: {
|
|
parentEl: HTMLElement,
|
|
colorEl: HTMLInputElement,
|
|
},
|
|
multimedia: {
|
|
parentEl: HTMLElement,
|
|
fileEl: HTMLInputElement,
|
|
uploadEl: HTMLButtonElement,
|
|
altEl: HTMLInputElement,
|
|
removeEl: HTMLButtonElement,
|
|
},
|
|
link: {
|
|
parentEl: HTMLElement,
|
|
urlEl: HTMLInputElement,
|
|
},
|
|
},
|
|
},
|
|
contentEl: HTMLElement,
|
|
wordAlertEl: HTMLElement,
|
|
htmlEl: HTMLTextAreaElement,
|
|
}
|
|
|
|
function getSel<T extends Element>(parentEl: HTMLElement, selector: string): T {
|
|
const el = parentEl.querySelector<T>(selector)
|
|
if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``)
|
|
return el
|
|
}
|
|
|
|
function setupEditor (editorEl: HTMLElement): void {
|
|
// XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor?
|
|
document.execCommand('defaultParagraphSeparator', false, 'p')
|
|
|
|
const editor: Editor = {
|
|
editorEl,
|
|
toolbarEl: getSel(editorEl, '.editor-toolbar'),
|
|
toolbar: {
|
|
auxiliary: {
|
|
mark: {
|
|
parentEl: getSel(editorEl, '[data-editor-auxiliary=mark]'),
|
|
colorEl: getSel(editorEl, '[data-editor-auxiliary=mark] [name=mark-color]'),
|
|
},
|
|
multimedia: {
|
|
parentEl: getSel(editorEl, '[data-editor-auxiliary=multimedia]'),
|
|
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]'),
|
|
urlEl: getSel(editorEl, '[data-editor-auxiliary=link] [name=link-url]'),
|
|
},
|
|
},
|
|
},
|
|
contentEl: getSel(editorEl, '.editor-content'),
|
|
wordAlertEl: getSel(editorEl, '.editor-aviso-word'),
|
|
htmlEl: getSel(editorEl, 'textarea'),
|
|
}
|
|
console.debug('iniciando editor', editor)
|
|
|
|
// Recuperar el contenido si hay algo guardado, si tuviéramos un campo
|
|
// de última edición podríamos saber si el artículo fue editado
|
|
// después o la versión local es la última.
|
|
//
|
|
// TODO: Preguntar si se lo quiere recuperar.
|
|
restoreContent(editor)
|
|
|
|
// Word alert
|
|
editor.contentEl.addEventListener('paste', () => {
|
|
editor.wordAlertEl.style.display = 'block'
|
|
})
|
|
|
|
// Setup routine listeners
|
|
const observer = new MutationObserver(() => routine(editor))
|
|
observer.observe(editor.contentEl, {
|
|
childList: true,
|
|
attributes: true,
|
|
subtree: true,
|
|
characterData: true,
|
|
})
|
|
|
|
document.addEventListener("selectionchange", () => routine(editor))
|
|
|
|
// Capture onClick
|
|
editor.contentEl.addEventListener('click', event => {
|
|
const target = event.target! as Element
|
|
const type = getType(target)
|
|
if (!type || !type.type.onClick) {
|
|
setAuxiliaryToolbar(editor, null)
|
|
clearSelected(editor)
|
|
return true
|
|
}
|
|
type.type.onClick(editor, target)
|
|
return false
|
|
}, true)
|
|
|
|
// Clean seleted
|
|
const selectedEl = editor.contentEl.querySelector('[data-editor-selected]')
|
|
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected
|
|
|
|
// Setup botones
|
|
setupMarksButtons(editor)
|
|
setupBlocksButtons(editor)
|
|
setupParentBlocksButtons(editor)
|
|
setupMultimediaButtons(editor)
|
|
|
|
setupLinkAuxiliaryToolbar(editor)
|
|
setupMultimediaAuxiliaryToolbar(editor)
|
|
setupMarkAuxiliaryToolbar(editor)
|
|
|
|
// Finally...
|
|
routine(editor)
|
|
}
|
|
|
|
document.addEventListener("turbolinks:load", () => {
|
|
const flash = document.querySelector<HTMLElement>('.js-flash')
|
|
|
|
if (flash) {
|
|
const keys = JSON.parse(flash.dataset.keys || '[]')
|
|
|
|
switch (flash.dataset.target) {
|
|
case 'editor':
|
|
switch (flash.dataset.action) {
|
|
case 'forget-content':
|
|
keys.forEach(forgetContent)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const editorEl of document.querySelectorAll<HTMLElement>('.editor[data-editor]')) {
|
|
try {
|
|
setupEditor(editorEl)
|
|
} catch (error) {
|
|
// TODO: mostrar error
|
|
console.error('no se pudo iniciar el editor, error completo', error)
|
|
}
|
|
}
|
|
})
|