From 6154b3667083dfca0a4b4b1b6268855f67e17adb Mon Sep 17 00:00:00 2001 From: f Date: Fri, 21 Oct 2022 16:31:54 -0300 Subject: [PATCH] Revert "Deprecar el editor incorporado" This reverts commit c0b5863573dbd174d3100c09aa7bc66c8078ffef. --- 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 insertions(+) create mode 100644 app/javascript/editor/storage.ts create mode 100644 app/javascript/editor/types.ts create mode 100644 app/javascript/editor/types/blocks.ts create mode 100644 app/javascript/editor/types/link.ts create mode 100644 app/javascript/editor/types/mark.ts create mode 100644 app/javascript/editor/types/marks.ts create mode 100644 app/javascript/editor/types/multimedia.ts create mode 100644 app/javascript/editor/types/parentBlocks.ts create mode 100644 app/javascript/editor/utils.ts diff --git a/app/javascript/editor/storage.ts b/app/javascript/editor/storage.ts new file mode 100644 index 00000000..e914a242 --- /dev/null +++ b/app/javascript/editor/storage.ts @@ -0,0 +1,38 @@ +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 new file mode 100644 index 00000000..ac3030ce --- /dev/null +++ b/app/javascript/editor/types.ts @@ -0,0 +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"; + +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 new file mode 100644 index 00000000..2e2dea7e --- /dev/null +++ b/app/javascript/editor/types/blocks.ts @@ -0,0 +1,76 @@ +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 new file mode 100644 index 00000000..eb85db90 --- /dev/null +++ b/app/javascript/editor/types/link.ts @@ -0,0 +1,37 @@ +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 new file mode 100644 index 00000000..4735c799 --- /dev/null +++ b/app/javascript/editor/types/mark.ts @@ -0,0 +1,66 @@ +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 new file mode 100644 index 00000000..0ea5a5ad --- /dev/null +++ b/app/javascript/editor/types/marks.ts @@ -0,0 +1,102 @@ +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 new file mode 100644 index 00000000..2af9643a --- /dev/null +++ b/app/javascript/editor/types/multimedia.ts @@ -0,0 +1,230 @@ +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 new file mode 100644 index 00000000..ffe40bdf --- /dev/null +++ b/app/javascript/editor/types/parentBlocks.ts @@ -0,0 +1,78 @@ +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 new file mode 100644 index 00000000..167c0a6d --- /dev/null +++ b/app/javascript/editor/utils.ts @@ -0,0 +1,101 @@ +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; +}