sutty/app/javascript/editor/editor.ts

340 lines
10 KiB
TypeScript
Raw Normal View History

2021-04-28 18:48:50 +00:00
import { storeContent, restoreContent, forgetContent } from "editor/storage";
2021-03-27 18:45:46 +00:00
import {
2021-04-28 18:48:50 +00:00
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";
2021-02-14 16:01:41 +00:00
import {
2021-04-28 18:48:50 +00:00
setupAuxiliaryToolbar as setupMultimediaAuxiliaryToolbar,
setupButtons as setupMultimediaButtons,
} from "editor/types/multimedia";
import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from "editor/types/mark";
// Esta funcion corrije errores que pueden haber como:
// * que un nodo que no tiene 'text' permitido no tenga children (se les
// inserta un allowedChildren[0])
// * TODO: que haya una imágen sin <figure> o que no esté como bloque (se ponen
// después del bloque en el que están como bloque de por si)
// * convierte <i> y <b> en <em> y <strong>
// Lo hace para que siga la estructura del documento y que no se borren por
// cleanContent luego.
2021-04-28 18:48:50 +00:00
function fixContent(editor: Editor, node: Element = editor.contentEl): void {
if (node.tagName === "SCRIPT" || node.tagName === "STYLE") {
node.parentElement?.removeChild(node);
return;
}
if (node.tagName === "I") {
const el = document.createElement("em");
moveChildren(node, el, null);
node.parentElement?.replaceChild(el, node);
node = el;
}
if (node.tagName === "B") {
const el = document.createElement("strong");
moveChildren(node, el, null);
node.parentElement?.replaceChild(el, node);
node = el;
}
if (node instanceof HTMLImageElement) {
node.dataset.multimediaInner = "";
const figureEl = types.multimedia.create(editor);
let targetEl = node.parentElement;
if (!targetEl) throw new Error("No encontré lx objetivo");
while (true) {
const type = getType(targetEl);
if (!type) throw new Error("lx objetivo tiene tipo");
if (type.type.allowedChildren.includes("multimedia")) break;
if (!targetEl.parentElement) throw new Error("No encontré lx objetivo");
targetEl = targetEl.parentElement;
}
let parentEl = [...targetEl.childNodes].find((el) => el.contains(node));
if (!parentEl) throw new Error("no encontré lx pariente");
targetEl.insertBefore(figureEl, parentEl);
const innerEl = figureEl.querySelector("[data-multimedia-inner]");
if (!innerEl) throw new Error("Raro.");
figureEl.replaceChild(node, innerEl);
node = figureEl;
}
const _type = getType(node);
if (!_type) return;
const { typeName, type } = _type;
if (type.allowedChildren !== "ignore-children") {
const sel = safeGetSelection(editor);
const range = sel && safeGetRangeAt(sel);
if (getValidChildren(node, type).length == 0) {
if (typeof type.handleEmpty !== "string") {
const el = type.handleEmpty.create(editor);
// mover cosas que pueden haber
// por ejemplo: cuando convertís a un <ul>, queda texto fuera del li que
// creamos acá
moveChildren(node, el, null);
node.appendChild(el);
if (range?.intersectsNode(node)) sel?.collapse(el);
}
}
for (const child of node.childNodes) {
if (!(child instanceof Element)) continue;
fixContent(editor, child);
}
}
}
// Esta funcion hace que los elementos del editor sigan la estructura.
// TODO: nos falta borrar atributos (style, y básicamente cualquier otra cosa)
// Edge cases:
// * no borramos los <br> por que se requieren para que los navegadores
// funcionen bien al escribir. no se deberían mostrar de todas maneras
2021-04-28 18:48:50 +00:00
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;
}
}
}
2021-04-28 18:48:50 +00:00
function routine(editor: Editor): void {
try {
fixContent(editor);
cleanContent(editor);
storeContent(editor);
2021-04-28 18:48:50 +00:00
editor.htmlEl.value = editor.contentEl.innerHTML;
} catch (error) {
console.error("Hubo un problema corriendo la rutina", editor, error);
}
}
2021-02-13 01:14:36 +00:00
export interface Editor {
2021-04-28 18:48:50 +00:00
editorEl: HTMLElement;
toolbarEl: HTMLElement;
toolbar: {
auxiliary: {
mark: {
parentEl: HTMLElement;
colorEl: HTMLInputElement;
textColorEl: HTMLInputElement;
2021-04-28 18:48:50 +00:00
};
multimedia: {
parentEl: HTMLElement;
fileEl: HTMLInputElement;
uploadEl: HTMLButtonElement;
altEl: HTMLInputElement;
removeEl: HTMLButtonElement;
};
link: {
parentEl: HTMLElement;
urlEl: HTMLInputElement;
};
};
};
contentEl: HTMLElement;
wordAlertEl: HTMLElement;
htmlEl: HTMLTextAreaElement;
2021-02-13 01:14:36 +00:00
}
function getSel<T extends Element>(parentEl: HTMLElement, selector: string): T {
2021-04-28 18:48:50 +00:00
const el = parentEl.querySelector<T>(selector);
if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``);
return el;
2021-02-13 01:14:36 +00:00
}
2021-04-28 18:48:50 +00:00
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]"
),
2021-04-28 18:48:50 +00:00
},
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", () => {
2021-04-28 18:48:50 +00:00
const flash = document.querySelector<HTMLElement>(".js-flash");
if (flash) {
const keys = JSON.parse(flash.dataset.keys || "[]");
switch (flash.dataset.target) {
case "editor":
switch (flash.dataset.action) {
case "forget-content":
keys.forEach(forgetContent);
}
}
}
for (const editorEl of document.querySelectorAll<HTMLElement>(
".editor[data-editor]"
)) {
try {
setupEditor(editorEl);
} catch (error) {
// TODO: mostrar error
console.error("no se pudo iniciar el editor, error completo", error);
}
}
});