5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-07-02 14:26:07 +00:00

style: correr prettier sobre el editor

This commit is contained in:
void 2021-04-28 18:48:50 +00:00
parent df38e12e3c
commit 525a6fc680
10 changed files with 969 additions and 859 deletions

View file

@ -1,18 +1,23 @@
import { storeContent, restoreContent, forgetContent } from 'editor/storage' import { storeContent, restoreContent, forgetContent } from "editor/storage";
import { import {
isDirectChild, moveChildren, safeGetSelection, safeGetRangeAt, isDirectChild,
setAuxiliaryToolbar, parentBlockNames, clearSelected, moveChildren,
} from 'editor/utils' safeGetSelection,
import { types, getValidChildren, getType } from 'editor/types' safeGetRangeAt,
import { setupButtons as setupMarksButtons } from 'editor/types/marks' setAuxiliaryToolbar,
import { setupButtons as setupBlocksButtons } from 'editor/types/blocks' parentBlockNames,
import { setupButtons as setupParentBlocksButtons } from 'editor/types/parentBlocks' clearSelected,
import { setupAuxiliaryToolbar as setupLinkAuxiliaryToolbar } from 'editor/types/link' } 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 { import {
setupAuxiliaryToolbar as setupMultimediaAuxiliaryToolbar, setupAuxiliaryToolbar as setupMultimediaAuxiliaryToolbar,
setupButtons as setupMultimediaButtons, setupButtons as setupMultimediaButtons,
} from 'editor/types/multimedia' } from "editor/types/multimedia";
import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from 'editor/types/mark' import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from "editor/types/mark";
// Esta funcion corrije errores que pueden haber como: // Esta funcion corrije errores que pueden haber como:
// * que un nodo que no tiene 'text' permitido no tenga children (se les // * 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 <i> y <b> en <em> y <strong> // * convierte <i> y <b> en <em> y <strong>
// Lo hace para que siga la estructura del documento y que no se borren por // Lo hace para que siga la estructura del documento y que no se borren por
// cleanContent luego. // cleanContent luego.
function fixContent (editor: Editor, node: Element = editor.contentEl): void { function fixContent(editor: Editor, node: Element = editor.contentEl): void {
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') { if (node.tagName === "SCRIPT" || node.tagName === "STYLE") {
node.parentElement?.removeChild(node) node.parentElement?.removeChild(node);
return return;
} }
if (node.tagName === 'I') { if (node.tagName === "I") {
const el = document.createElement('em') const el = document.createElement("em");
moveChildren(node, el, null) moveChildren(node, el, null);
node.parentElement?.replaceChild(el, node) node.parentElement?.replaceChild(el, node);
node = el node = el;
} }
if (node.tagName === 'B') { if (node.tagName === "B") {
const el = document.createElement('strong') const el = document.createElement("strong");
moveChildren(node, el, null) moveChildren(node, el, null);
node.parentElement?.replaceChild(el, node) node.parentElement?.replaceChild(el, node);
node = el node = el;
} }
if (node instanceof HTMLImageElement) { if (node instanceof HTMLImageElement) {
node.dataset.multimediaInner = '' node.dataset.multimediaInner = "";
const figureEl = types.multimedia.create(editor) const figureEl = types.multimedia.create(editor);
let targetEl = node.parentElement let targetEl = node.parentElement;
if (!targetEl) throw new Error('No encontré lx objetivo') if (!targetEl) throw new Error("No encontré lx objetivo");
while (true) { while (true) {
const type = getType(targetEl) const type = getType(targetEl);
if (!type) throw new Error('lx objetivo tiene tipo') if (!type) throw new Error("lx objetivo tiene tipo");
if (type.type.allowedChildren.includes('multimedia')) break if (type.type.allowedChildren.includes("multimedia")) break;
if (!targetEl.parentElement) throw new Error('No encontré lx objetivo') if (!targetEl.parentElement) throw new Error("No encontré lx objetivo");
targetEl = targetEl.parentElement targetEl = targetEl.parentElement;
} }
let parentEl = [...targetEl.childNodes].find( let parentEl = [...targetEl.childNodes].find((el) => el.contains(node));
el => el.contains(node) if (!parentEl) throw new Error("no encontré lx pariente");
) targetEl.insertBefore(figureEl, parentEl);
if (!parentEl) throw new Error('no encontré lx pariente')
targetEl.insertBefore(figureEl, parentEl)
const innerEl = figureEl.querySelector('[data-multimedia-inner]') const innerEl = figureEl.querySelector("[data-multimedia-inner]");
if (!innerEl) throw new Error('Raro.') if (!innerEl) throw new Error("Raro.");
figureEl.replaceChild(node, innerEl) figureEl.replaceChild(node, innerEl);
node = figureEl node = figureEl;
} }
const _type = getType(node) const _type = getType(node);
if (!_type) return if (!_type) return;
const { typeName, type } = _type const { typeName, type } = _type;
if (type.allowedChildren !== 'ignore-children') { if (type.allowedChildren !== "ignore-children") {
const sel = safeGetSelection(editor) const sel = safeGetSelection(editor);
const range = sel && safeGetRangeAt(sel) const range = sel && safeGetRangeAt(sel);
if (getValidChildren(node, type).length == 0) { if (getValidChildren(node, type).length == 0) {
if (typeof type.handleEmpty !== 'string') { if (typeof type.handleEmpty !== "string") {
const el = type.handleEmpty.create(editor) const el = type.handleEmpty.create(editor);
// mover cosas que pueden haber // mover cosas que pueden haber
// por ejemplo: cuando convertís a un <ul>, queda texto fuera del li que // por ejemplo: cuando convertís a un <ul>, queda texto fuera del li que
// creamos acá // creamos acá
moveChildren(node, el, null) moveChildren(node, el, null);
node.appendChild(el) node.appendChild(el);
if (range?.intersectsNode(node)) if (range?.intersectsNode(node)) sel?.collapse(el);
sel?.collapse(el) }
} }
}
for (const child of node.childNodes) {
for (const child of node.childNodes) { if (!(child instanceof Element)) continue;
if (!(child instanceof Element)) continue fixContent(editor, child);
fixContent(editor, child) }
} }
}
} }
// Esta funcion hace que los elementos del editor sigan la estructura. // 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: // Edge cases:
// * no borramos los <br> por que se requieren para que los navegadores // * no borramos los <br> por que se requieren para que los navegadores
// funcionen bien al escribir. no se deberían mostrar de todas maneras // funcionen bien al escribir. no se deberían mostrar de todas maneras
function cleanContent (editor: Editor, node: Element = editor.contentEl): void { function cleanContent(editor: Editor, node: Element = editor.contentEl): void {
const _type = getType(node) const _type = getType(node);
if (!_type) { if (!_type) {
node.parentElement?.removeChild(node) node.parentElement?.removeChild(node);
return return;
} }
const { type } = _type const { type } = _type;
if (type.allowedChildren !== 'ignore-children') { if (type.allowedChildren !== "ignore-children") {
for (const child of node.childNodes) { for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE if (
&& !type.allowedChildren.includes('text') child.nodeType === Node.TEXT_NODE &&
) { !type.allowedChildren.includes("text")
node.removeChild(child) ) {
continue node.removeChild(child);
} continue;
}
if (!(child instanceof Element)) continue if (!(child instanceof Element)) continue;
const childType = getType(child) const childType = getType(child);
if (childType?.typeName === 'br') continue if (childType?.typeName === "br") continue;
if (!childType || !type.allowedChildren.includes(childType.typeName)) { if (!childType || !type.allowedChildren.includes(childType.typeName)) {
// XXX: esto extrae las cosas de adentro para que no sea destructivo // XXX: esto extrae las cosas de adentro para que no sea destructivo
moveChildren(child, node, child) moveChildren(child, node, child);
node.removeChild(child) node.removeChild(child);
return return;
} }
cleanContent(editor, child) cleanContent(editor, child);
} }
// solo contar children válido para ese nodo // solo contar children válido para ese nodo
const validChildrenLength = getValidChildren(node, type).length const validChildrenLength = getValidChildren(node, type).length;
const sel = safeGetSelection(editor) const sel = safeGetSelection(editor);
const range = sel && safeGetRangeAt(sel) const range = sel && safeGetRangeAt(sel);
if (type.handleEmpty === 'remove' if (
&& validChildrenLength == 0 type.handleEmpty === "remove" &&
//&& (!range || !range.intersectsNode(node)) validChildrenLength == 0
) { //&& (!range || !range.intersectsNode(node))
node.parentNode?.removeChild(node) ) {
return node.parentNode?.removeChild(node);
} return;
} }
}
} }
function routine (editor: Editor): void { function routine(editor: Editor): void {
try { try {
fixContent(editor) fixContent(editor);
cleanContent(editor) cleanContent(editor);
storeContent(editor) storeContent(editor);
editor.htmlEl.value = editor.contentEl.innerHTML editor.htmlEl.value = editor.contentEl.innerHTML;
} catch (error) { } catch (error) {
console.error('Hubo un problema corriendo la rutina', editor, error) console.error("Hubo un problema corriendo la rutina", editor, error);
} }
} }
export interface Editor { export interface Editor {
editorEl: HTMLElement, editorEl: HTMLElement;
toolbarEl: HTMLElement, toolbarEl: HTMLElement;
toolbar: { toolbar: {
auxiliary: { auxiliary: {
mark: { mark: {
parentEl: HTMLElement, parentEl: HTMLElement;
colorEl: HTMLInputElement, colorEl: HTMLInputElement;
}, };
multimedia: { multimedia: {
parentEl: HTMLElement, parentEl: HTMLElement;
fileEl: HTMLInputElement, fileEl: HTMLInputElement;
uploadEl: HTMLButtonElement, uploadEl: HTMLButtonElement;
altEl: HTMLInputElement, altEl: HTMLInputElement;
removeEl: HTMLButtonElement, removeEl: HTMLButtonElement;
}, };
link: { link: {
parentEl: HTMLElement, parentEl: HTMLElement;
urlEl: HTMLInputElement, urlEl: HTMLInputElement;
}, };
}, };
}, };
contentEl: HTMLElement, contentEl: HTMLElement;
wordAlertEl: HTMLElement, wordAlertEl: HTMLElement;
htmlEl: HTMLTextAreaElement, htmlEl: HTMLTextAreaElement;
} }
function getSel<T extends Element>(parentEl: HTMLElement, selector: string): T { function getSel<T extends Element>(parentEl: HTMLElement, selector: string): T {
const el = parentEl.querySelector<T>(selector) const el = parentEl.querySelector<T>(selector);
if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``) if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``);
return el return el;
} }
function setupEditor (editorEl: HTMLElement): void { function setupEditor(editorEl: HTMLElement): void {
// XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor? // XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor?
document.execCommand('defaultParagraphSeparator', false, 'p') document.execCommand("defaultParagraphSeparator", false, "p");
const editor: Editor = { const editor: Editor = {
editorEl, editorEl,
toolbarEl: getSel(editorEl, '.editor-toolbar'), toolbarEl: getSel(editorEl, ".editor-toolbar"),
toolbar: { toolbar: {
auxiliary: { auxiliary: {
mark: { mark: {
parentEl: getSel(editorEl, '[data-editor-auxiliary=mark]'), parentEl: getSel(editorEl, "[data-editor-auxiliary=mark]"),
colorEl: getSel(editorEl, '[data-editor-auxiliary=mark] [name=mark-color]'), colorEl: getSel(
}, editorEl,
multimedia: { "[data-editor-auxiliary=mark] [name=mark-color]"
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]'), multimedia: {
altEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-alt]'), parentEl: getSel(editorEl, "[data-editor-auxiliary=multimedia]"),
removeEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-remove]'), fileEl: getSel(
}, editorEl,
link: { "[data-editor-auxiliary=multimedia] [name=multimedia-file]"
parentEl: getSel(editorEl, '[data-editor-auxiliary=link]'), ),
urlEl: getSel(editorEl, '[data-editor-auxiliary=link] [name=link-url]'), uploadEl: getSel(
}, editorEl,
}, "[data-editor-auxiliary=multimedia] [name=multimedia-file-upload]"
}, ),
contentEl: getSel(editorEl, '.editor-content'), altEl: getSel(
wordAlertEl: getSel(editorEl, '.editor-aviso-word'), editorEl,
htmlEl: getSel(editorEl, 'textarea'), "[data-editor-auxiliary=multimedia] [name=multimedia-alt]"
} ),
console.debug('iniciando editor', editor) 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 // 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 // de última edición podríamos saber si el artículo fue editado
// después o la versión local es la última. // después o la versión local es la última.
// //
// TODO: Preguntar si se lo quiere recuperar. // TODO: Preguntar si se lo quiere recuperar.
restoreContent(editor) restoreContent(editor);
// Word alert // Word alert
editor.contentEl.addEventListener('paste', () => { editor.contentEl.addEventListener("paste", () => {
editor.wordAlertEl.style.display = 'block' editor.wordAlertEl.style.display = "block";
}) });
// Setup routine listeners // Setup routine listeners
const observer = new MutationObserver(() => routine(editor)) const observer = new MutationObserver(() => routine(editor));
observer.observe(editor.contentEl, { observer.observe(editor.contentEl, {
childList: true, childList: true,
attributes: true, attributes: true,
subtree: true, subtree: true,
characterData: true, characterData: true,
}) });
document.addEventListener("selectionchange", () => routine(editor)) document.addEventListener("selectionchange", () => routine(editor));
// Capture onClick // Capture onClick
editor.contentEl.addEventListener('click', event => { editor.contentEl.addEventListener(
const target = event.target! as Element "click",
const type = getType(target) (event) => {
if (!type || !type.type.onClick) { const target = event.target! as Element;
setAuxiliaryToolbar(editor, null) const type = getType(target);
clearSelected(editor) if (!type || !type.type.onClick) {
return true setAuxiliaryToolbar(editor, null);
} clearSelected(editor);
type.type.onClick(editor, target) return true;
return false }
}, true) type.type.onClick(editor, target);
return false;
},
true
);
// Clean seleted // Clean seleted
const selectedEl = editor.contentEl.querySelector('[data-editor-selected]') const selectedEl = editor.contentEl.querySelector("[data-editor-selected]");
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected;
// Setup botones // Setup botones
setupMarksButtons(editor) setupMarksButtons(editor);
setupBlocksButtons(editor) setupBlocksButtons(editor);
setupParentBlocksButtons(editor) setupParentBlocksButtons(editor);
setupMultimediaButtons(editor) setupMultimediaButtons(editor);
setupLinkAuxiliaryToolbar(editor) setupLinkAuxiliaryToolbar(editor);
setupMultimediaAuxiliaryToolbar(editor) setupMultimediaAuxiliaryToolbar(editor);
setupMarkAuxiliaryToolbar(editor) setupMarkAuxiliaryToolbar(editor);
// Finally... // Finally...
routine(editor) routine(editor);
} }
document.addEventListener("turbolinks:load", () => { document.addEventListener("turbolinks:load", () => {
const flash = document.querySelector<HTMLElement>('.js-flash') const flash = document.querySelector<HTMLElement>(".js-flash");
if (flash) { if (flash) {
const keys = JSON.parse(flash.dataset.keys || '[]') const keys = JSON.parse(flash.dataset.keys || "[]");
switch (flash.dataset.target) { switch (flash.dataset.target) {
case 'editor': case "editor":
switch (flash.dataset.action) { switch (flash.dataset.action) {
case 'forget-content': case "forget-content":
keys.forEach(forgetContent) keys.forEach(forgetContent);
} }
} }
} }
for (const editorEl of document.querySelectorAll<HTMLElement>('.editor[data-editor]')) { for (const editorEl of document.querySelectorAll<HTMLElement>(
try { ".editor[data-editor]"
setupEditor(editorEl) )) {
} catch (error) { try {
// TODO: mostrar error setupEditor(editorEl);
console.error('no se pudo iniciar el editor, error completo', error) } catch (error) {
} // TODO: mostrar error
} console.error("no se pudo iniciar el editor, error completo", error);
}) }
}
});

View file

@ -1,4 +1,4 @@
import { Editor } from 'editor/editor' import { Editor } from "editor/editor";
/* /*
* Guarda una copia local de los cambios para poder recuperarlos * 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. * Usamos la URL completa sin anchors.
*/ */
function getStorageKey (editor: Editor): string { function getStorageKey(editor: Editor): string {
const keyEl = editor.editorEl.querySelector<HTMLInputElement>('[data-target="storage-key"]') const keyEl = editor.editorEl.querySelector<HTMLInputElement>(
if (!keyEl) throw new Error('No encuentro la llave para guardar los artículos') '[data-target="storage-key"]'
return keyEl.value );
if (!keyEl)
throw new Error("No encuentro la llave para guardar los artículos");
return keyEl.value;
} }
export function forgetContent (storedKey: string): void { export function forgetContent(storedKey: string): void {
window.localStorage.removeItem(storedKey) window.localStorage.removeItem(storedKey);
} }
export function storeContent (editor: Editor): void { export function storeContent(editor: Editor): void {
if (editor.contentEl.innerText.trim().length === 0) return 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 { export function restoreContent(editor: Editor): void {
const content = window.localStorage.getItem(getStorageKey(editor)) const content = window.localStorage.getItem(getStorageKey(editor));
if (!content) return if (!content) return;
if (content.trim().length === 0) return if (content.trim().length === 0) return;
editor.contentEl.innerHTML = content editor.contentEl.innerHTML = content;
} }

View file

@ -1,126 +1,140 @@
import { Editor } from 'editor/editor' import { Editor } from "editor/editor";
import { marks } from 'editor/types/marks' import { marks } from "editor/types/marks";
import { blocks, li, EditorBlock } from 'editor/types/blocks' import { blocks, li, EditorBlock } from "editor/types/blocks";
import { parentBlocks } from 'editor/types/parentBlocks' import { parentBlocks } from "editor/types/parentBlocks";
import { multimedia } from 'editor/types/multimedia' import { multimedia } from "editor/types/multimedia";
import { blockNames, parentBlockNames, safeGetRangeAt, safeGetSelection } from 'editor/utils' import {
blockNames,
parentBlockNames,
safeGetRangeAt,
safeGetSelection,
} from "editor/utils";
export interface EditorNode { export interface EditorNode {
selector: string, selector: string;
// la string es el nombre en la gran lista de types O 'text' // 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, // 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 // quizás podemos hacer que esto sea una función que retorna bool
allowedChildren: string[] | 'ignore-children', allowedChildren: string[] | "ignore-children";
// * si es 'do-nothing', no hace nada si está vacío (esto es para cuando // * 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) // permitís 'text' entonces se puede tipear adentro, ej: párrafo vacío)
// * si es 'remove', sacamos el coso si está vacío. // * si es 'remove', sacamos el coso si está vacío.
// ej: strong: { handleNothing: 'remove' } // ej: strong: { handleNothing: 'remove' }
// * si es un block, insertamos el bloque y movemos la selección ahí // * si es un block, insertamos el bloque y movemos la selección ahí
// ej: ul: { handleNothing: li } // ej: ul: { handleNothing: li }
handleEmpty: 'do-nothing' | 'remove' | EditorBlock, handleEmpty: "do-nothing" | "remove" | EditorBlock;
// esta función puede ser llamada para cosas que no necesariamente sea la // 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 // 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 // el formato. esto es importante por que, por ejemplo, no deberíamos
// cambiar la selección acá. // cambiar la selección acá.
create: (editor: Editor) => HTMLElement, create: (editor: Editor) => HTMLElement;
onClick?: (editor: Editor, target: Element) => void, onClick?: (editor: Editor, target: Element) => void;
} }
export const types: { [propName: string]: EditorNode } = { export const types: { [propName: string]: EditorNode } = {
...marks, ...marks,
...blocks, ...blocks,
li, li,
...parentBlocks, ...parentBlocks,
contentEl: { contentEl: {
selector: '.editor-content', selector: ".editor-content",
allowedChildren: [...blockNames, ...parentBlockNames, 'multimedia'], allowedChildren: [...blockNames, ...parentBlockNames, "multimedia"],
handleEmpty: blocks.paragraph, handleEmpty: blocks.paragraph,
create: () => { throw new Error('se intentó crear contentEl') } create: () => {
}, throw new Error("se intentó crear contentEl");
br: { },
selector: 'br', },
allowedChildren: [], br: {
handleEmpty: 'do-nothing', selector: "br",
create: () => { throw new Error('se intentó crear br') } allowedChildren: [],
}, handleEmpty: "do-nothing",
multimedia, create: () => {
} throw new Error("se intentó crear br");
},
},
multimedia,
};
export function getType (node: Element): { typeName: string, type: EditorNode } | null { export function getType(
for (let [typeName, type] of Object.entries(types)) { node: Element
if (node.matches(type.selector)) { ): { typeName: string; type: EditorNode } | null {
return { typeName, type } for (let [typeName, type] of Object.entries(types)) {
} if (node.matches(type.selector)) {
} return { typeName, type };
}
return null }
return null;
} }
// encuentra el primer pariente que pueda tener al type, y retorna un array // encuentra el primer pariente que pueda tener al type, y retorna un array
// donde // donde
// array[0] = elemento que matchea el type // array[0] = elemento que matchea el type
// array[array.len - 1] = primer elemento seleccionado // array[array.len - 1] = primer elemento seleccionado
export function getValidParentInSelection (args: { export function getValidParentInSelection(args: {
editor: Editor, editor: Editor;
type: string, type: string;
}): Element[] { }): Element[] {
const sel = safeGetSelection(args.editor) const sel = safeGetSelection(args.editor);
if (!sel) throw new Error('No se donde insertar esto') if (!sel) throw new Error("No se donde insertar esto");
const range = safeGetRangeAt(sel) const range = safeGetRangeAt(sel);
if (!range) throw new Error('No se donde insertar esto') if (!range) throw new Error("No se donde insertar esto");
let list: Element[] = [] 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) { if (!sel.anchorNode) {
const el = list[0] throw new Error("No se donde insertar esto");
if (!args.editor.contentEl.contains(el) } else if (sel.anchorNode instanceof Element) {
&& el != args.editor.contentEl) list = [sel.anchorNode];
throw new Error('No se donde insertar esto') } else if (sel.anchorNode.parentElement) {
const type = getType(el) list = [sel.anchorNode.parentElement];
} else {
throw new Error("No se donde insertar esto");
}
if (type) { while (true) {
//if (type.typeName === 'contentEl') break const el = list[0];
//if (parentBlockNames.includes(type.typeName)) break if (!args.editor.contentEl.contains(el) && el != args.editor.contentEl)
if ((type.type.allowedChildren instanceof Array) throw new Error("No se donde insertar esto");
&& type.type.allowedChildren.includes(args.type)) break const type = getType(el);
}
if (el.parentElement) { if (type) {
list = [el.parentElement, ...list] //if (type.typeName === 'contentEl') break
} else { //if (parentBlockNames.includes(type.typeName)) break
throw new Error('No se donde insertar esto') if (
} type.type.allowedChildren instanceof Array &&
} type.type.allowedChildren.includes(args.type)
)
return list 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[] { export function getValidChildren(node: Element, type: EditorNode): Node[] {
if (type.allowedChildren === 'ignore-children') if (type.allowedChildren === "ignore-children")
throw new Error('se llamó a getValidChildren con un type que no lo permite!') throw new Error(
return [...node.childNodes].filter(n => { "se llamó a getValidChildren con un type que no lo permite!"
// si permite texto y esto es un texto, es válido );
if (n.nodeType === Node.TEXT_NODE) return [...node.childNodes].filter((n) => {
return type.allowedChildren.includes('text') && n.textContent?.length // 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 // si no es un elemento, no es válido
if (!(n instanceof Element)) if (!(n instanceof Element)) return false;
return false
const t = getType(n) const t = getType(n);
if (!t) return false if (!t) return false;
return type.allowedChildren.includes(t.typeName) return type.allowedChildren.includes(t.typeName);
}) });
} }

View file

@ -1,72 +1,76 @@
import { Editor } from 'editor/editor' import { Editor } from "editor/editor";
import { import {
safeGetSelection, safeGetRangeAt, safeGetSelection,
moveChildren, safeGetRangeAt,
markNames, blockNames, parentBlockNames, moveChildren,
} from 'editor/utils' markNames,
import { EditorNode, getType, getValidParentInSelection } from 'editor/types' 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 { export const li: EditorBlock = makeBlock("li");
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 // XXX: si agregás algo acá, agregalo a blockNames
// (y probablemente le quieras hacer un botón en app/views/posts/attributes/_content.haml) // (y probablemente le quieras hacer un botón en app/views/posts/attributes/_content.haml)
export const blocks: { [propName: string]: EditorBlock } = { export const blocks: { [propName: string]: EditorBlock } = {
paragraph: makeBlock('p'), paragraph: makeBlock("p"),
h1: makeBlock('h1'), h1: makeBlock("h1"),
h2: makeBlock('h2'), h2: makeBlock("h2"),
h3: makeBlock('h3'), h3: makeBlock("h3"),
h4: makeBlock('h4'), h4: makeBlock("h4"),
h5: makeBlock('h5'), h5: makeBlock("h5"),
h6: makeBlock('h6'), h6: makeBlock("h6"),
unordered_list: { unordered_list: {
...makeBlock('ul'), ...makeBlock("ul"),
allowedChildren: ['li'], allowedChildren: ["li"],
handleEmpty: li, handleEmpty: li,
}, },
ordered_list: { ordered_list: {
...makeBlock('ol'), ...makeBlock("ol"),
allowedChildren: ['li'], allowedChildren: ["li"],
handleEmpty: li, handleEmpty: li,
}, },
} };
export function setupButtons (editor: Editor): void { export function setupButtons(editor: Editor): void {
for (const [ name, type ] of Object.entries(blocks)) { for (const [name, type] of Object.entries(blocks)) {
const buttonEl = editor.toolbarEl.querySelector(`[data-editor-button="block-${name}"]`) const buttonEl = editor.toolbarEl.querySelector(
if (!buttonEl) continue `[data-editor-button="block-${name}"]`
buttonEl.addEventListener("click", event => { );
event.preventDefault() if (!buttonEl) continue;
buttonEl.addEventListener("click", (event) => {
const list = getValidParentInSelection({ editor, type: name }) event.preventDefault();
// No borrar cosas como multimedia const list = getValidParentInSelection({ editor, type: name });
if (blockNames.indexOf(getType(list[1])!.typeName) === -1) {
return // No borrar cosas como multimedia
} if (blockNames.indexOf(getType(list[1])!.typeName) === -1) {
return;
let replacementType = list[1].matches(type.selector) }
? blocks.paragraph
: type let replacementType = list[1].matches(type.selector)
? blocks.paragraph
const el = replacementType.create(editor) : type;
replacementType.onClick && replacementType.onClick(editor, el)
moveChildren(list[1], el, null) const el = replacementType.create(editor);
list[0].replaceChild(el, list[1]) replacementType.onClick && replacementType.onClick(editor, el);
window.getSelection()?.collapse(el) moveChildren(list[1], el, null);
list[0].replaceChild(el, list[1]);
return false window.getSelection()?.collapse(el);
})
} return false;
});
}
} }

View file

@ -1,37 +1,37 @@
import { Editor } from 'editor/editor' import { Editor } from "editor/editor";
import { EditorNode } from 'editor/types' import { EditorNode } from "editor/types";
import { markNames, setAuxiliaryToolbar, clearSelected } from 'editor/utils' import { markNames, setAuxiliaryToolbar, clearSelected } from "editor/utils";
function select (editor: Editor, el: HTMLAnchorElement): void { function select(editor: Editor, el: HTMLAnchorElement): void {
clearSelected(editor) clearSelected(editor);
el.dataset.editorSelected = '' el.dataset.editorSelected = "";
editor.toolbar.auxiliary.link.urlEl.value = el.href editor.toolbar.auxiliary.link.urlEl.value = el.href;
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.link.parentEl) setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.link.parentEl);
} }
export const link: EditorNode = { export const link: EditorNode = {
selector: 'a', selector: "a",
allowedChildren: [...markNames.filter(n => n !== 'link'), 'text'], allowedChildren: [...markNames.filter((n) => n !== "link"), "text"],
handleEmpty: 'remove', handleEmpty: "remove",
create: () => document.createElement('a'), create: () => document.createElement("a"),
onClick (editor, el) { onClick(editor, el) {
if (!(el instanceof HTMLAnchorElement)) if (!(el instanceof HTMLAnchorElement)) throw new Error("oh no");
throw new Error('oh no') select(editor, el);
select(editor, el) },
}, };
}
export function setupAuxiliaryToolbar (editor: Editor): void { export function setupAuxiliaryToolbar(editor: Editor): void {
editor.toolbar.auxiliary.link.urlEl.addEventListener('input', event => { editor.toolbar.auxiliary.link.urlEl.addEventListener("input", (event) => {
const url = editor.toolbar.auxiliary.link.urlEl.value const url = editor.toolbar.auxiliary.link.urlEl.value;
const selectedEl = editor.contentEl const selectedEl = editor.contentEl.querySelector<HTMLAnchorElement>(
.querySelector<HTMLAnchorElement>('a[data-editor-selected]') "a[data-editor-selected]"
if (!selectedEl) );
throw new Error('No pude encontrar el link para setear el enlace') if (!selectedEl)
throw new Error("No pude encontrar el link para setear el enlace");
selectedEl.href = url
}) selectedEl.href = url;
editor.toolbar.auxiliary.link.urlEl.addEventListener('keydown', event => { });
if (event.keyCode == 13) event.preventDefault() editor.toolbar.auxiliary.link.urlEl.addEventListener("keydown", (event) => {
}) if (event.keyCode == 13) event.preventDefault();
});
} }

View file

@ -1,49 +1,48 @@
import { Editor } from 'editor/editor' import { Editor } from "editor/editor";
import { EditorNode } from 'editor/types' import { EditorNode } from "editor/types";
import { markNames, setAuxiliaryToolbar, clearSelected } from 'editor/utils' 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 // https://stackoverflow.com/a/3627747
// TODO: cambiar por una solución más copada // TODO: cambiar por una solución más copada
function rgbToHex (rgb: string): string { function rgbToHex(rgb: string): string {
const matches = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/) const matches = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
if (!matches) throw new Error('no pude parsear el rgb()') if (!matches) throw new Error("no pude parsear el rgb()");
return "#" + hex(matches[1]) + hex(matches[2]) + hex(matches[3]) return "#" + hex(matches[1]) + hex(matches[2]) + hex(matches[3]);
} }
function select (editor: Editor, el: HTMLElement): void { function select(editor: Editor, el: HTMLElement): void {
clearSelected(editor) clearSelected(editor);
el.dataset.editorSelected = '' el.dataset.editorSelected = "";
editor.toolbar.auxiliary.mark.colorEl.value editor.toolbar.auxiliary.mark.colorEl.value = el.style.backgroundColor
= el.style.backgroundColor ? rgbToHex(el.style.backgroundColor)
? rgbToHex(el.style.backgroundColor) : "#f206f9";
: '#f206f9' setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.mark.parentEl);
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.mark.parentEl)
} }
export const mark: EditorNode = { export const mark: EditorNode = {
selector: 'mark', selector: "mark",
allowedChildren: [...markNames.filter(n => n !== 'mark'), 'text'], allowedChildren: [...markNames.filter((n) => n !== "mark"), "text"],
handleEmpty: 'remove', handleEmpty: "remove",
create: () => document.createElement('mark'), create: () => document.createElement("mark"),
onClick (editor, el) { onClick(editor, el) {
if (!(el instanceof HTMLElement)) if (!(el instanceof HTMLElement)) throw new Error("oh no");
throw new Error('oh no') select(editor, el);
select(editor, el) },
}, };
}
export function setupAuxiliaryToolbar (editor: Editor): void { export function setupAuxiliaryToolbar(editor: Editor): void {
editor.toolbar.auxiliary.mark.colorEl.addEventListener('input', event => { editor.toolbar.auxiliary.mark.colorEl.addEventListener("input", (event) => {
const color = editor.toolbar.auxiliary.mark.colorEl.value const color = editor.toolbar.auxiliary.mark.colorEl.value;
const selectedEl = editor.contentEl const selectedEl = editor.contentEl.querySelector<HTMLElement>(
.querySelector<HTMLElement>('mark[data-editor-selected]') "mark[data-editor-selected]"
if (!selectedEl) );
throw new Error('No pude encontrar el mark para setear el color') if (!selectedEl)
throw new Error("No pude encontrar el mark para setear el color");
selectedEl.style.backgroundColor = color
}) selectedEl.style.backgroundColor = color;
editor.toolbar.auxiliary.mark.colorEl.addEventListener('keydown', event => { });
if (event.keyCode == 13) event.preventDefault() editor.toolbar.auxiliary.mark.colorEl.addEventListener("keydown", (event) => {
}) if (event.keyCode == 13) event.preventDefault();
});
} }

View file

@ -1,96 +1,102 @@
import { Editor } from 'editor/editor' import { Editor } from "editor/editor";
import { EditorNode } from 'editor/types' import { EditorNode } from "editor/types";
import { import {
safeGetSelection, safeGetRangeAt, safeGetSelection,
moveChildren, safeGetRangeAt,
markNames, moveChildren,
} from 'editor/utils' markNames,
import { link } from 'editor/types/link' } from "editor/utils";
import { mark } from 'editor/types/mark' import { link } from "editor/types/link";
import { mark } from "editor/types/mark";
function makeMark (name: string, tag: string): EditorNode { function makeMark(name: string, tag: string): EditorNode {
return { return {
selector: tag, selector: tag,
allowedChildren: [...markNames.filter(n => n !== name), 'text'], allowedChildren: [...markNames.filter((n) => n !== name), "text"],
handleEmpty: 'remove', handleEmpty: "remove",
create: () => document.createElement(tag), create: () => document.createElement(tag),
} };
} }
// XXX: si agregás algo acá, agregalo a markNames // XXX: si agregás algo acá, agregalo a markNames
export const marks: { [propName: string]: EditorNode } = { export const marks: { [propName: string]: EditorNode } = {
bold: makeMark('bold', 'strong'), bold: makeMark("bold", "strong"),
italic: makeMark('italic', 'em'), italic: makeMark("italic", "em"),
deleted: makeMark('deleted', 'del'), deleted: makeMark("deleted", "del"),
underline: makeMark('underline', 'u'), underline: makeMark("underline", "u"),
sub: makeMark('sub', 'sub'), sub: makeMark("sub", "sub"),
super: makeMark('super', 'sup'), super: makeMark("super", "sup"),
mark, mark,
link, link,
small: makeMark('small', 'small'), small: makeMark("small", "small"),
} };
function recursiveFilterSelection ( function recursiveFilterSelection(
node: Element, node: Element,
selection: Selection, selection: Selection,
selector: string, selector: string
): Element[] { ): Element[] {
let output: Element[] = [] let output: Element[] = [];
for (const child of [...node.children]) { for (const child of [...node.children]) {
if (child.matches(selector) if (child.matches(selector) && selection.containsNode(child))
&& selection.containsNode(child) output.push(child);
) output.push(child) output = [
output = [...output, ...recursiveFilterSelection(child, selection, selector)] ...output,
} ...recursiveFilterSelection(child, selection, selector),
return output ];
}
return output;
} }
export function setupButtons (editor: Editor): void { export function setupButtons(editor: Editor): void {
for (const [ name, type ] of Object.entries(marks)) { for (const [name, type] of Object.entries(marks)) {
const buttonEl = editor.toolbarEl.querySelector(`[data-editor-button="mark-${name}"]`) const buttonEl = editor.toolbarEl.querySelector(
if (!buttonEl) continue `[data-editor-button="mark-${name}"]`
buttonEl.addEventListener("click", event => { );
event.preventDefault() if (!buttonEl) continue;
buttonEl.addEventListener("click", (event) => {
event.preventDefault();
const sel = safeGetSelection(editor) const sel = safeGetSelection(editor);
if (!sel) return if (!sel) return;
const range = safeGetRangeAt(sel) const range = safeGetRangeAt(sel);
if (!range) return if (!range) return;
let parentEl = range.commonAncestorContainer let parentEl = range.commonAncestorContainer;
while (!(parentEl instanceof Element)) { while (!(parentEl instanceof Element)) {
if (!parentEl.parentElement) return if (!parentEl.parentElement) return;
parentEl = parentEl.parentElement parentEl = parentEl.parentElement;
} }
const existingMarks = recursiveFilterSelection( const existingMarks = recursiveFilterSelection(
parentEl, parentEl,
sel, sel,
type.selector, type.selector
) );
console.debug('marks encontradas:', existingMarks) console.debug("marks encontradas:", existingMarks);
if (existingMarks.length > 0) { if (existingMarks.length > 0) {
const mark = existingMarks[0] const mark = existingMarks[0];
if (!mark.parentElement) if (!mark.parentElement) throw new Error(":/");
throw new Error(':/') moveChildren(mark, mark.parentElement, mark);
moveChildren(mark, mark.parentElement, mark) mark.parentElement.removeChild(mark);
mark.parentElement.removeChild(mark) } else {
} else { if (range.commonAncestorContainer === editor.contentEl)
if (range.commonAncestorContainer === editor.contentEl) // TODO: mostrar error
// TODO: mostrar error return console.error(
return console.error("No puedo marcar cosas a través de distintos bloques!") "No puedo marcar cosas a través de distintos bloques!"
);
const tagEl = type.create(editor) const tagEl = type.create(editor);
type.onClick && type.onClick(editor, tagEl) type.onClick && type.onClick(editor, tagEl);
tagEl.appendChild(range.extractContents()) tagEl.appendChild(range.extractContents());
range.insertNode(tagEl) range.insertNode(tagEl);
range.selectNode(tagEl) range.selectNode(tagEl);
} }
return false return false;
}) });
} }
} }

View file

@ -1,206 +1,230 @@
import * as ActiveStorage from '@rails/activestorage' import * as ActiveStorage from "@rails/activestorage";
import { Editor } from 'editor/editor' import { Editor } from "editor/editor";
import { EditorNode, getValidParentInSelection } from 'editor/types' import { EditorNode, getValidParentInSelection } from "editor/types";
import { import {
safeGetSelection, safeGetRangeAt, safeGetSelection,
markNames, parentBlockNames, safeGetRangeAt,
setAuxiliaryToolbar, clearSelected, markNames,
} from 'editor/utils' parentBlockNames,
setAuxiliaryToolbar,
clearSelected,
} from "editor/utils";
function uploadFile (file: File): Promise<string> { function uploadFile(file: File): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const upload = new ActiveStorage.DirectUpload( const upload = new ActiveStorage.DirectUpload(
file, file,
origin + '/rails/active_storage/direct_uploads', origin + "/rails/active_storage/direct_uploads"
) );
upload.create((error: any, blob: any) => { upload.create((error: any, blob: any) => {
if (error) { if (error) {
reject(error) reject(error);
} else { } else {
const url = `${origin}/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}` const url = `${origin}/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}`;
resolve(url) resolve(url);
} }
}) });
}) });
} }
function getAlt (multimediaInnerEl: HTMLElement): string | null { function getAlt(multimediaInnerEl: HTMLElement): string | null {
switch (multimediaInnerEl.tagName) { switch (multimediaInnerEl.tagName) {
case 'VIDEO': case "VIDEO":
case 'AUDIO': case "AUDIO":
return multimediaInnerEl.getAttribute('aria-label') return multimediaInnerEl.getAttribute("aria-label");
case 'IMG': case "IMG":
return (multimediaInnerEl as HTMLImageElement).alt return (multimediaInnerEl as HTMLImageElement).alt;
case 'IFRAME': case "IFRAME":
return multimediaInnerEl.title return multimediaInnerEl.title;
default: default:
throw new Error('no pude conseguir el alt') throw new Error("no pude conseguir el alt");
} }
} }
function setAlt (multimediaInnerEl: HTMLElement, value: string): void { function setAlt(multimediaInnerEl: HTMLElement, value: string): void {
switch (multimediaInnerEl.tagName) { switch (multimediaInnerEl.tagName) {
case 'VIDEO': case "VIDEO":
case 'AUDIO': case "AUDIO":
multimediaInnerEl.setAttribute('aria-label', value) multimediaInnerEl.setAttribute("aria-label", value);
break break;
case 'IMG': case "IMG":
(multimediaInnerEl as HTMLImageElement).alt = value (multimediaInnerEl as HTMLImageElement).alt = value;
break break;
case 'IFRAME': case "IFRAME":
multimediaInnerEl.title = value multimediaInnerEl.title = value;
break break;
default: default:
throw new Error('no pude setear el alt') throw new Error("no pude setear el alt");
} }
} }
function select (editor: Editor, el: HTMLElement): void { function select(editor: Editor, el: HTMLElement): void {
clearSelected(editor) clearSelected(editor);
el.dataset.editorSelected = '' el.dataset.editorSelected = "";
const innerEl = el.querySelector<HTMLElement>('[data-multimedia-inner]') const innerEl = el.querySelector<HTMLElement>("[data-multimedia-inner]");
if (!innerEl) throw new Error('No hay multimedia válida') if (!innerEl) throw new Error("No hay multimedia válida");
if (innerEl.tagName === "P") { if (innerEl.tagName === "P") {
editor.toolbar.auxiliary.multimedia.altEl.value = ""; editor.toolbar.auxiliary.multimedia.altEl.value = "";
editor.toolbar.auxiliary.multimedia.altEl.disabled = true; editor.toolbar.auxiliary.multimedia.altEl.disabled = true;
} else { } else {
editor.toolbar.auxiliary.multimedia.altEl.value = getAlt(innerEl) || ""; editor.toolbar.auxiliary.multimedia.altEl.value = getAlt(innerEl) || "";
editor.toolbar.auxiliary.multimedia.altEl.disabled = false; 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 = { export const multimedia: EditorNode = {
selector: 'figure[data-multimedia]', selector: "figure[data-multimedia]",
allowedChildren: 'ignore-children', allowedChildren: "ignore-children",
handleEmpty: 'remove', handleEmpty: "remove",
create: () => { create: () => {
const figureEl = document.createElement('figure') const figureEl = document.createElement("figure");
figureEl.dataset.multimedia = '' figureEl.dataset.multimedia = "";
figureEl.contentEditable = 'false' figureEl.contentEditable = "false";
const placeholderEl = document.createElement('p') const placeholderEl = document.createElement("p");
placeholderEl.dataset.multimediaInner = '' placeholderEl.dataset.multimediaInner = "";
// TODO i18n // TODO i18n
placeholderEl.append('¡Clickeame para subir un archivo!') placeholderEl.append("¡Clickeame para subir un archivo!");
figureEl.appendChild(placeholderEl) figureEl.appendChild(placeholderEl);
const descriptionEl = document.createElement('figcaption') const descriptionEl = document.createElement("figcaption");
descriptionEl.contentEditable = 'true' descriptionEl.contentEditable = "true";
// TODO i18n // TODO i18n
descriptionEl.append('Escribí acá la descripción del archivo.') descriptionEl.append("Escribí acá la descripción del archivo.");
figureEl.appendChild(descriptionEl) figureEl.appendChild(descriptionEl);
return figureEl return figureEl;
}, },
onClick (editor, el) { onClick(editor, el) {
if (!(el instanceof HTMLElement)) if (!(el instanceof HTMLElement)) throw new Error("oh no");
throw new Error('oh no') select(editor, el);
select(editor, el) },
}, };
} function createElementWithFile(url: string, type: string): HTMLElement {
function createElementWithFile (url: string, type: string): HTMLElement { if (type.match(/^image\/.+$/)) {
if (type.match(/^image\/.+$/)) { const el = document.createElement("img");
const el = document.createElement('img') el.dataset.multimediaInner = "";
el.dataset.multimediaInner = '' el.src = url;
el.src = url return el;
return el } else if (type.match(/^video\/.+$/)) {
} else if (type.match(/^video\/.+$/)) { const el = document.createElement("video");
const el = document.createElement('video') el.controls = true;
el.controls = true el.dataset.multimediaInner = "";
el.dataset.multimediaInner = '' el.src = url;
el.src = url return el;
return el } else if (type.match(/^audio\/.+$/)) {
} else if (type.match(/^audio\/.+$/)) { const el = document.createElement("audio");
const el = document.createElement('audio') el.controls = true;
el.controls = true el.dataset.multimediaInner = "";
el.dataset.multimediaInner = '' el.src = url;
el.src = url return el;
return el } else if (type.match(/^application\/pdf$/)) {
} else if (type.match(/^application\/pdf$/)) { const el = document.createElement("iframe");
const el = document.createElement('iframe') el.dataset.multimediaInner = "";
el.dataset.multimediaInner = '' el.src = url;
el.src = url return el;
return el } else {
} else { // TODO: chequear si el archivo es válido antes de subir
// TODO: chequear si el archivo es válido antes de subir throw new Error("Tipo de archivo no reconocido");
throw new Error('Tipo de archivo no reconocido') }
}
} }
export function setupAuxiliaryToolbar (editor: Editor): void { export function setupAuxiliaryToolbar(editor: Editor): void {
editor.toolbar.auxiliary.multimedia.uploadEl.addEventListener('click', event => { editor.toolbar.auxiliary.multimedia.uploadEl.addEventListener(
const files = editor.toolbar.auxiliary.multimedia.fileEl.files "click",
if (!files || !files.length) throw new Error('no hay archivos para subir') (event) => {
const file = files[0] 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 const selectedEl = editor.contentEl.querySelector<HTMLElement>(
.querySelector<HTMLElement>('figure[data-editor-selected]') "figure[data-editor-selected]"
if (!selectedEl) );
throw new Error('No pude encontrar el elemento para setear el archivo') if (!selectedEl)
throw new Error("No pude encontrar el elemento para setear el archivo");
selectedEl.dataset.editorLoading = '' selectedEl.dataset.editorLoading = "";
uploadFile(file) uploadFile(file)
.then(url => { .then((url) => {
const innerEl = selectedEl.querySelector('[data-multimedia-inner]') const innerEl = selectedEl.querySelector("[data-multimedia-inner]");
if (!innerEl) throw new Error('No hay multimedia a reemplazar') if (!innerEl) throw new Error("No hay multimedia a reemplazar");
const el = createElementWithFile(url, file.type) const el = createElementWithFile(url, file.type);
setAlt(el, editor.toolbar.auxiliary.multimedia.altEl.value) setAlt(el, editor.toolbar.auxiliary.multimedia.altEl.value);
selectedEl.replaceChild(el, innerEl) selectedEl.replaceChild(el, innerEl);
select(editor, selectedEl) select(editor, selectedEl);
delete selectedEl.dataset.editorError delete selectedEl.dataset.editorError;
}) })
.catch(err => { .catch((err) => {
console.error(err) console.error(err);
// TODO: mostrar error // TODO: mostrar error
selectedEl.dataset.editorError = '' selectedEl.dataset.editorError = "";
}) })
.finally(() => { delete selectedEl.dataset.editorLoading }) .finally(() => {
}) delete selectedEl.dataset.editorLoading;
});
}
);
editor.toolbar.auxiliary.multimedia.removeEl.addEventListener('click', event => { editor.toolbar.auxiliary.multimedia.removeEl.addEventListener(
const selectedEl = editor.contentEl "click",
.querySelector<HTMLElement>('figure[data-editor-selected]') (event) => {
if (!selectedEl) const selectedEl = editor.contentEl.querySelector<HTMLElement>(
throw new Error('No pude encontrar el elemento para borrar') "figure[data-editor-selected]"
);
if (!selectedEl)
throw new Error("No pude encontrar el elemento para borrar");
selectedEl.parentElement?.removeChild(selectedEl) selectedEl.parentElement?.removeChild(selectedEl);
setAuxiliaryToolbar(editor, null) setAuxiliaryToolbar(editor, null);
}) }
);
editor.toolbar.auxiliary.multimedia.altEl.addEventListener('input', event => { editor.toolbar.auxiliary.multimedia.altEl.addEventListener(
const selectedEl = editor.contentEl "input",
.querySelector<HTMLAnchorElement>('figure[data-editor-selected]') (event) => {
if (!selectedEl) const selectedEl = editor.contentEl.querySelector<HTMLAnchorElement>(
throw new Error('No pude encontrar el multimedia para setear el alt') "figure[data-editor-selected]"
);
if (!selectedEl)
throw new Error("No pude encontrar el multimedia para setear el alt");
const innerEl = selectedEl.querySelector<HTMLElement>('[data-multimedia-inner]') const innerEl = selectedEl.querySelector<HTMLElement>(
if (!innerEl) throw new Error('No hay multimedia a para setear el alt') "[data-multimedia-inner]"
);
if (!innerEl) throw new Error("No hay multimedia a para setear el alt");
setAlt(innerEl, editor.toolbar.auxiliary.multimedia.altEl.value) setAlt(innerEl, editor.toolbar.auxiliary.multimedia.altEl.value);
}) }
editor.toolbar.auxiliary.multimedia.altEl.addEventListener('keydown', event => { );
if (event.keyCode == 13) event.preventDefault() editor.toolbar.auxiliary.multimedia.altEl.addEventListener(
}) "keydown",
(event) => {
if (event.keyCode == 13) event.preventDefault();
}
);
} }
export function setupButtons (editor: Editor): void { export function setupButtons(editor: Editor): void {
const buttonEl = editor.toolbarEl.querySelector('[data-editor-button="multimedia"]') const buttonEl = editor.toolbarEl.querySelector(
if (!buttonEl) throw new Error('No encontre el botón de multimedia') '[data-editor-button="multimedia"]'
buttonEl.addEventListener('click', event => { );
event.preventDefault() 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) const el = multimedia.create(editor);
list[0].insertBefore(el, list[1].nextElementSibling) list[0].insertBefore(el, list[1].nextElementSibling);
select(editor, el) select(editor, el);
return false return false;
}) });
} }

View file

@ -1,70 +1,75 @@
import { Editor } from 'editor/editor' import { Editor } from "editor/editor";
import { import {
safeGetSelection, safeGetRangeAt, safeGetSelection,
moveChildren, safeGetRangeAt,
blockNames, parentBlockNames, moveChildren,
} from 'editor/utils' blockNames,
import { EditorNode, getType, getValidParentInSelection } from 'editor/types' parentBlockNames,
} from "editor/utils";
import { EditorNode, getType, getValidParentInSelection } from "editor/types";
function makeParentBlock (tag: string, create: EditorNode["create"]): EditorNode { function makeParentBlock(
return { tag: string,
selector: tag, create: EditorNode["create"]
allowedChildren: [...blockNames, 'multimedia'], ): EditorNode {
handleEmpty: 'remove', return {
create, selector: tag,
} allowedChildren: [...blockNames, "multimedia"],
handleEmpty: "remove",
create,
};
} }
// TODO: añadir blockquote // TODO: añadir blockquote
// XXX: si agregás algo acá, probablemente le quieras hacer un botón // XXX: si agregás algo acá, probablemente le quieras hacer un botón
// en app/views/posts/attributes/_content.haml // en app/views/posts/attributes/_content.haml
export const parentBlocks: { [propName: string]: EditorNode } = { export const parentBlocks: { [propName: string]: EditorNode } = {
left: makeParentBlock('div[data-align=left]', () => { left: makeParentBlock("div[data-align=left]", () => {
const el = document.createElement('div') const el = document.createElement("div");
el.dataset.align = 'left' el.dataset.align = "left";
return el return el;
}), }),
center: makeParentBlock('div[data-align=center]', () => { center: makeParentBlock("div[data-align=center]", () => {
const el = document.createElement('div') const el = document.createElement("div");
el.dataset.align = 'center' el.dataset.align = "center";
return el return el;
}), }),
right: makeParentBlock('div[data-align=right]', () => { right: makeParentBlock("div[data-align=right]", () => {
const el = document.createElement('div') const el = document.createElement("div");
el.dataset.align = 'right' el.dataset.align = "right";
return el return el;
}), }),
} };
export function setupButtons (editor: Editor): void { export function setupButtons(editor: Editor): void {
for (const [ name, type ] of Object.entries(parentBlocks)) { for (const [name, type] of Object.entries(parentBlocks)) {
const buttonEl = editor.toolbarEl.querySelector( const buttonEl = editor.toolbarEl.querySelector(
`[data-editor-button="parentBlock-${name}"]` `[data-editor-button="parentBlock-${name}"]`
) );
if (!buttonEl) continue if (!buttonEl) continue;
buttonEl.addEventListener("click", event => { buttonEl.addEventListener("click", (event) => {
event.preventDefault() event.preventDefault();
// TODO: Esto solo mueve el bloque en el que está el final de la selección // 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 // (anchorNode). quizás lo podemos hacer al revés (iterar desde contentEl
// para encontrar los bloques que están seleccionados y moverlos/cambiarles // para encontrar los bloques que están seleccionados y moverlos/cambiarles
// el parentBlock) // el parentBlock)
const list = getValidParentInSelection({ editor, type: name }) const list = getValidParentInSelection({ editor, type: name });
const replacementEl = type.create(editor) const replacementEl = type.create(editor);
if (list[0] == editor.contentEl) { if (list[0] == editor.contentEl) {
// no está en un parentBlock // no está en un parentBlock
editor.contentEl.insertBefore(replacementEl, list[1]) editor.contentEl.insertBefore(replacementEl, list[1]);
replacementEl.appendChild(list[1]) replacementEl.appendChild(list[1]);
} else { } else {
// está en un parentBlock // está en un parentBlock
moveChildren(list[0], replacementEl, null) moveChildren(list[0], replacementEl, null);
editor.contentEl.replaceChild(replacementEl, list[0]) editor.contentEl.replaceChild(replacementEl, list[0]);
} }
window.getSelection()?.collapse(replacementEl) window.getSelection()?.collapse(replacementEl);
return false return false;
}) });
} }
} }

View file

@ -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 blockNames = [
export const markNames = ['bold', 'italic', 'deleted', 'underline', 'sub', 'super', 'mark', 'link', 'small'] "paragraph",
export const parentBlockNames = ['left', 'center', 'right'] "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) { export function moveChildren(from: Element, to: Element, toRef: Node | null) {
while (from.firstChild) to.insertBefore(from.firstChild, toRef) while (from.firstChild) to.insertBefore(from.firstChild, toRef);
} }
export function isDirectChild (node: Node, supposedChild: Node): boolean { export function isDirectChild(node: Node, supposedChild: Node): boolean {
for (const child of node.childNodes) { for (const child of node.childNodes) {
if (child == supposedChild) return true if (child == supposedChild) return true;
} }
return false return false;
} }
export function safeGetSelection (editor: Editor): Selection | null { export function safeGetSelection(editor: Editor): Selection | null {
const sel = window.getSelection() const sel = window.getSelection();
if (!sel) return null if (!sel) return null;
// XXX: no damos la selección si esta fuera o _es_ el contentEl, ¿quizás // XXX: no damos la selección si esta fuera o _es_ el contentEl, ¿quizás
// deberíamos mostrar un error? // deberíamos mostrar un error?
if ( if (
!editor.contentEl.contains(sel.anchorNode) !editor.contentEl.contains(sel.anchorNode) ||
|| !editor.contentEl.contains(sel.focusNode) !editor.contentEl.contains(sel.focusNode) ||
|| sel.anchorNode == editor.contentEl sel.anchorNode == editor.contentEl ||
|| sel.focusNode == editor.contentEl sel.focusNode == editor.contentEl
) return null )
return sel return null;
return sel;
} }
export function safeGetRangeAt (selection: Selection, num = 0): Range | null { export function safeGetRangeAt(selection: Selection, num = 0): Range | null {
try { try {
return selection.getRangeAt(num) return selection.getRangeAt(num);
} catch (error) { } catch (error) {
return null return null;
} }
} }
interface SplitNode { interface SplitNode {
range: Range, range: Range;
node: Node, node: Node;
} }
export function splitNode (node: Element, range: Range): [SplitNode, SplitNode] { export function splitNode(node: Element, range: Range): [SplitNode, SplitNode] {
const [left, right] = [ const [left, right] = [
{ range: document.createRange(), node: node.cloneNode(false) }, { range: document.createRange(), node: node.cloneNode(false) },
{ range: document.createRange(), node: node.cloneNode(false) }, { range: document.createRange(), node: node.cloneNode(false) },
] ];
if (node.firstChild) left.range.setStartBefore(node.firstChild) if (node.firstChild) left.range.setStartBefore(node.firstChild);
left.range.setEnd(range.startContainer, range.startOffset) left.range.setEnd(range.startContainer, range.startOffset);
left.range.surroundContents(left.node) left.range.surroundContents(left.node);
right.range.setStart(range.endContainer, range.endOffset) right.range.setStart(range.endContainer, range.endOffset);
if (node.lastChild) right.range.setEndAfter(node.lastChild) if (node.lastChild) right.range.setEndAfter(node.lastChild);
right.range.surroundContents(right.node) right.range.surroundContents(right.node);
if (!node.parentElement) if (!node.parentElement)
throw new Error('No pude separar los nodos por que no tiene parentNode') throw new Error("No pude separar los nodos por que no tiene parentNode");
moveChildren(node, node.parentElement, node) moveChildren(node, node.parentElement, node);
node.parentElement.removeChild(node) node.parentElement.removeChild(node);
return [left, right] return [left, right];
} }
export function setAuxiliaryToolbar (editor: Editor, bar: HTMLElement | null): void { export function setAuxiliaryToolbar(
for (const { parentEl } of Object.values(editor.toolbar.auxiliary)) { editor: Editor,
delete parentEl.dataset.editorAuxiliaryActive bar: HTMLElement | null
} ): void {
if (bar) bar.dataset.editorAuxiliaryActive = 'active' 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 { export function clearSelected(editor: Editor): void {
const selectedEl = editor.contentEl.querySelector('[data-editor-selected]') const selectedEl = editor.contentEl.querySelector("[data-editor-selected]");
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected;
} }