From 6467a265d3fd2c1b77d576d2cd5412c6f189ac0d Mon Sep 17 00:00:00 2001 From: f Date: Tue, 10 Aug 2021 18:36:55 -0300 Subject: [PATCH 1/2] Deprecar el editor --- app/javascript/editor/editor.ts | 313 -------------------------------- 1 file changed, 313 deletions(-) diff --git a/app/javascript/editor/editor.ts b/app/javascript/editor/editor.ts index 233cc3c0..c254a650 100644 --- a/app/javascript/editor/editor.ts +++ b/app/javascript/editor/editor.ts @@ -1,320 +1,7 @@ -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"; - /// @ts-ignore import SuttyEditor from "@suttyweb/editor"; import "@suttyweb/editor/dist/style.css"; -// 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
o que no esté como bloque (se ponen -// después del bloque en el que están como bloque de por si) -// * 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; - } - - 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
    , 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
    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; - textColorEl: 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; -} - -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]" - ), - textColorEl: getSel( - editorEl, - "[data-editor-auxiliary=mark] [name=mark-text-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(".js-flash"); From c0b5863573dbd174d3100c09aa7bc66c8078ffef Mon Sep 17 00:00:00 2001 From: f Date: Tue, 10 Aug 2021 18:38:33 -0300 Subject: [PATCH 2/2] Deprecar el editor incorporado --- app/javascript/editor/storage.ts | 38 ---- app/javascript/editor/types.ts | 140 ------------ app/javascript/editor/types/blocks.ts | 76 ------- app/javascript/editor/types/link.ts | 37 ---- app/javascript/editor/types/mark.ts | 66 ------ app/javascript/editor/types/marks.ts | 102 --------- app/javascript/editor/types/multimedia.ts | 230 -------------------- app/javascript/editor/types/parentBlocks.ts | 78 ------- app/javascript/editor/utils.ts | 101 --------- 9 files changed, 868 deletions(-) delete mode 100644 app/javascript/editor/storage.ts delete mode 100644 app/javascript/editor/types.ts delete mode 100644 app/javascript/editor/types/blocks.ts delete mode 100644 app/javascript/editor/types/link.ts delete mode 100644 app/javascript/editor/types/mark.ts delete mode 100644 app/javascript/editor/types/marks.ts delete mode 100644 app/javascript/editor/types/multimedia.ts delete mode 100644 app/javascript/editor/types/parentBlocks.ts delete mode 100644 app/javascript/editor/utils.ts diff --git a/app/javascript/editor/storage.ts b/app/javascript/editor/storage.ts deleted file mode 100644 index e914a242..00000000 --- a/app/javascript/editor/storage.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Editor } from "editor/editor"; - -/* - * Guarda una copia local de los cambios para poder recuperarlos - * después. - * - * 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; -} - -export function forgetContent(storedKey: string): void { - window.localStorage.removeItem(storedKey); -} - -export function storeContent(editor: Editor): void { - if (editor.contentEl.innerText.trim().length === 0) return; - - window.localStorage.setItem( - getStorageKey(editor), - editor.contentEl.innerHTML - ); -} - -export function restoreContent(editor: Editor): void { - const content = window.localStorage.getItem(getStorageKey(editor)); - - if (!content) return; - if (content.trim().length === 0) return; - - editor.contentEl.innerHTML = content; -} diff --git a/app/javascript/editor/types.ts b/app/javascript/editor/types.ts deleted file mode 100644 index ac3030ce..00000000 --- a/app/javascript/editor/types.ts +++ /dev/null @@ -1,140 +0,0 @@ -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"; - - // * 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; - - 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, -}; - -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; -}): 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"); - - 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"); - } - - 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; - - // 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); - }); -} diff --git a/app/javascript/editor/types/blocks.ts b/app/javascript/editor/types/blocks.ts deleted file mode 100644 index 2e2dea7e..00000000 --- a/app/javascript/editor/types/blocks.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Editor } from "editor/editor"; -import { - safeGetSelection, - safeGetRangeAt, - moveChildren, - markNames, - blockNames, - parentBlockNames, -} from "editor/utils"; -import { EditorNode, getType, getValidParentInSelection } from "editor/types"; - -export interface EditorBlock extends EditorNode {} - -function makeBlock(tag: string): EditorBlock { - return { - selector: tag, - allowedChildren: [...markNames, "text"], - handleEmpty: "do-nothing", - create: () => document.createElement(tag), - }; -} - -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; - }); - } -} diff --git a/app/javascript/editor/types/link.ts b/app/javascript/editor/types/link.ts deleted file mode 100644 index eb85db90..00000000 --- a/app/javascript/editor/types/link.ts +++ /dev/null @@ -1,37 +0,0 @@ -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); -} - -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); - }, -}; - -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 deleted file mode 100644 index 4735c799..00000000 --- a/app/javascript/editor/types/mark.ts +++ /dev/null @@ -1,66 +0,0 @@ -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); -// 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 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"; - editor.toolbar.auxiliary.mark.textColorEl.value = el.style.color - ? rgbToHex(el.style.color) - : "#000000"; - 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); - }, -}; - -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.textColorEl.addEventListener( - "input", - (event) => { - const color = editor.toolbar.auxiliary.mark.textColorEl.value; - const selectedEl = editor.contentEl.querySelector( - "mark[data-editor-selected]" - ); - if (!selectedEl) - throw new Error( - "No pude encontrar el mark para setear el color del text" - ); - - selectedEl.style.color = 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 deleted file mode 100644 index 0ea5a5ad..00000000 --- a/app/javascript/editor/types/marks.ts +++ /dev/null @@ -1,102 +0,0 @@ -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"; - -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"), -}; - -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; -} - -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; - - 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); - - 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); - - tagEl.appendChild(range.extractContents()); - - range.insertNode(tagEl); - range.selectNode(tagEl); - } - - return false; - }); - } -} diff --git a/app/javascript/editor/types/multimedia.ts b/app/javascript/editor/types/multimedia.ts deleted file mode 100644 index 2af9643a..00000000 --- a/app/javascript/editor/types/multimedia.ts +++ /dev/null @@ -1,230 +0,0 @@ -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"; - -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"); - } -} - -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; - } - - 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"; - - 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"); - 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]; - - 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); - - select(editor, selectedEl); - - 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 list = getValidParentInSelection({ editor, type: "multimedia" }); - - const el = multimedia.create(editor); - list[0].insertBefore(el, list[1].nextElementSibling); - select(editor, el); - - return false; - }); -} diff --git a/app/javascript/editor/types/parentBlocks.ts b/app/javascript/editor/types/parentBlocks.ts deleted file mode 100644 index ffe40bdf..00000000 --- a/app/javascript/editor/types/parentBlocks.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Editor } from "editor/editor"; -import { - 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, - }; -} - -// 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"; - el.style.textAlign = "left"; - return el; - }), - center: makeParentBlock("div[data-align=center]", () => { - const el = document.createElement("div"); - el.dataset.align = "center"; - el.style.textAlign = "center"; - return el; - }), - right: makeParentBlock("div[data-align=right]", () => { - const el = document.createElement("div"); - el.dataset.align = "right"; - el.style.textAlign = "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 deleted file mode 100644 index 167c0a6d..00000000 --- a/app/javascript/editor/utils.ts +++ /dev/null @@ -1,101 +0,0 @@ -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 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 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; - } -} - -interface SplitNode { - 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) }, - ]; - - 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); - - 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); - - 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 clearSelected(editor: Editor): void { - const selectedEl = editor.contentEl.querySelector("[data-editor-selected]"); - if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected; -}