diff --git a/app/javascript/editor/editor.ts b/app/javascript/editor/editor.ts index 167d40d..6ec06f3 100644 --- a/app/javascript/editor/editor.ts +++ b/app/javascript/editor/editor.ts @@ -1,18 +1,23 @@ -import { storeContent, restoreContent, forgetContent } from 'editor/storage' +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' + 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' + 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 @@ -22,79 +27,76 @@ import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from 'editor/types // * convierte y en y // 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 - } +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.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) + 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 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) + 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) + const innerEl = figureEl.querySelector("[data-multimedia-inner]"); + if (!innerEl) throw new Error("Raro."); + figureEl.replaceChild(node, innerEl); - node = figureEl - } + node = figureEl; + } - const _type = getType(node) - if (!_type) return + const _type = getType(node); + if (!_type) return; - const { typeName, type } = _type + const { typeName, type } = _type; - if (type.allowedChildren !== 'ignore-children') { - 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) - } - } - - for (const child of node.childNodes) { - if (!(child instanceof Element)) continue - fixContent(editor, child) - } - } + 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); + } + } } // Esta funcion hace que los elementos del editor sigan la estructura. @@ -102,205 +104,231 @@ function fixContent (editor: Editor, node: Element = editor.contentEl): void { // Edge cases: // * no borramos los
      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 - } +function cleanContent(editor: Editor, node: Element = editor.contentEl): void { + const _type = getType(node); + if (!_type) { + node.parentElement?.removeChild(node); + return; + } - const { type } = _type + 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 (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 + 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 - } + 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) - } + cleanContent(editor, child); + } - // solo contar children válido para ese nodo - const validChildrenLength = getValidChildren(node, type).length + // 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 - } - } + 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) +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) - } + 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, + 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(parentEl: HTMLElement, selector: string): T { - const el = parentEl.querySelector(selector) - if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``) - return el + const el = parentEl.querySelector(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') +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) + 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) + // 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' - }) + // 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, - }) + // 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)) + 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) + // 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 + // 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) + // Setup botones + setupMarksButtons(editor); + setupBlocksButtons(editor); + setupParentBlocksButtons(editor); + setupMultimediaButtons(editor); - setupLinkAuxiliaryToolbar(editor) - setupMultimediaAuxiliaryToolbar(editor) - setupMarkAuxiliaryToolbar(editor) + setupLinkAuxiliaryToolbar(editor); + setupMultimediaAuxiliaryToolbar(editor); + setupMarkAuxiliaryToolbar(editor); - // Finally... - routine(editor) + // Finally... + routine(editor); } document.addEventListener("turbolinks:load", () => { - const flash = document.querySelector('.js-flash') + const flash = document.querySelector(".js-flash"); - if (flash) { - const keys = JSON.parse(flash.dataset.keys || '[]') + 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) - } - } - } + switch (flash.dataset.target) { + case "editor": + switch (flash.dataset.action) { + case "forget-content": + keys.forEach(forgetContent); + } + } + } - for (const editorEl of document.querySelectorAll('.editor[data-editor]')) { - try { - setupEditor(editorEl) - } catch (error) { - // TODO: mostrar error - console.error('no se pudo iniciar el editor, error completo', error) - } - } -}) + for (const editorEl of document.querySelectorAll( + ".editor[data-editor]" + )) { + try { + setupEditor(editorEl); + } catch (error) { + // TODO: mostrar error + console.error("no se pudo iniciar el editor, error completo", error); + } + } +}); diff --git a/app/javascript/editor/storage.ts b/app/javascript/editor/storage.ts index df27d59..e914a24 100644 --- a/app/javascript/editor/storage.ts +++ b/app/javascript/editor/storage.ts @@ -1,4 +1,4 @@ -import { Editor } from 'editor/editor' +import { Editor } from "editor/editor"; /* * Guarda una copia local de los cambios para poder recuperarlos @@ -6,27 +6,33 @@ import { Editor } from 'editor/editor' * * Usamos la URL completa sin anchors. */ -function getStorageKey (editor: Editor): string { - const keyEl = editor.editorEl.querySelector('[data-target="storage-key"]') - if (!keyEl) throw new Error('No encuentro la llave para guardar los artículos') - return keyEl.value +function getStorageKey(editor: Editor): string { + const keyEl = editor.editorEl.querySelector( + '[data-target="storage-key"]' + ); + if (!keyEl) + throw new Error("No encuentro la llave para guardar los artículos"); + return keyEl.value; } -export function forgetContent (storedKey: string): void { - window.localStorage.removeItem(storedKey) +export function forgetContent(storedKey: string): void { + window.localStorage.removeItem(storedKey); } -export function storeContent (editor: Editor): void { - if (editor.contentEl.innerText.trim().length === 0) return +export function storeContent(editor: Editor): void { + if (editor.contentEl.innerText.trim().length === 0) return; - window.localStorage.setItem(getStorageKey(editor), editor.contentEl.innerHTML) + window.localStorage.setItem( + getStorageKey(editor), + editor.contentEl.innerHTML + ); } -export function restoreContent (editor: Editor): void { - const content = window.localStorage.getItem(getStorageKey(editor)) +export function restoreContent(editor: Editor): void { + const content = window.localStorage.getItem(getStorageKey(editor)); - if (!content) return - if (content.trim().length === 0) return + if (!content) return; + if (content.trim().length === 0) return; - editor.contentEl.innerHTML = content + editor.contentEl.innerHTML = content; } diff --git a/app/javascript/editor/types.ts b/app/javascript/editor/types.ts index 8034e3e..ac3030c 100644 --- a/app/javascript/editor/types.ts +++ b/app/javascript/editor/types.ts @@ -1,126 +1,140 @@ -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, safeGetRangeAt, safeGetSelection } from 'editor/utils' +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, + safeGetRangeAt, + safeGetSelection, +} from "editor/utils"; export interface EditorNode { - selector: string, - // 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[] | 'ignore-children', + selector: string; + // 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[] | "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) - // * si es 'remove', sacamos el coso si está vacío. - // ej: strong: { handleNothing: 'remove' } - // * si es un block, insertamos el bloque y movemos la selección ahí - // ej: ul: { handleNothing: li } - handleEmpty: 'do-nothing' | 'remove' | EditorBlock, + // * 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) + // * si es 'remove', sacamos el coso si está vacío. + // ej: strong: { handleNothing: 'remove' } + // * si es un block, insertamos el bloque y movemos la selección ahí + // ej: ul: { handleNothing: li } + handleEmpty: "do-nothing" | "remove" | EditorBlock; - // esta función puede ser llamada para cosas que no necesariamente sea la - // creación del nodo con el botón; por ejemplo, al intentar recuperar - // el formato. esto es importante por que, por ejemplo, no deberíamos - // cambiar la selección acá. - create: (editor: Editor) => HTMLElement, + // esta función puede ser llamada para cosas que no necesariamente sea la + // creación del nodo con el botón; por ejemplo, al intentar recuperar + // el formato. esto es importante por que, por ejemplo, no deberíamos + // cambiar la selección acá. + create: (editor: Editor) => HTMLElement; - onClick?: (editor: Editor, target: Element) => void, + onClick?: (editor: Editor, target: Element) => void; } export const types: { [propName: string]: EditorNode } = { - ...marks, - ...blocks, - li, - ...parentBlocks, - contentEl: { - selector: '.editor-content', - allowedChildren: [...blockNames, ...parentBlockNames, 'multimedia'], - handleEmpty: blocks.paragraph, - create: () => { throw new Error('se intentó crear contentEl') } - }, - br: { - selector: 'br', - allowedChildren: [], - handleEmpty: 'do-nothing', - create: () => { throw new Error('se intentó crear br') } - }, - multimedia, -} + ...marks, + ...blocks, + li, + ...parentBlocks, + contentEl: { + selector: ".editor-content", + allowedChildren: [...blockNames, ...parentBlockNames, "multimedia"], + handleEmpty: blocks.paragraph, + create: () => { + throw new Error("se intentó crear contentEl"); + }, + }, + br: { + selector: "br", + allowedChildren: [], + handleEmpty: "do-nothing", + create: () => { + throw new Error("se intentó crear br"); + }, + }, + multimedia, +}; -export function getType (node: Element): { typeName: string, type: EditorNode } | null { - for (let [typeName, type] of Object.entries(types)) { - if (node.matches(type.selector)) { - return { typeName, type } - } - } - - return null +export function getType( + node: Element +): { typeName: string; type: EditorNode } | null { + for (let [typeName, type] of Object.entries(types)) { + if (node.matches(type.selector)) { + return { typeName, type }; + } + } + + return null; } // encuentra el primer pariente que pueda tener al type, y retorna un array // donde // array[0] = elemento que matchea el type // array[array.len - 1] = primer elemento seleccionado -export function getValidParentInSelection (args: { - editor: Editor, - type: string, +export function getValidParentInSelection(args: { + editor: Editor; + type: string; }): Element[] { - const sel = safeGetSelection(args.editor) - if (!sel) throw new Error('No se donde insertar esto') - const range = safeGetRangeAt(sel) - if (!range) throw new Error('No se donde insertar esto') + const sel = safeGetSelection(args.editor); + if (!sel) throw new Error("No se donde insertar esto"); + const range = safeGetRangeAt(sel); + if (!range) throw new Error("No se donde insertar esto"); - let list: Element[] = [] - - if (!sel.anchorNode) { - throw new Error('No se donde insertar esto') - } else if (sel.anchorNode instanceof Element) { - list = [sel.anchorNode] - } else if (sel.anchorNode.parentElement) { - list = [sel.anchorNode.parentElement] - } else { - throw new Error('No se donde insertar esto') - } + let list: Element[] = []; - while (true) { - const el = list[0] - if (!args.editor.contentEl.contains(el) - && el != args.editor.contentEl) - throw new Error('No se donde insertar esto') - const type = getType(el) + if (!sel.anchorNode) { + throw new Error("No se donde insertar esto"); + } else if (sel.anchorNode instanceof Element) { + list = [sel.anchorNode]; + } else if (sel.anchorNode.parentElement) { + list = [sel.anchorNode.parentElement]; + } else { + throw new Error("No se donde insertar esto"); + } - if (type) { - //if (type.typeName === 'contentEl') break - //if (parentBlockNames.includes(type.typeName)) break - if ((type.type.allowedChildren instanceof Array) - && type.type.allowedChildren.includes(args.type)) break - } - if (el.parentElement) { - list = [el.parentElement, ...list] - } else { - throw new Error('No se donde insertar esto') - } - } - - return list + while (true) { + const el = list[0]; + if (!args.editor.contentEl.contains(el) && el != args.editor.contentEl) + throw new Error("No se donde insertar esto"); + const type = getType(el); + + if (type) { + //if (type.typeName === 'contentEl') break + //if (parentBlockNames.includes(type.typeName)) break + if ( + type.type.allowedChildren instanceof Array && + type.type.allowedChildren.includes(args.type) + ) + break; + } + if (el.parentElement) { + list = [el.parentElement, ...list]; + } else { + throw new Error("No se donde insertar esto"); + } + } + + return list; } -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) - return type.allowedChildren.includes('text') && n.textContent?.length +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) + return type.allowedChildren.includes("text") && n.textContent?.length; - // si no es un elemento, no es válido - if (!(n instanceof Element)) - return false + // si no es un elemento, no es válido + if (!(n instanceof Element)) return false; - const t = getType(n) - if (!t) return false - return type.allowedChildren.includes(t.typeName) - }) + const t = getType(n); + if (!t) return false; + return type.allowedChildren.includes(t.typeName); + }); } diff --git a/app/javascript/editor/types/blocks.ts b/app/javascript/editor/types/blocks.ts index 52ad157..2e2dea7 100644 --- a/app/javascript/editor/types/blocks.ts +++ b/app/javascript/editor/types/blocks.ts @@ -1,72 +1,76 @@ -import { Editor } from 'editor/editor' +import { Editor } from "editor/editor"; import { - safeGetSelection, safeGetRangeAt, - moveChildren, - markNames, blockNames, parentBlockNames, -} from 'editor/utils' -import { EditorNode, getType, getValidParentInSelection } from 'editor/types' + safeGetSelection, + safeGetRangeAt, + moveChildren, + markNames, + blockNames, + parentBlockNames, +} from "editor/utils"; +import { EditorNode, getType, getValidParentInSelection } from "editor/types"; -export interface EditorBlock extends EditorNode { +export interface EditorBlock extends EditorNode {} + +function makeBlock(tag: string): EditorBlock { + return { + selector: tag, + allowedChildren: [...markNames, "text"], + handleEmpty: "do-nothing", + create: () => document.createElement(tag), + }; } -function makeBlock (tag: string): EditorBlock { - return { - selector: tag, - allowedChildren: [...markNames, 'text'], - handleEmpty: 'do-nothing', - create: () => document.createElement(tag), - } -} - -export const li: EditorBlock = makeBlock('li') +export const li: EditorBlock = makeBlock("li"); // XXX: si agregás algo acá, agregalo a blockNames // (y probablemente le quieras hacer un botón en app/views/posts/attributes/_content.haml) export const blocks: { [propName: string]: EditorBlock } = { - paragraph: makeBlock('p'), - h1: makeBlock('h1'), - h2: makeBlock('h2'), - h3: makeBlock('h3'), - h4: makeBlock('h4'), - h5: makeBlock('h5'), - h6: makeBlock('h6'), - unordered_list: { - ...makeBlock('ul'), - allowedChildren: ['li'], - handleEmpty: li, - }, - ordered_list: { - ...makeBlock('ol'), - allowedChildren: ['li'], - handleEmpty: li, - }, -} - -export function setupButtons (editor: Editor): void { - for (const [ name, type ] of Object.entries(blocks)) { - const buttonEl = editor.toolbarEl.querySelector(`[data-editor-button="block-${name}"]`) - if (!buttonEl) continue - buttonEl.addEventListener("click", event => { - event.preventDefault() - - const list = getValidParentInSelection({ editor, type: name }) - - // No borrar cosas como multimedia - if (blockNames.indexOf(getType(list[1])!.typeName) === -1) { - return - } - - let replacementType = list[1].matches(type.selector) - ? blocks.paragraph - : type - - const el = replacementType.create(editor) - replacementType.onClick && replacementType.onClick(editor, el) - moveChildren(list[1], el, null) - list[0].replaceChild(el, list[1]) - window.getSelection()?.collapse(el) - - return false - }) - } + paragraph: makeBlock("p"), + h1: makeBlock("h1"), + h2: makeBlock("h2"), + h3: makeBlock("h3"), + h4: makeBlock("h4"), + h5: makeBlock("h5"), + h6: makeBlock("h6"), + unordered_list: { + ...makeBlock("ul"), + allowedChildren: ["li"], + handleEmpty: li, + }, + ordered_list: { + ...makeBlock("ol"), + allowedChildren: ["li"], + handleEmpty: li, + }, +}; + +export function setupButtons(editor: Editor): void { + for (const [name, type] of Object.entries(blocks)) { + const buttonEl = editor.toolbarEl.querySelector( + `[data-editor-button="block-${name}"]` + ); + if (!buttonEl) continue; + buttonEl.addEventListener("click", (event) => { + event.preventDefault(); + + const list = getValidParentInSelection({ editor, type: name }); + + // No borrar cosas como multimedia + if (blockNames.indexOf(getType(list[1])!.typeName) === -1) { + return; + } + + let replacementType = list[1].matches(type.selector) + ? blocks.paragraph + : type; + + const el = replacementType.create(editor); + replacementType.onClick && replacementType.onClick(editor, el); + moveChildren(list[1], el, null); + list[0].replaceChild(el, list[1]); + window.getSelection()?.collapse(el); + + return false; + }); + } } diff --git a/app/javascript/editor/types/link.ts b/app/javascript/editor/types/link.ts index 40a26e1..eb85db9 100644 --- a/app/javascript/editor/types/link.ts +++ b/app/javascript/editor/types/link.ts @@ -1,37 +1,37 @@ -import { Editor } from 'editor/editor' -import { EditorNode } from 'editor/types' -import { markNames, setAuxiliaryToolbar, clearSelected } from 'editor/utils' +import { Editor } from "editor/editor"; +import { EditorNode } from "editor/types"; +import { markNames, setAuxiliaryToolbar, clearSelected } from "editor/utils"; -function select (editor: Editor, el: HTMLAnchorElement): void { - clearSelected(editor) - el.dataset.editorSelected = '' - editor.toolbar.auxiliary.link.urlEl.value = el.href - setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.link.parentEl) +function select(editor: Editor, el: HTMLAnchorElement): void { + clearSelected(editor); + el.dataset.editorSelected = ""; + editor.toolbar.auxiliary.link.urlEl.value = el.href; + setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.link.parentEl); } export const link: EditorNode = { - selector: 'a', - allowedChildren: [...markNames.filter(n => n !== 'link'), 'text'], - handleEmpty: 'remove', - create: () => document.createElement('a'), - onClick (editor, el) { - if (!(el instanceof HTMLAnchorElement)) - throw new Error('oh no') - select(editor, el) - }, -} + selector: "a", + allowedChildren: [...markNames.filter((n) => n !== "link"), "text"], + handleEmpty: "remove", + create: () => document.createElement("a"), + onClick(editor, el) { + if (!(el instanceof HTMLAnchorElement)) throw new Error("oh no"); + select(editor, el); + }, +}; -export function setupAuxiliaryToolbar (editor: Editor): void { - editor.toolbar.auxiliary.link.urlEl.addEventListener('input', event => { - const url = editor.toolbar.auxiliary.link.urlEl.value - const selectedEl = editor.contentEl - .querySelector('a[data-editor-selected]') - if (!selectedEl) - throw new Error('No pude encontrar el link para setear el enlace') - - selectedEl.href = url - }) - editor.toolbar.auxiliary.link.urlEl.addEventListener('keydown', event => { - if (event.keyCode == 13) event.preventDefault() - }) +export function setupAuxiliaryToolbar(editor: Editor): void { + editor.toolbar.auxiliary.link.urlEl.addEventListener("input", (event) => { + const url = editor.toolbar.auxiliary.link.urlEl.value; + const selectedEl = editor.contentEl.querySelector( + "a[data-editor-selected]" + ); + if (!selectedEl) + throw new Error("No pude encontrar el link para setear el enlace"); + + selectedEl.href = url; + }); + editor.toolbar.auxiliary.link.urlEl.addEventListener("keydown", (event) => { + if (event.keyCode == 13) event.preventDefault(); + }); } diff --git a/app/javascript/editor/types/mark.ts b/app/javascript/editor/types/mark.ts index 1e63e36..2607cae 100644 --- a/app/javascript/editor/types/mark.ts +++ b/app/javascript/editor/types/mark.ts @@ -1,49 +1,48 @@ -import { Editor } from 'editor/editor' -import { EditorNode } from 'editor/types' -import { markNames, setAuxiliaryToolbar, clearSelected } from 'editor/utils' +import { Editor } from "editor/editor"; +import { EditorNode } from "editor/types"; +import { markNames, setAuxiliaryToolbar, clearSelected } from "editor/utils"; -const hex = (x: string) => ("0" + parseInt(x).toString(16)).slice(-2) +const hex = (x: string) => ("0" + parseInt(x).toString(16)).slice(-2); // https://stackoverflow.com/a/3627747 // TODO: cambiar por una solución más copada -function rgbToHex (rgb: string): string { - const matches = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/) - if (!matches) throw new Error('no pude parsear el rgb()') - return "#" + hex(matches[1]) + hex(matches[2]) + hex(matches[3]) +function rgbToHex(rgb: string): string { + const matches = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); + if (!matches) throw new Error("no pude parsear el rgb()"); + return "#" + hex(matches[1]) + hex(matches[2]) + hex(matches[3]); } -function select (editor: Editor, el: HTMLElement): void { - clearSelected(editor) - el.dataset.editorSelected = '' - editor.toolbar.auxiliary.mark.colorEl.value - = el.style.backgroundColor - ? rgbToHex(el.style.backgroundColor) - : '#f206f9' - setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.mark.parentEl) +function select(editor: Editor, el: HTMLElement): void { + clearSelected(editor); + el.dataset.editorSelected = ""; + editor.toolbar.auxiliary.mark.colorEl.value = el.style.backgroundColor + ? rgbToHex(el.style.backgroundColor) + : "#f206f9"; + setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.mark.parentEl); } export const mark: EditorNode = { - selector: 'mark', - allowedChildren: [...markNames.filter(n => n !== 'mark'), 'text'], - handleEmpty: 'remove', - create: () => document.createElement('mark'), - onClick (editor, el) { - if (!(el instanceof HTMLElement)) - throw new Error('oh no') - select(editor, el) - }, -} + selector: "mark", + allowedChildren: [...markNames.filter((n) => n !== "mark"), "text"], + handleEmpty: "remove", + create: () => document.createElement("mark"), + onClick(editor, el) { + if (!(el instanceof HTMLElement)) throw new Error("oh no"); + select(editor, el); + }, +}; -export function setupAuxiliaryToolbar (editor: Editor): void { - editor.toolbar.auxiliary.mark.colorEl.addEventListener('input', event => { - const color = editor.toolbar.auxiliary.mark.colorEl.value - const selectedEl = editor.contentEl - .querySelector('mark[data-editor-selected]') - if (!selectedEl) - throw new Error('No pude encontrar el mark para setear el color') - - selectedEl.style.backgroundColor = color - }) - editor.toolbar.auxiliary.mark.colorEl.addEventListener('keydown', event => { - if (event.keyCode == 13) event.preventDefault() - }) +export function setupAuxiliaryToolbar(editor: Editor): void { + editor.toolbar.auxiliary.mark.colorEl.addEventListener("input", (event) => { + const color = editor.toolbar.auxiliary.mark.colorEl.value; + const selectedEl = editor.contentEl.querySelector( + "mark[data-editor-selected]" + ); + if (!selectedEl) + throw new Error("No pude encontrar el mark para setear el color"); + + selectedEl.style.backgroundColor = color; + }); + editor.toolbar.auxiliary.mark.colorEl.addEventListener("keydown", (event) => { + if (event.keyCode == 13) event.preventDefault(); + }); } diff --git a/app/javascript/editor/types/marks.ts b/app/javascript/editor/types/marks.ts index 3790c74..0ea5a5a 100644 --- a/app/javascript/editor/types/marks.ts +++ b/app/javascript/editor/types/marks.ts @@ -1,96 +1,102 @@ -import { Editor } from 'editor/editor' -import { EditorNode } from 'editor/types' +import { Editor } from "editor/editor"; +import { EditorNode } from "editor/types"; import { - safeGetSelection, safeGetRangeAt, - moveChildren, - markNames, -} from 'editor/utils' -import { link } from 'editor/types/link' -import { mark } from 'editor/types/mark' + safeGetSelection, + safeGetRangeAt, + moveChildren, + markNames, +} from "editor/utils"; +import { link } from "editor/types/link"; +import { mark } from "editor/types/mark"; -function makeMark (name: string, tag: string): EditorNode { - return { - selector: tag, - allowedChildren: [...markNames.filter(n => n !== name), 'text'], - handleEmpty: 'remove', - create: () => document.createElement(tag), - } +function makeMark(name: string, tag: string): EditorNode { + return { + selector: tag, + allowedChildren: [...markNames.filter((n) => n !== name), "text"], + handleEmpty: "remove", + create: () => document.createElement(tag), + }; } // XXX: si agregás algo acá, agregalo a markNames export const marks: { [propName: string]: EditorNode } = { - bold: makeMark('bold', 'strong'), - italic: makeMark('italic', 'em'), - deleted: makeMark('deleted', 'del'), - underline: makeMark('underline', 'u'), - sub: makeMark('sub', 'sub'), - super: makeMark('super', 'sup'), - mark, - link, - small: makeMark('small', 'small'), -} + bold: makeMark("bold", "strong"), + italic: makeMark("italic", "em"), + deleted: makeMark("deleted", "del"), + underline: makeMark("underline", "u"), + sub: makeMark("sub", "sub"), + super: makeMark("super", "sup"), + mark, + link, + small: makeMark("small", "small"), +}; -function recursiveFilterSelection ( - node: Element, - selection: Selection, - selector: string, +function recursiveFilterSelection( + node: Element, + selection: Selection, + selector: string ): Element[] { - let output: Element[] = [] - for (const child of [...node.children]) { - if (child.matches(selector) - && selection.containsNode(child) - ) output.push(child) - output = [...output, ...recursiveFilterSelection(child, selection, selector)] - } - return output + let output: Element[] = []; + for (const child of [...node.children]) { + if (child.matches(selector) && selection.containsNode(child)) + output.push(child); + output = [ + ...output, + ...recursiveFilterSelection(child, selection, selector), + ]; + } + return output; } -export function setupButtons (editor: Editor): void { - for (const [ name, type ] of Object.entries(marks)) { - const buttonEl = editor.toolbarEl.querySelector(`[data-editor-button="mark-${name}"]`) - if (!buttonEl) continue - buttonEl.addEventListener("click", event => { - event.preventDefault() +export function setupButtons(editor: Editor): void { + for (const [name, type] of Object.entries(marks)) { + const buttonEl = editor.toolbarEl.querySelector( + `[data-editor-button="mark-${name}"]` + ); + if (!buttonEl) continue; + buttonEl.addEventListener("click", (event) => { + event.preventDefault(); - const sel = safeGetSelection(editor) - if (!sel) return - const range = safeGetRangeAt(sel) - if (!range) return + const sel = safeGetSelection(editor); + if (!sel) return; + const range = safeGetRangeAt(sel); + if (!range) return; - let parentEl = range.commonAncestorContainer - while (!(parentEl instanceof Element)) { - if (!parentEl.parentElement) return - parentEl = parentEl.parentElement - } + let parentEl = range.commonAncestorContainer; + while (!(parentEl instanceof Element)) { + if (!parentEl.parentElement) return; + parentEl = parentEl.parentElement; + } - const existingMarks = recursiveFilterSelection( - parentEl, - sel, - type.selector, - ) - console.debug('marks encontradas:', existingMarks) + const existingMarks = recursiveFilterSelection( + parentEl, + sel, + type.selector + ); + console.debug("marks encontradas:", existingMarks); - if (existingMarks.length > 0) { - const mark = existingMarks[0] - if (!mark.parentElement) - throw new Error(':/') - moveChildren(mark, mark.parentElement, mark) - mark.parentElement.removeChild(mark) - } else { - if (range.commonAncestorContainer === editor.contentEl) - // TODO: mostrar error - return console.error("No puedo marcar cosas a través de distintos bloques!") + if (existingMarks.length > 0) { + const mark = existingMarks[0]; + if (!mark.parentElement) throw new Error(":/"); + moveChildren(mark, mark.parentElement, mark); + mark.parentElement.removeChild(mark); + } else { + if (range.commonAncestorContainer === editor.contentEl) + // TODO: mostrar error + return console.error( + "No puedo marcar cosas a través de distintos bloques!" + ); - const tagEl = type.create(editor) - type.onClick && type.onClick(editor, tagEl) + const tagEl = type.create(editor); + type.onClick && type.onClick(editor, tagEl); - tagEl.appendChild(range.extractContents()) + tagEl.appendChild(range.extractContents()); - range.insertNode(tagEl) - range.selectNode(tagEl) - } + range.insertNode(tagEl); + range.selectNode(tagEl); + } - return false - }) - } + return false; + }); + } } diff --git a/app/javascript/editor/types/multimedia.ts b/app/javascript/editor/types/multimedia.ts index 54c430f..2af9643 100644 --- a/app/javascript/editor/types/multimedia.ts +++ b/app/javascript/editor/types/multimedia.ts @@ -1,206 +1,230 @@ -import * as ActiveStorage from '@rails/activestorage' -import { Editor } from 'editor/editor' -import { EditorNode, getValidParentInSelection } from 'editor/types' +import * as ActiveStorage from "@rails/activestorage"; +import { Editor } from "editor/editor"; +import { EditorNode, getValidParentInSelection } from "editor/types"; import { - safeGetSelection, safeGetRangeAt, - markNames, parentBlockNames, - setAuxiliaryToolbar, clearSelected, -} from 'editor/utils' + safeGetSelection, + safeGetRangeAt, + markNames, + parentBlockNames, + setAuxiliaryToolbar, + clearSelected, +} from "editor/utils"; -function uploadFile (file: File): Promise { - return new Promise((resolve, reject) => { - const upload = new ActiveStorage.DirectUpload( - file, - origin + '/rails/active_storage/direct_uploads', - ) +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) - } - }) - }) + 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 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') - } +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"); + } } -function select (editor: Editor, el: HTMLElement): void { - clearSelected(editor) - el.dataset.editorSelected = '' +function select(editor: Editor, el: HTMLElement): void { + clearSelected(editor); + 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 = ""; - editor.toolbar.auxiliary.multimedia.altEl.disabled = true; - } else { - editor.toolbar.auxiliary.multimedia.altEl.value = getAlt(innerEl) || ""; - editor.toolbar.auxiliary.multimedia.altEl.disabled = false; - } + 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 = ""; + editor.toolbar.auxiliary.multimedia.altEl.disabled = true; + } else { + editor.toolbar.auxiliary.multimedia.altEl.value = getAlt(innerEl) || ""; + editor.toolbar.auxiliary.multimedia.altEl.disabled = false; + } - setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.multimedia.parentEl) + setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.multimedia.parentEl); } 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' + 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 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) + 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') - select(editor, el) - }, -} -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') - } + return figureEl; + }, + onClick(editor, el) { + if (!(el instanceof HTMLElement)) throw new Error("oh no"); + select(editor, el); + }, +}; +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 || !files.length) throw new Error('no hay archivos para subir') - const file = files[0] +export function setupAuxiliaryToolbar(editor: Editor): void { + editor.toolbar.auxiliary.multimedia.uploadEl.addEventListener( + "click", + (event) => { + const files = editor.toolbar.auxiliary.multimedia.fileEl.files; + if (!files || !files.length) + 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') + 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') + 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) + const el = createElementWithFile(url, file.type); + setAlt(el, editor.toolbar.auxiliary.multimedia.altEl.value); + selectedEl.replaceChild(el, innerEl); - select(editor, selectedEl) + select(editor, selectedEl); - delete selectedEl.dataset.editorError - }) - .catch(err => { - console.error(err) - // TODO: mostrar error - selectedEl.dataset.editorError = '' - }) - .finally(() => { delete selectedEl.dataset.editorLoading }) - }) + 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') + 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) - }) + 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') + 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') + 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() - }) + 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() +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 list = getValidParentInSelection({ editor, type: 'multimedia' }) + const list = getValidParentInSelection({ editor, type: "multimedia" }); - const el = multimedia.create(editor) - list[0].insertBefore(el, list[1].nextElementSibling) - select(editor, el) + const el = multimedia.create(editor); + list[0].insertBefore(el, list[1].nextElementSibling); + select(editor, el); - return false - }) + return false; + }); } diff --git a/app/javascript/editor/types/parentBlocks.ts b/app/javascript/editor/types/parentBlocks.ts index 55a8c3d..d77df2b 100644 --- a/app/javascript/editor/types/parentBlocks.ts +++ b/app/javascript/editor/types/parentBlocks.ts @@ -1,70 +1,75 @@ -import { Editor } from 'editor/editor' +import { Editor } from "editor/editor"; import { - safeGetSelection, safeGetRangeAt, - moveChildren, - blockNames, parentBlockNames, -} from 'editor/utils' -import { EditorNode, getType, getValidParentInSelection } from 'editor/types' + safeGetSelection, + safeGetRangeAt, + moveChildren, + blockNames, + parentBlockNames, +} from "editor/utils"; +import { EditorNode, getType, getValidParentInSelection } from "editor/types"; -function makeParentBlock (tag: string, create: EditorNode["create"]): EditorNode { - return { - selector: tag, - allowedChildren: [...blockNames, 'multimedia'], - handleEmpty: 'remove', - create, - } +function makeParentBlock( + tag: string, + create: EditorNode["create"] +): EditorNode { + return { + selector: tag, + allowedChildren: [...blockNames, "multimedia"], + handleEmpty: "remove", + create, + }; } // TODO: añadir blockquote // XXX: si agregás algo acá, probablemente le quieras hacer un botón // en app/views/posts/attributes/_content.haml export const parentBlocks: { [propName: string]: EditorNode } = { - left: makeParentBlock('div[data-align=left]', () => { - const el = document.createElement('div') - el.dataset.align = 'left' - return el - }), - center: makeParentBlock('div[data-align=center]', () => { - const el = document.createElement('div') - el.dataset.align = 'center' - return el - }), - right: makeParentBlock('div[data-align=right]', () => { - const el = document.createElement('div') - el.dataset.align = 'right' - return el - }), -} - -export function setupButtons (editor: Editor): void { - for (const [ name, type ] of Object.entries(parentBlocks)) { - const buttonEl = editor.toolbarEl.querySelector( - `[data-editor-button="parentBlock-${name}"]` - ) - if (!buttonEl) continue - buttonEl.addEventListener("click", event => { - event.preventDefault() - - // TODO: Esto solo mueve el bloque en el que está el final de la selección - // (anchorNode). quizás lo podemos hacer al revés (iterar desde contentEl - // para encontrar los bloques que están seleccionados y moverlos/cambiarles - // el parentBlock) - - const list = getValidParentInSelection({ editor, type: name }) - - const replacementEl = type.create(editor) - if (list[0] == editor.contentEl) { - // no está en un parentBlock - editor.contentEl.insertBefore(replacementEl, list[1]) - replacementEl.appendChild(list[1]) - } else { - // está en un parentBlock - moveChildren(list[0], replacementEl, null) - editor.contentEl.replaceChild(replacementEl, list[0]) - } - window.getSelection()?.collapse(replacementEl) - - return false - }) - } + left: makeParentBlock("div[data-align=left]", () => { + const el = document.createElement("div"); + el.dataset.align = "left"; + return el; + }), + center: makeParentBlock("div[data-align=center]", () => { + const el = document.createElement("div"); + el.dataset.align = "center"; + return el; + }), + right: makeParentBlock("div[data-align=right]", () => { + const el = document.createElement("div"); + el.dataset.align = "right"; + return el; + }), +}; + +export function setupButtons(editor: Editor): void { + for (const [name, type] of Object.entries(parentBlocks)) { + const buttonEl = editor.toolbarEl.querySelector( + `[data-editor-button="parentBlock-${name}"]` + ); + if (!buttonEl) continue; + buttonEl.addEventListener("click", (event) => { + event.preventDefault(); + + // TODO: Esto solo mueve el bloque en el que está el final de la selección + // (anchorNode). quizás lo podemos hacer al revés (iterar desde contentEl + // para encontrar los bloques que están seleccionados y moverlos/cambiarles + // el parentBlock) + + const list = getValidParentInSelection({ editor, type: name }); + + const replacementEl = type.create(editor); + if (list[0] == editor.contentEl) { + // no está en un parentBlock + editor.contentEl.insertBefore(replacementEl, list[1]); + replacementEl.appendChild(list[1]); + } else { + // está en un parentBlock + moveChildren(list[0], replacementEl, null); + editor.contentEl.replaceChild(replacementEl, list[0]); + } + window.getSelection()?.collapse(replacementEl); + + return false; + }); + } } diff --git a/app/javascript/editor/utils.ts b/app/javascript/editor/utils.ts index 7ac4c18..167c0a6 100644 --- a/app/javascript/editor/utils.ts +++ b/app/javascript/editor/utils.ts @@ -1,77 +1,101 @@ -import { Editor } from 'editor/editor' +import { Editor } from "editor/editor"; -export const blockNames = ['paragraph', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'unordered_list', 'ordered_list'] -export const markNames = ['bold', 'italic', 'deleted', 'underline', 'sub', 'super', 'mark', 'link', 'small'] -export const parentBlockNames = ['left', 'center', 'right'] +export const blockNames = [ + "paragraph", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "unordered_list", + "ordered_list", +]; +export const markNames = [ + "bold", + "italic", + "deleted", + "underline", + "sub", + "super", + "mark", + "link", + "small", +]; +export const parentBlockNames = ["left", "center", "right"]; -export function moveChildren (from: Element, to: Element, toRef: Node | null) { - while (from.firstChild) to.insertBefore(from.firstChild, toRef) +export function moveChildren(from: Element, to: Element, toRef: Node | null) { + while (from.firstChild) to.insertBefore(from.firstChild, toRef); } -export function isDirectChild (node: Node, supposedChild: Node): boolean { - for (const child of node.childNodes) { - if (child == supposedChild) return true - } - return false +export function isDirectChild(node: Node, supposedChild: Node): boolean { + for (const child of node.childNodes) { + if (child == supposedChild) return true; + } + return false; } -export function safeGetSelection (editor: Editor): Selection | null { - const sel = window.getSelection() - if (!sel) return null - // XXX: no damos la selección si esta fuera o _es_ el contentEl, ¿quizás - // deberíamos mostrar un error? - if ( - !editor.contentEl.contains(sel.anchorNode) - || !editor.contentEl.contains(sel.focusNode) - || sel.anchorNode == editor.contentEl - || sel.focusNode == editor.contentEl - ) return null - return sel +export function safeGetSelection(editor: Editor): Selection | null { + const sel = window.getSelection(); + if (!sel) return null; + // XXX: no damos la selección si esta fuera o _es_ el contentEl, ¿quizás + // deberíamos mostrar un error? + if ( + !editor.contentEl.contains(sel.anchorNode) || + !editor.contentEl.contains(sel.focusNode) || + sel.anchorNode == editor.contentEl || + sel.focusNode == editor.contentEl + ) + return null; + return sel; } -export function safeGetRangeAt (selection: Selection, num = 0): Range | null { - try { - return selection.getRangeAt(num) - } catch (error) { - return null - } +export function safeGetRangeAt(selection: Selection, num = 0): Range | null { + try { + return selection.getRangeAt(num); + } catch (error) { + return null; + } } interface SplitNode { - range: Range, - node: Node, + range: Range; + node: Node; } -export function splitNode (node: Element, range: Range): [SplitNode, SplitNode] { - const [left, right] = [ - { range: document.createRange(), node: node.cloneNode(false) }, - { range: document.createRange(), node: node.cloneNode(false) }, - ] +export function splitNode(node: Element, range: Range): [SplitNode, SplitNode] { + const [left, right] = [ + { range: document.createRange(), node: node.cloneNode(false) }, + { range: document.createRange(), node: node.cloneNode(false) }, + ]; - if (node.firstChild) left.range.setStartBefore(node.firstChild) - left.range.setEnd(range.startContainer, range.startOffset) - left.range.surroundContents(left.node) + if (node.firstChild) left.range.setStartBefore(node.firstChild); + left.range.setEnd(range.startContainer, range.startOffset); + left.range.surroundContents(left.node); - right.range.setStart(range.endContainer, range.endOffset) - if (node.lastChild) right.range.setEndAfter(node.lastChild) - right.range.surroundContents(right.node) + right.range.setStart(range.endContainer, range.endOffset); + if (node.lastChild) right.range.setEndAfter(node.lastChild); + right.range.surroundContents(right.node); - if (!node.parentElement) - throw new Error('No pude separar los nodos por que no tiene parentNode') + if (!node.parentElement) + throw new Error("No pude separar los nodos por que no tiene parentNode"); - moveChildren(node, node.parentElement, node) - node.parentElement.removeChild(node) + moveChildren(node, node.parentElement, node); + node.parentElement.removeChild(node); - return [left, right] + return [left, right]; } -export function setAuxiliaryToolbar (editor: Editor, bar: HTMLElement | null): void { - for (const { parentEl } of Object.values(editor.toolbar.auxiliary)) { - delete parentEl.dataset.editorAuxiliaryActive - } - if (bar) bar.dataset.editorAuxiliaryActive = 'active' +export function setAuxiliaryToolbar( + editor: Editor, + bar: HTMLElement | null +): void { + for (const { parentEl } of Object.values(editor.toolbar.auxiliary)) { + delete parentEl.dataset.editorAuxiliaryActive; + } + if (bar) bar.dataset.editorAuxiliaryActive = "active"; } -export function clearSelected (editor: Editor): void { - const selectedEl = editor.contentEl.querySelector('[data-editor-selected]') - if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected +export function clearSelected(editor: Editor): void { + const selectedEl = editor.contentEl.querySelector("[data-editor-selected]"); + if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected; }