From 7e0600779ed259f2bedc3603d5205d18bc21316b Mon Sep 17 00:00:00 2001 From: void Date: Sat, 22 May 2021 21:17:00 +0000 Subject: [PATCH 01/12] usar @suttyweb/editor --- app/javascript/editor/editor.ts | 15 ++- app/views/posts/attributes/_content.haml | 117 +---------------------- package.json | 1 + yarn.lock | 5 + 4 files changed, 20 insertions(+), 118 deletions(-) diff --git a/app/javascript/editor/editor.ts b/app/javascript/editor/editor.ts index 880547de..233cc3c0 100644 --- a/app/javascript/editor/editor.ts +++ b/app/javascript/editor/editor.ts @@ -19,6 +19,10 @@ import { } from "editor/types/multimedia"; import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from "editor/types/mark"; +/// @ts-ignore +import SuttyEditor from "@suttyweb/editor"; +import "@suttyweb/editor/dist/style.css"; + // Esta funcion corrije errores que pueden haber como: // * que un nodo que no tiene 'text' permitido no tenga children (se les // inserta un allowedChildren[0]) @@ -330,10 +334,15 @@ document.addEventListener("turbolinks:load", () => { ".editor[data-editor]" )) { try { - setupEditor(editorEl); + new SuttyEditor({ + target: editorEl, + props: { + textareaEl: editorEl.parentElement!.querySelector("textarea"), + }, + }); } catch (error) { - // TODO: mostrar error - console.error("no se pudo iniciar el editor, error completo", error); + console.error(error); + alert(error); } } }); diff --git a/app/views/posts/attributes/_content.haml b/app/views/posts/attributes/_content.haml index 4ae70ba0..65462397 100644 --- a/app/views/posts/attributes/_content.haml +++ b/app/views/posts/attributes/_content.haml @@ -9,119 +9,6 @@ .alert.alert-info :markdown #{t('editor.alert')} - = text_area_tag "#{base}[#{attribute}]", '', + = text_area_tag "#{base}[#{attribute}]", metadata.value.html_safe, dir: dir, lang: locale, - **field_options(attribute, metadata), class: 'd-none' - - -# - el > se come el salto de línea y hace que los botones no tengan - espacio adicional - - TODO: Eliminar todo el espacio en blanco para minificar HTML - .editor-toolbar{ style: 'z-index: 1' } - .editor-primary-toolbar.scrollbar-black - %button.btn{ type: 'button', title: t('editor.multimedia'), data: { editor_button: 'multimedia' } }> - %i.fa.fa-fw.fa-upload> - %span.sr-only>= t('editor.multimedia') - %button.btn{ type: 'button', title: t('editor.bold'), data: { editor_button: 'mark-bold' } }> - %i.fa.fa-fw.fa-bold> - %span.sr-only>= t('editor.bold') - %button.btn{ type: 'button', title: t('editor.italic'), data: { editor_button: 'mark-italic' } }> - %i.fa.fa-fw.fa-italic> - %span.sr-only>= t('editor.italic') - %button.btn{ type: 'button', title: t('editor.mark'), data: { editor_button: 'mark-mark' } }> - %i.fa.fa-fw.fa-tint> - %span.sr-only>= t('editor.mark') - %button.btn{ type: 'button', title: t('editor.link'), data: { editor_button: 'mark-link' } }> - %i.fa.fa-fw.fa-link> - %span.sr-only>= t('editor.link') - %button.btn{ type: 'button', title: t('editor.deleted'), data: { editor_button: 'mark-deleted' } }> - %i.fa.fa-fw.fa-strikethrough> - %span.sr-only>= t('editor.deleted') - %button.btn{ type: 'button', title: t('editor.underline'), data: { editor_button: 'mark-underline' } }> - %i.fa.fa-fw.fa-underline> - %span.sr-only>= t('editor.underline') - %button.btn{ type: 'button', title: t('editor.super'), data: { editor_button: 'mark-super' } }> - %i.fa.fa-fw.fa-superscript> - %span.sr-only>= t('editor.super') - %button.btn{ type: 'button', title: t('editor.sub'), data: { editor_button: 'mark-sub' } }> - %i.fa.fa-fw.fa-subscript> - %span.sr-only>= t('editor.sub') - %button.btn{ type: 'button', title: t('editor.small'), data: { editor_button: 'mark-small' } }> - %i.fa.fa-fw.fa-subscript> - %span.sr-only>= t('editor.small') - %button.btn.mr-0{ type: 'button', title: t('editor.h1'), data: { editor_button: 'block-h1' } }> - %i.fa.fa-fw.fa-heading> - 1 - %span.sr-only>= t('editor.h1') - %details.d-inline> - %summary.d-inline> - %span.btn.ml-0{ role: 'button', title: t('editor.more') }> - %i.fa.fa-caret-right> - %span.sr-only= t('editor.more') - .d-inline> - %button.btn{ type: 'button', title: t('editor.h2'), data: { editor_button: 'block-h2' } }> - %i.fa.fa-fw.fa-heading> - 2 - %span.sr-only>= t('editor.h2') - %button.btn{ type: 'button', title: t('editor.h3'), data: { editor_button: 'block-h3' } }> - %i.fa.fa-fw.fa-heading> - 3 - %span.sr-only>= t('editor.h3') - %button.btn{ type: 'button', title: t('editor.h4'), data: { editor_button: 'block-h4' } }> - %i.fa.fa-fw.fa-heading> - 4 - %span.sr-only>= t('editor.h4') - %button.btn{ type: 'button', title: t('editor.h5'), data: { editor_button: 'block-h5' } }> - %i.fa.fa-fw.fa-heading> - 5 - %span.sr-only>= t('editor.h5') - %button.btn{ type: 'button', title: t('editor.h6'), data: { editor_button: 'block-h6' } }> - %i.fa.fa-fw.fa-heading> - 6 - %span.sr-only>= t('editor.h6') - %button.btn{ type: 'button', title: t('editor.ul'), data: { editor_button: 'block-unordered_list' } }> - %i.fa.fa-fw.fa-list-ul> - %span.sr-only>= t('editor.ul') - %button.btn{ type: 'button', title: t('editor.ol'), data: { editor_button: 'block-ordered_list' } }> - %i.fa.fa-fw.fa-list-ol> - %span.sr-only>= t('editor.ol') - %button.btn{ type: 'button', title: t('editor.left'), data: { editor_button: 'parentBlock-left' } }> - %i.fa.fa-fw.fa-align-left> - %span.sr-only>= t('editor.left') - %button.btn{ type: 'button', title: t('editor.center'), data: { editor_button: 'parentBlock-center' } }> - %i.fa.fa-fw.fa-align-center> - %span.sr-only>= t('editor.center') - %button.btn{ type: 'button', title: t('editor.right'), data: { editor_button: 'parentBlock-right' } }> - %i.fa.fa-fw.fa-align-right> - %span.sr-only>= t('editor.right') - - -# HAML cringe - .editor-auxiliary-toolbar.mt-1.scrollbar-black{ data: { editor_auxiliary_toolbar: '' } } - .form-group{ data: { editor_auxiliary: 'mark' } } - %label{ for: 'mark-color' }= t('editor.color') - %input.form-control{ type: 'color', name: 'mark-color' }/ - %label{ for: 'mark-text-color' }= t('editor.text-color') - %input.form-control{ type: 'color', name: 'mark-text-color' }/ - - %div{ data: { editor_auxiliary: 'multimedia' } } - .form-group - .custom-file - %input.custom-file-input{ type: 'file', id: 'multimedia-file', name: 'multimedia-file' }/ - %label.custom-file-label{ for: 'multimedia-file' }= t('editor.multimedia-select') - .form-group - %label{ for: 'multimedia-alt' }= t('editor.description') - %input.form-control{ type: 'text', id: 'multimedia-alt', name: 'multimedia-alt' }/ - .form-group - %button.btn{ type: 'button', id: 'multimedia-file-upload', name: 'multimedia-file-upload' }= t('editor.multimedia-upload') - %button.btn{ type: 'button', id: 'multimedia-remove', name: 'multimedia-remove' }= t('editor.multimedia-remove') - - .form-group{ data: { editor_auxiliary: 'link' } } - %label{ for: 'link-url' }= t('editor.url') - %input.form-control{ type: 'url', id: 'link-url', name: 'link-url' }/ - - .editor-aviso-word.alert.alert-info - %p= t('editor.word') - - .editor-content.form-control.h-auto.mt-1{ contenteditable: 'true' } - = metadata.value.html_safe + **field_options(attribute, metadata) diff --git a/package.json b/package.json index 0a2458a6..94fa93cc 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@rails/activestorage": "^6.1.3-1", "@rails/ujs": "^6.1.3-1", "@rails/webpacker": "5.2.1", + "@suttyweb/editor": "^0.0.3", "babel-loader": "^8.2.2", "circular-dependency-plugin": "^5.2.2", "commonmark": "^0.29.0", diff --git a/yarn.lock b/yarn.lock index 11ff78cb..216c9342 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1171,6 +1171,11 @@ resolved "https://registry.yarnpkg.com/@stimulus/webpack-helpers/-/webpack-helpers-1.1.1.tgz#eff60cd4e58b921d1a2764dc5215f5141510f2c2" integrity sha512-XOkqSw53N9072FLHvpLM25PIwy+ndkSSbnTtjKuyzsv8K5yfkFB2rv68jU1pzqYa9FZLcvZWP4yazC0V38dx9A== +"@suttyweb/editor@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@suttyweb/editor/-/editor-0.0.3.tgz#6572b9d29fe1c3adf36c4cfc4ee152453ba15c0f" + integrity sha512-mFh3cWpA/9vNlqKHBLjPVjCOzDH8HaBL+CwAGtiQoFUhxx8gaUiAfBGZGOE/KK9IcD/xDVTkG6q6HYZ6iIQIFQ== + "@types/caseless@*": version "0.12.2" resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" From a64f554f0e7b63ec36781096dc0e042c8f26d572 Mon Sep 17 00:00:00 2001 From: void Date: Sat, 22 May 2021 22:03:18 +0000 Subject: [PATCH 02/12] usar @suttyweb/editor@0.0.4 subida de archivos :) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 94fa93cc..773e9234 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@rails/activestorage": "^6.1.3-1", "@rails/ujs": "^6.1.3-1", "@rails/webpacker": "5.2.1", - "@suttyweb/editor": "^0.0.3", + "@suttyweb/editor": "^0.0.4", "babel-loader": "^8.2.2", "circular-dependency-plugin": "^5.2.2", "commonmark": "^0.29.0", diff --git a/yarn.lock b/yarn.lock index 216c9342..342a616a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1171,10 +1171,10 @@ resolved "https://registry.yarnpkg.com/@stimulus/webpack-helpers/-/webpack-helpers-1.1.1.tgz#eff60cd4e58b921d1a2764dc5215f5141510f2c2" integrity sha512-XOkqSw53N9072FLHvpLM25PIwy+ndkSSbnTtjKuyzsv8K5yfkFB2rv68jU1pzqYa9FZLcvZWP4yazC0V38dx9A== -"@suttyweb/editor@^0.0.3": - version "0.0.3" - resolved "https://registry.yarnpkg.com/@suttyweb/editor/-/editor-0.0.3.tgz#6572b9d29fe1c3adf36c4cfc4ee152453ba15c0f" - integrity sha512-mFh3cWpA/9vNlqKHBLjPVjCOzDH8HaBL+CwAGtiQoFUhxx8gaUiAfBGZGOE/KK9IcD/xDVTkG6q6HYZ6iIQIFQ== +"@suttyweb/editor@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@suttyweb/editor/-/editor-0.0.4.tgz#5fc6a295ee44f25d4034137a777f8b9fdf721aaa" + integrity sha512-ZlFjV/I9q1lW1rgRWJHyisRQQodsZkcGiIAXFPPhRBJmvE92AhEpuzPDLTesyJ1WQ2EZojOxAeoGFczUnv3jCA== "@types/caseless@*": version "0.12.2" From 06a9b78eee53bba5a10fb0c1c74978bac9af39c8 Mon Sep 17 00:00:00 2001 From: f Date: Wed, 16 Jun 2021 12:06:44 -0300 Subject: [PATCH 03/12] usar @suttyweb/editor@0.0.6 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 773e9234..b082128e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@rails/activestorage": "^6.1.3-1", "@rails/ujs": "^6.1.3-1", "@rails/webpacker": "5.2.1", - "@suttyweb/editor": "^0.0.4", + "@suttyweb/editor": "0.0.6", "babel-loader": "^8.2.2", "circular-dependency-plugin": "^5.2.2", "commonmark": "^0.29.0", diff --git a/yarn.lock b/yarn.lock index 342a616a..e25e2357 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1171,10 +1171,10 @@ resolved "https://registry.yarnpkg.com/@stimulus/webpack-helpers/-/webpack-helpers-1.1.1.tgz#eff60cd4e58b921d1a2764dc5215f5141510f2c2" integrity sha512-XOkqSw53N9072FLHvpLM25PIwy+ndkSSbnTtjKuyzsv8K5yfkFB2rv68jU1pzqYa9FZLcvZWP4yazC0V38dx9A== -"@suttyweb/editor@^0.0.4": - version "0.0.4" - resolved "https://registry.yarnpkg.com/@suttyweb/editor/-/editor-0.0.4.tgz#5fc6a295ee44f25d4034137a777f8b9fdf721aaa" - integrity sha512-ZlFjV/I9q1lW1rgRWJHyisRQQodsZkcGiIAXFPPhRBJmvE92AhEpuzPDLTesyJ1WQ2EZojOxAeoGFczUnv3jCA== +"@suttyweb/editor@0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/@suttyweb/editor/-/editor-0.0.6.tgz#5496762997c835262d0551a03eb919bdb9cc9dab" + integrity sha512-cLTM/xtEP2H4Vld2yF5Mnpqzke2pb43czVSDIAOnbDd9szG9o25ABAtQZVc7Iapf47mEen2rJpdoC0+Hfvruqw== "@types/caseless@*": version "0.12.2" From ffa2c80bf1dbab6b762c6961a9f576aadabd1054 Mon Sep 17 00:00:00 2001 From: f Date: Mon, 28 Jun 2021 14:29:37 -0300 Subject: [PATCH 04/12] @suttyweb/editor@0.0.7 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index b082128e..9560cdf3 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@rails/activestorage": "^6.1.3-1", "@rails/ujs": "^6.1.3-1", "@rails/webpacker": "5.2.1", - "@suttyweb/editor": "0.0.6", + "@suttyweb/editor": "0.0.7", "babel-loader": "^8.2.2", "circular-dependency-plugin": "^5.2.2", "commonmark": "^0.29.0", diff --git a/yarn.lock b/yarn.lock index e25e2357..604ee871 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1171,10 +1171,10 @@ resolved "https://registry.yarnpkg.com/@stimulus/webpack-helpers/-/webpack-helpers-1.1.1.tgz#eff60cd4e58b921d1a2764dc5215f5141510f2c2" integrity sha512-XOkqSw53N9072FLHvpLM25PIwy+ndkSSbnTtjKuyzsv8K5yfkFB2rv68jU1pzqYa9FZLcvZWP4yazC0V38dx9A== -"@suttyweb/editor@0.0.6": - version "0.0.6" - resolved "https://registry.yarnpkg.com/@suttyweb/editor/-/editor-0.0.6.tgz#5496762997c835262d0551a03eb919bdb9cc9dab" - integrity sha512-cLTM/xtEP2H4Vld2yF5Mnpqzke2pb43czVSDIAOnbDd9szG9o25ABAtQZVc7Iapf47mEen2rJpdoC0+Hfvruqw== +"@suttyweb/editor@0.0.7": + version "0.0.7" + resolved "https://registry.yarnpkg.com/@suttyweb/editor/-/editor-0.0.7.tgz#019c1ab2d43e6a6fb7d4ebf44c44f79ebd5a6896" + integrity sha512-fZepXH1pRTdDQWxKSF10lYVlYjoczkqGi00vTaSWDRTL/rnZrgOEakcdFyutcJzYnZWdWsWsF6AUidKvQvd1dw== "@types/caseless@*": version "0.12.2" From 6467a265d3fd2c1b77d576d2cd5412c6f189ac0d Mon Sep 17 00:00:00 2001 From: f Date: Tue, 10 Aug 2021 18:36:55 -0300 Subject: [PATCH 05/12] Deprecar el editor --- app/javascript/editor/editor.ts | 313 -------------------------------- 1 file changed, 313 deletions(-) diff --git a/app/javascript/editor/editor.ts b/app/javascript/editor/editor.ts index 233cc3c0..c254a650 100644 --- a/app/javascript/editor/editor.ts +++ b/app/javascript/editor/editor.ts @@ -1,320 +1,7 @@ -import { storeContent, restoreContent, forgetContent } from "editor/storage"; -import { - isDirectChild, - moveChildren, - safeGetSelection, - safeGetRangeAt, - setAuxiliaryToolbar, - parentBlockNames, - clearSelected, -} from "editor/utils"; -import { types, getValidChildren, getType } from "editor/types"; -import { setupButtons as setupMarksButtons } from "editor/types/marks"; -import { setupButtons as setupBlocksButtons } from "editor/types/blocks"; -import { setupButtons as setupParentBlocksButtons } from "editor/types/parentBlocks"; -import { setupAuxiliaryToolbar as setupLinkAuxiliaryToolbar } from "editor/types/link"; -import { - setupAuxiliaryToolbar as setupMultimediaAuxiliaryToolbar, - setupButtons as setupMultimediaButtons, -} from "editor/types/multimedia"; -import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from "editor/types/mark"; - /// @ts-ignore import SuttyEditor from "@suttyweb/editor"; import "@suttyweb/editor/dist/style.css"; -// Esta funcion corrije errores que pueden haber como: -// * que un nodo que no tiene 'text' permitido no tenga children (se les -// inserta un allowedChildren[0]) -// * TODO: que haya una imágen sin
o que no esté como bloque (se ponen -// después del bloque en el que están como bloque de por si) -// * convierte y en y -// Lo hace para que siga la estructura del documento y que no se borren por -// cleanContent luego. -function fixContent(editor: Editor, node: Element = editor.contentEl): void { - if (node.tagName === "SCRIPT" || node.tagName === "STYLE") { - node.parentElement?.removeChild(node); - return; - } - - if (node.tagName === "I") { - const el = document.createElement("em"); - moveChildren(node, el, null); - node.parentElement?.replaceChild(el, node); - node = el; - } - if (node.tagName === "B") { - const el = document.createElement("strong"); - moveChildren(node, el, null); - node.parentElement?.replaceChild(el, node); - node = el; - } - - if (node instanceof HTMLImageElement) { - node.dataset.multimediaInner = ""; - const figureEl = types.multimedia.create(editor); - - let targetEl = node.parentElement; - if (!targetEl) throw new Error("No encontré lx objetivo"); - while (true) { - const type = getType(targetEl); - if (!type) throw new Error("lx objetivo tiene tipo"); - if (type.type.allowedChildren.includes("multimedia")) break; - if (!targetEl.parentElement) throw new Error("No encontré lx objetivo"); - targetEl = targetEl.parentElement; - } - - let parentEl = [...targetEl.childNodes].find((el) => el.contains(node)); - if (!parentEl) throw new Error("no encontré lx pariente"); - targetEl.insertBefore(figureEl, parentEl); - - const innerEl = figureEl.querySelector("[data-multimedia-inner]"); - if (!innerEl) throw new Error("Raro."); - figureEl.replaceChild(node, innerEl); - - node = figureEl; - } - - const _type = getType(node); - if (!_type) return; - - const { typeName, type } = _type; - - if (type.allowedChildren !== "ignore-children") { - const sel = safeGetSelection(editor); - const range = sel && safeGetRangeAt(sel); - - if (getValidChildren(node, type).length == 0) { - if (typeof type.handleEmpty !== "string") { - const el = type.handleEmpty.create(editor); - // mover cosas que pueden haber - // por ejemplo: cuando convertís a un
    , queda texto fuera del li que - // creamos acá - moveChildren(node, el, null); - node.appendChild(el); - if (range?.intersectsNode(node)) sel?.collapse(el); - } - } - - for (const child of node.childNodes) { - if (!(child instanceof Element)) continue; - fixContent(editor, child); - } - } -} - -// Esta funcion hace que los elementos del editor sigan la estructura. -// TODO: nos falta borrar atributos (style, y básicamente cualquier otra cosa) -// Edge cases: -// * no borramos los
    por que se requieren para que los navegadores -// funcionen bien al escribir. no se deberían mostrar de todas maneras -function cleanContent(editor: Editor, node: Element = editor.contentEl): void { - const _type = getType(node); - if (!_type) { - node.parentElement?.removeChild(node); - return; - } - - const { type } = _type; - - if (type.allowedChildren !== "ignore-children") { - for (const child of node.childNodes) { - if ( - child.nodeType === Node.TEXT_NODE && - !type.allowedChildren.includes("text") - ) { - node.removeChild(child); - continue; - } - - if (!(child instanceof Element)) continue; - - const childType = getType(child); - if (childType?.typeName === "br") continue; - if (!childType || !type.allowedChildren.includes(childType.typeName)) { - // XXX: esto extrae las cosas de adentro para que no sea destructivo - moveChildren(child, node, child); - node.removeChild(child); - return; - } - - cleanContent(editor, child); - } - - // solo contar children válido para ese nodo - const validChildrenLength = getValidChildren(node, type).length; - - const sel = safeGetSelection(editor); - const range = sel && safeGetRangeAt(sel); - if ( - type.handleEmpty === "remove" && - validChildrenLength == 0 - //&& (!range || !range.intersectsNode(node)) - ) { - node.parentNode?.removeChild(node); - return; - } - } -} - -function routine(editor: Editor): void { - try { - fixContent(editor); - cleanContent(editor); - storeContent(editor); - - editor.htmlEl.value = editor.contentEl.innerHTML; - } catch (error) { - console.error("Hubo un problema corriendo la rutina", editor, error); - } -} - -export interface Editor { - editorEl: HTMLElement; - toolbarEl: HTMLElement; - toolbar: { - auxiliary: { - mark: { - parentEl: HTMLElement; - colorEl: HTMLInputElement; - textColorEl: HTMLInputElement; - }; - multimedia: { - parentEl: HTMLElement; - fileEl: HTMLInputElement; - uploadEl: HTMLButtonElement; - altEl: HTMLInputElement; - removeEl: HTMLButtonElement; - }; - link: { - parentEl: HTMLElement; - urlEl: HTMLInputElement; - }; - }; - }; - contentEl: HTMLElement; - wordAlertEl: HTMLElement; - htmlEl: HTMLTextAreaElement; -} - -function getSel(parentEl: HTMLElement, selector: string): T { - const el = parentEl.querySelector(selector); - if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``); - return el; -} - -function setupEditor(editorEl: HTMLElement): void { - // XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor? - document.execCommand("defaultParagraphSeparator", false, "p"); - - const editor: Editor = { - editorEl, - toolbarEl: getSel(editorEl, ".editor-toolbar"), - toolbar: { - auxiliary: { - mark: { - parentEl: getSel(editorEl, "[data-editor-auxiliary=mark]"), - colorEl: getSel( - editorEl, - "[data-editor-auxiliary=mark] [name=mark-color]" - ), - textColorEl: getSel( - editorEl, - "[data-editor-auxiliary=mark] [name=mark-text-color]" - ), - }, - multimedia: { - parentEl: getSel(editorEl, "[data-editor-auxiliary=multimedia]"), - fileEl: getSel( - editorEl, - "[data-editor-auxiliary=multimedia] [name=multimedia-file]" - ), - uploadEl: getSel( - editorEl, - "[data-editor-auxiliary=multimedia] [name=multimedia-file-upload]" - ), - altEl: getSel( - editorEl, - "[data-editor-auxiliary=multimedia] [name=multimedia-alt]" - ), - removeEl: getSel( - editorEl, - "[data-editor-auxiliary=multimedia] [name=multimedia-remove]" - ), - }, - link: { - parentEl: getSel(editorEl, "[data-editor-auxiliary=link]"), - urlEl: getSel( - editorEl, - "[data-editor-auxiliary=link] [name=link-url]" - ), - }, - }, - }, - contentEl: getSel(editorEl, ".editor-content"), - wordAlertEl: getSel(editorEl, ".editor-aviso-word"), - htmlEl: getSel(editorEl, "textarea"), - }; - console.debug("iniciando editor", editor); - - // Recuperar el contenido si hay algo guardado, si tuviéramos un campo - // de última edición podríamos saber si el artículo fue editado - // después o la versión local es la última. - // - // TODO: Preguntar si se lo quiere recuperar. - restoreContent(editor); - - // Word alert - editor.contentEl.addEventListener("paste", () => { - editor.wordAlertEl.style.display = "block"; - }); - - // Setup routine listeners - const observer = new MutationObserver(() => routine(editor)); - observer.observe(editor.contentEl, { - childList: true, - attributes: true, - subtree: true, - characterData: true, - }); - - document.addEventListener("selectionchange", () => routine(editor)); - - // Capture onClick - editor.contentEl.addEventListener( - "click", - (event) => { - const target = event.target! as Element; - const type = getType(target); - if (!type || !type.type.onClick) { - setAuxiliaryToolbar(editor, null); - clearSelected(editor); - return true; - } - type.type.onClick(editor, target); - return false; - }, - true - ); - - // Clean seleted - const selectedEl = editor.contentEl.querySelector("[data-editor-selected]"); - if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected; - - // Setup botones - setupMarksButtons(editor); - setupBlocksButtons(editor); - setupParentBlocksButtons(editor); - setupMultimediaButtons(editor); - - setupLinkAuxiliaryToolbar(editor); - setupMultimediaAuxiliaryToolbar(editor); - setupMarkAuxiliaryToolbar(editor); - - // Finally... - routine(editor); -} - document.addEventListener("turbolinks:load", () => { const flash = document.querySelector(".js-flash"); From c0b5863573dbd174d3100c09aa7bc66c8078ffef Mon Sep 17 00:00:00 2001 From: f Date: Tue, 10 Aug 2021 18:38:33 -0300 Subject: [PATCH 06/12] Deprecar el editor incorporado --- app/javascript/editor/storage.ts | 38 ---- app/javascript/editor/types.ts | 140 ------------ app/javascript/editor/types/blocks.ts | 76 ------- app/javascript/editor/types/link.ts | 37 ---- app/javascript/editor/types/mark.ts | 66 ------ app/javascript/editor/types/marks.ts | 102 --------- app/javascript/editor/types/multimedia.ts | 230 -------------------- app/javascript/editor/types/parentBlocks.ts | 78 ------- app/javascript/editor/utils.ts | 101 --------- 9 files changed, 868 deletions(-) delete mode 100644 app/javascript/editor/storage.ts delete mode 100644 app/javascript/editor/types.ts delete mode 100644 app/javascript/editor/types/blocks.ts delete mode 100644 app/javascript/editor/types/link.ts delete mode 100644 app/javascript/editor/types/mark.ts delete mode 100644 app/javascript/editor/types/marks.ts delete mode 100644 app/javascript/editor/types/multimedia.ts delete mode 100644 app/javascript/editor/types/parentBlocks.ts delete mode 100644 app/javascript/editor/utils.ts diff --git a/app/javascript/editor/storage.ts b/app/javascript/editor/storage.ts deleted file mode 100644 index e914a242..00000000 --- a/app/javascript/editor/storage.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Editor } from "editor/editor"; - -/* - * Guarda una copia local de los cambios para poder recuperarlos - * después. - * - * Usamos la URL completa sin anchors. - */ -function getStorageKey(editor: Editor): string { - const keyEl = editor.editorEl.querySelector( - '[data-target="storage-key"]' - ); - if (!keyEl) - throw new Error("No encuentro la llave para guardar los artículos"); - return keyEl.value; -} - -export function forgetContent(storedKey: string): void { - window.localStorage.removeItem(storedKey); -} - -export function storeContent(editor: Editor): void { - if (editor.contentEl.innerText.trim().length === 0) return; - - window.localStorage.setItem( - getStorageKey(editor), - editor.contentEl.innerHTML - ); -} - -export function restoreContent(editor: Editor): void { - const content = window.localStorage.getItem(getStorageKey(editor)); - - if (!content) return; - if (content.trim().length === 0) return; - - editor.contentEl.innerHTML = content; -} diff --git a/app/javascript/editor/types.ts b/app/javascript/editor/types.ts deleted file mode 100644 index ac3030ce..00000000 --- a/app/javascript/editor/types.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { Editor } from "editor/editor"; -import { marks } from "editor/types/marks"; -import { blocks, li, EditorBlock } from "editor/types/blocks"; -import { parentBlocks } from "editor/types/parentBlocks"; -import { multimedia } from "editor/types/multimedia"; -import { - blockNames, - parentBlockNames, - safeGetRangeAt, - safeGetSelection, -} from "editor/utils"; - -export interface EditorNode { - selector: string; - // la string es el nombre en la gran lista de types O 'text' - // XXX: esto es un hack para no poner EditorNode dentro de EditorNode, - // quizás podemos hacer que esto sea una función que retorna bool - allowedChildren: string[] | "ignore-children"; - - // * si es 'do-nothing', no hace nada si está vacío (esto es para cuando - // permitís 'text' entonces se puede tipear adentro, ej: párrafo vacío) - // * si es 'remove', sacamos el coso si está vacío. - // ej: strong: { handleNothing: 'remove' } - // * si es un block, insertamos el bloque y movemos la selección ahí - // ej: ul: { handleNothing: li } - handleEmpty: "do-nothing" | "remove" | EditorBlock; - - // esta función puede ser llamada para cosas que no necesariamente sea la - // creación del nodo con el botón; por ejemplo, al intentar recuperar - // el formato. esto es importante por que, por ejemplo, no deberíamos - // cambiar la selección acá. - create: (editor: Editor) => HTMLElement; - - onClick?: (editor: Editor, target: Element) => void; -} - -export const types: { [propName: string]: EditorNode } = { - ...marks, - ...blocks, - li, - ...parentBlocks, - contentEl: { - selector: ".editor-content", - allowedChildren: [...blockNames, ...parentBlockNames, "multimedia"], - handleEmpty: blocks.paragraph, - create: () => { - throw new Error("se intentó crear contentEl"); - }, - }, - br: { - selector: "br", - allowedChildren: [], - handleEmpty: "do-nothing", - create: () => { - throw new Error("se intentó crear br"); - }, - }, - multimedia, -}; - -export function getType( - node: Element -): { typeName: string; type: EditorNode } | null { - for (let [typeName, type] of Object.entries(types)) { - if (node.matches(type.selector)) { - return { typeName, type }; - } - } - - return null; -} - -// encuentra el primer pariente que pueda tener al type, y retorna un array -// donde -// array[0] = elemento que matchea el type -// array[array.len - 1] = primer elemento seleccionado -export function getValidParentInSelection(args: { - editor: Editor; - type: string; -}): Element[] { - const sel = safeGetSelection(args.editor); - if (!sel) throw new Error("No se donde insertar esto"); - const range = safeGetRangeAt(sel); - if (!range) throw new Error("No se donde insertar esto"); - - let list: Element[] = []; - - if (!sel.anchorNode) { - throw new Error("No se donde insertar esto"); - } else if (sel.anchorNode instanceof Element) { - list = [sel.anchorNode]; - } else if (sel.anchorNode.parentElement) { - list = [sel.anchorNode.parentElement]; - } else { - throw new Error("No se donde insertar esto"); - } - - while (true) { - const el = list[0]; - if (!args.editor.contentEl.contains(el) && el != args.editor.contentEl) - throw new Error("No se donde insertar esto"); - const type = getType(el); - - if (type) { - //if (type.typeName === 'contentEl') break - //if (parentBlockNames.includes(type.typeName)) break - if ( - type.type.allowedChildren instanceof Array && - type.type.allowedChildren.includes(args.type) - ) - break; - } - if (el.parentElement) { - list = [el.parentElement, ...list]; - } else { - throw new Error("No se donde insertar esto"); - } - } - - return list; -} - -export function getValidChildren(node: Element, type: EditorNode): Node[] { - if (type.allowedChildren === "ignore-children") - throw new Error( - "se llamó a getValidChildren con un type que no lo permite!" - ); - return [...node.childNodes].filter((n) => { - // si permite texto y esto es un texto, es válido - if (n.nodeType === Node.TEXT_NODE) - return type.allowedChildren.includes("text") && n.textContent?.length; - - // si no es un elemento, no es válido - if (!(n instanceof Element)) return false; - - const t = getType(n); - if (!t) return false; - return type.allowedChildren.includes(t.typeName); - }); -} diff --git a/app/javascript/editor/types/blocks.ts b/app/javascript/editor/types/blocks.ts deleted file mode 100644 index 2e2dea7e..00000000 --- a/app/javascript/editor/types/blocks.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Editor } from "editor/editor"; -import { - safeGetSelection, - safeGetRangeAt, - moveChildren, - markNames, - blockNames, - parentBlockNames, -} from "editor/utils"; -import { EditorNode, getType, getValidParentInSelection } from "editor/types"; - -export interface EditorBlock extends EditorNode {} - -function makeBlock(tag: string): EditorBlock { - return { - selector: tag, - allowedChildren: [...markNames, "text"], - handleEmpty: "do-nothing", - create: () => document.createElement(tag), - }; -} - -export const li: EditorBlock = makeBlock("li"); - -// XXX: si agregás algo acá, agregalo a blockNames -// (y probablemente le quieras hacer un botón en app/views/posts/attributes/_content.haml) -export const blocks: { [propName: string]: EditorBlock } = { - paragraph: makeBlock("p"), - h1: makeBlock("h1"), - h2: makeBlock("h2"), - h3: makeBlock("h3"), - h4: makeBlock("h4"), - h5: makeBlock("h5"), - h6: makeBlock("h6"), - unordered_list: { - ...makeBlock("ul"), - allowedChildren: ["li"], - handleEmpty: li, - }, - ordered_list: { - ...makeBlock("ol"), - allowedChildren: ["li"], - handleEmpty: li, - }, -}; - -export function setupButtons(editor: Editor): void { - for (const [name, type] of Object.entries(blocks)) { - const buttonEl = editor.toolbarEl.querySelector( - `[data-editor-button="block-${name}"]` - ); - if (!buttonEl) continue; - buttonEl.addEventListener("click", (event) => { - event.preventDefault(); - - const list = getValidParentInSelection({ editor, type: name }); - - // No borrar cosas como multimedia - if (blockNames.indexOf(getType(list[1])!.typeName) === -1) { - return; - } - - let replacementType = list[1].matches(type.selector) - ? blocks.paragraph - : type; - - const el = replacementType.create(editor); - replacementType.onClick && replacementType.onClick(editor, el); - moveChildren(list[1], el, null); - list[0].replaceChild(el, list[1]); - window.getSelection()?.collapse(el); - - return false; - }); - } -} diff --git a/app/javascript/editor/types/link.ts b/app/javascript/editor/types/link.ts deleted file mode 100644 index eb85db90..00000000 --- a/app/javascript/editor/types/link.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Editor } from "editor/editor"; -import { EditorNode } from "editor/types"; -import { markNames, setAuxiliaryToolbar, clearSelected } from "editor/utils"; - -function select(editor: Editor, el: HTMLAnchorElement): void { - clearSelected(editor); - el.dataset.editorSelected = ""; - editor.toolbar.auxiliary.link.urlEl.value = el.href; - setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.link.parentEl); -} - -export const link: EditorNode = { - selector: "a", - allowedChildren: [...markNames.filter((n) => n !== "link"), "text"], - handleEmpty: "remove", - create: () => document.createElement("a"), - onClick(editor, el) { - if (!(el instanceof HTMLAnchorElement)) throw new Error("oh no"); - select(editor, el); - }, -}; - -export function setupAuxiliaryToolbar(editor: Editor): void { - editor.toolbar.auxiliary.link.urlEl.addEventListener("input", (event) => { - const url = editor.toolbar.auxiliary.link.urlEl.value; - const selectedEl = editor.contentEl.querySelector( - "a[data-editor-selected]" - ); - if (!selectedEl) - throw new Error("No pude encontrar el link para setear el enlace"); - - selectedEl.href = url; - }); - editor.toolbar.auxiliary.link.urlEl.addEventListener("keydown", (event) => { - if (event.keyCode == 13) event.preventDefault(); - }); -} diff --git a/app/javascript/editor/types/mark.ts b/app/javascript/editor/types/mark.ts deleted file mode 100644 index 4735c799..00000000 --- a/app/javascript/editor/types/mark.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Editor } from "editor/editor"; -import { EditorNode } from "editor/types"; -import { markNames, setAuxiliaryToolbar, clearSelected } from "editor/utils"; - -const hex = (x: string) => ("0" + parseInt(x).toString(16)).slice(-2); -// https://stackoverflow.com/a/3627747 -// TODO: cambiar por una solución más copada -function rgbToHex(rgb: string): string { - const matches = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); - if (!matches) throw new Error("no pude parsear el rgb()"); - return "#" + hex(matches[1]) + hex(matches[2]) + hex(matches[3]); -} - -function select(editor: Editor, el: HTMLElement): void { - clearSelected(editor); - el.dataset.editorSelected = ""; - editor.toolbar.auxiliary.mark.colorEl.value = el.style.backgroundColor - ? rgbToHex(el.style.backgroundColor) - : "#f206f9"; - editor.toolbar.auxiliary.mark.textColorEl.value = el.style.color - ? rgbToHex(el.style.color) - : "#000000"; - setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.mark.parentEl); -} - -export const mark: EditorNode = { - selector: "mark", - allowedChildren: [...markNames.filter((n) => n !== "mark"), "text"], - handleEmpty: "remove", - create: () => document.createElement("mark"), - onClick(editor, el) { - if (!(el instanceof HTMLElement)) throw new Error("oh no"); - select(editor, el); - }, -}; - -export function setupAuxiliaryToolbar(editor: Editor): void { - editor.toolbar.auxiliary.mark.colorEl.addEventListener("input", (event) => { - const color = editor.toolbar.auxiliary.mark.colorEl.value; - const selectedEl = editor.contentEl.querySelector( - "mark[data-editor-selected]" - ); - if (!selectedEl) - throw new Error("No pude encontrar el mark para setear el color"); - - selectedEl.style.backgroundColor = color; - }); - editor.toolbar.auxiliary.mark.textColorEl.addEventListener( - "input", - (event) => { - const color = editor.toolbar.auxiliary.mark.textColorEl.value; - const selectedEl = editor.contentEl.querySelector( - "mark[data-editor-selected]" - ); - if (!selectedEl) - throw new Error( - "No pude encontrar el mark para setear el color del text" - ); - - selectedEl.style.color = color; - } - ); - editor.toolbar.auxiliary.mark.colorEl.addEventListener("keydown", (event) => { - if (event.keyCode == 13) event.preventDefault(); - }); -} diff --git a/app/javascript/editor/types/marks.ts b/app/javascript/editor/types/marks.ts deleted file mode 100644 index 0ea5a5ad..00000000 --- a/app/javascript/editor/types/marks.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Editor } from "editor/editor"; -import { EditorNode } from "editor/types"; -import { - safeGetSelection, - safeGetRangeAt, - moveChildren, - markNames, -} from "editor/utils"; -import { link } from "editor/types/link"; -import { mark } from "editor/types/mark"; - -function makeMark(name: string, tag: string): EditorNode { - return { - selector: tag, - allowedChildren: [...markNames.filter((n) => n !== name), "text"], - handleEmpty: "remove", - create: () => document.createElement(tag), - }; -} - -// XXX: si agregás algo acá, agregalo a markNames -export const marks: { [propName: string]: EditorNode } = { - bold: makeMark("bold", "strong"), - italic: makeMark("italic", "em"), - deleted: makeMark("deleted", "del"), - underline: makeMark("underline", "u"), - sub: makeMark("sub", "sub"), - super: makeMark("super", "sup"), - mark, - link, - small: makeMark("small", "small"), -}; - -function recursiveFilterSelection( - node: Element, - selection: Selection, - selector: string -): Element[] { - let output: Element[] = []; - for (const child of [...node.children]) { - if (child.matches(selector) && selection.containsNode(child)) - output.push(child); - output = [ - ...output, - ...recursiveFilterSelection(child, selection, selector), - ]; - } - return output; -} - -export function setupButtons(editor: Editor): void { - for (const [name, type] of Object.entries(marks)) { - const buttonEl = editor.toolbarEl.querySelector( - `[data-editor-button="mark-${name}"]` - ); - if (!buttonEl) continue; - buttonEl.addEventListener("click", (event) => { - event.preventDefault(); - - const sel = safeGetSelection(editor); - if (!sel) return; - const range = safeGetRangeAt(sel); - if (!range) return; - - let parentEl = range.commonAncestorContainer; - while (!(parentEl instanceof Element)) { - if (!parentEl.parentElement) return; - parentEl = parentEl.parentElement; - } - - const existingMarks = recursiveFilterSelection( - parentEl, - sel, - type.selector - ); - console.debug("marks encontradas:", existingMarks); - - if (existingMarks.length > 0) { - const mark = existingMarks[0]; - if (!mark.parentElement) throw new Error(":/"); - moveChildren(mark, mark.parentElement, mark); - mark.parentElement.removeChild(mark); - } else { - if (range.commonAncestorContainer === editor.contentEl) - // TODO: mostrar error - return console.error( - "No puedo marcar cosas a través de distintos bloques!" - ); - - const tagEl = type.create(editor); - type.onClick && type.onClick(editor, tagEl); - - tagEl.appendChild(range.extractContents()); - - range.insertNode(tagEl); - range.selectNode(tagEl); - } - - return false; - }); - } -} diff --git a/app/javascript/editor/types/multimedia.ts b/app/javascript/editor/types/multimedia.ts deleted file mode 100644 index 2af9643a..00000000 --- a/app/javascript/editor/types/multimedia.ts +++ /dev/null @@ -1,230 +0,0 @@ -import * as ActiveStorage from "@rails/activestorage"; -import { Editor } from "editor/editor"; -import { EditorNode, getValidParentInSelection } from "editor/types"; -import { - safeGetSelection, - safeGetRangeAt, - markNames, - parentBlockNames, - setAuxiliaryToolbar, - clearSelected, -} from "editor/utils"; - -function uploadFile(file: File): Promise { - return new Promise((resolve, reject) => { - const upload = new ActiveStorage.DirectUpload( - file, - origin + "/rails/active_storage/direct_uploads" - ); - - upload.create((error: any, blob: any) => { - if (error) { - reject(error); - } else { - const url = `${origin}/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}`; - resolve(url); - } - }); - }); -} - -function getAlt(multimediaInnerEl: HTMLElement): string | null { - switch (multimediaInnerEl.tagName) { - case "VIDEO": - case "AUDIO": - return multimediaInnerEl.getAttribute("aria-label"); - case "IMG": - return (multimediaInnerEl as HTMLImageElement).alt; - case "IFRAME": - return multimediaInnerEl.title; - default: - throw new Error("no pude conseguir el alt"); - } -} -function setAlt(multimediaInnerEl: HTMLElement, value: string): void { - switch (multimediaInnerEl.tagName) { - case "VIDEO": - case "AUDIO": - multimediaInnerEl.setAttribute("aria-label", value); - break; - case "IMG": - (multimediaInnerEl as HTMLImageElement).alt = value; - break; - case "IFRAME": - multimediaInnerEl.title = value; - break; - default: - throw new Error("no pude setear el alt"); - } -} - -function select(editor: Editor, el: HTMLElement): void { - clearSelected(editor); - el.dataset.editorSelected = ""; - - const innerEl = el.querySelector("[data-multimedia-inner]"); - if (!innerEl) throw new Error("No hay multimedia válida"); - if (innerEl.tagName === "P") { - editor.toolbar.auxiliary.multimedia.altEl.value = ""; - editor.toolbar.auxiliary.multimedia.altEl.disabled = true; - } else { - editor.toolbar.auxiliary.multimedia.altEl.value = getAlt(innerEl) || ""; - editor.toolbar.auxiliary.multimedia.altEl.disabled = false; - } - - setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.multimedia.parentEl); -} - -export const multimedia: EditorNode = { - selector: "figure[data-multimedia]", - allowedChildren: "ignore-children", - handleEmpty: "remove", - create: () => { - const figureEl = document.createElement("figure"); - figureEl.dataset.multimedia = ""; - figureEl.contentEditable = "false"; - - const placeholderEl = document.createElement("p"); - placeholderEl.dataset.multimediaInner = ""; - // TODO i18n - placeholderEl.append("¡Clickeame para subir un archivo!"); - figureEl.appendChild(placeholderEl); - - const descriptionEl = document.createElement("figcaption"); - descriptionEl.contentEditable = "true"; - // TODO i18n - descriptionEl.append("Escribí acá la descripción del archivo."); - figureEl.appendChild(descriptionEl); - - return figureEl; - }, - onClick(editor, el) { - if (!(el instanceof HTMLElement)) throw new Error("oh no"); - select(editor, el); - }, -}; -function createElementWithFile(url: string, type: string): HTMLElement { - if (type.match(/^image\/.+$/)) { - const el = document.createElement("img"); - el.dataset.multimediaInner = ""; - el.src = url; - return el; - } else if (type.match(/^video\/.+$/)) { - const el = document.createElement("video"); - el.controls = true; - el.dataset.multimediaInner = ""; - el.src = url; - return el; - } else if (type.match(/^audio\/.+$/)) { - const el = document.createElement("audio"); - el.controls = true; - el.dataset.multimediaInner = ""; - el.src = url; - return el; - } else if (type.match(/^application\/pdf$/)) { - const el = document.createElement("iframe"); - el.dataset.multimediaInner = ""; - el.src = url; - return el; - } else { - // TODO: chequear si el archivo es válido antes de subir - throw new Error("Tipo de archivo no reconocido"); - } -} - -export function setupAuxiliaryToolbar(editor: Editor): void { - editor.toolbar.auxiliary.multimedia.uploadEl.addEventListener( - "click", - (event) => { - const files = editor.toolbar.auxiliary.multimedia.fileEl.files; - if (!files || !files.length) - throw new Error("no hay archivos para subir"); - const file = files[0]; - - const selectedEl = editor.contentEl.querySelector( - "figure[data-editor-selected]" - ); - if (!selectedEl) - throw new Error("No pude encontrar el elemento para setear el archivo"); - - selectedEl.dataset.editorLoading = ""; - uploadFile(file) - .then((url) => { - const innerEl = selectedEl.querySelector("[data-multimedia-inner]"); - if (!innerEl) throw new Error("No hay multimedia a reemplazar"); - - const el = createElementWithFile(url, file.type); - setAlt(el, editor.toolbar.auxiliary.multimedia.altEl.value); - selectedEl.replaceChild(el, innerEl); - - select(editor, selectedEl); - - delete selectedEl.dataset.editorError; - }) - .catch((err) => { - console.error(err); - // TODO: mostrar error - selectedEl.dataset.editorError = ""; - }) - .finally(() => { - delete selectedEl.dataset.editorLoading; - }); - } - ); - - editor.toolbar.auxiliary.multimedia.removeEl.addEventListener( - "click", - (event) => { - const selectedEl = editor.contentEl.querySelector( - "figure[data-editor-selected]" - ); - if (!selectedEl) - throw new Error("No pude encontrar el elemento para borrar"); - - selectedEl.parentElement?.removeChild(selectedEl); - setAuxiliaryToolbar(editor, null); - } - ); - - editor.toolbar.auxiliary.multimedia.altEl.addEventListener( - "input", - (event) => { - const selectedEl = editor.contentEl.querySelector( - "figure[data-editor-selected]" - ); - if (!selectedEl) - throw new Error("No pude encontrar el multimedia para setear el alt"); - - const innerEl = selectedEl.querySelector( - "[data-multimedia-inner]" - ); - if (!innerEl) throw new Error("No hay multimedia a para setear el alt"); - - setAlt(innerEl, editor.toolbar.auxiliary.multimedia.altEl.value); - } - ); - editor.toolbar.auxiliary.multimedia.altEl.addEventListener( - "keydown", - (event) => { - if (event.keyCode == 13) event.preventDefault(); - } - ); -} - -export function setupButtons(editor: Editor): void { - const buttonEl = editor.toolbarEl.querySelector( - '[data-editor-button="multimedia"]' - ); - if (!buttonEl) throw new Error("No encontre el botón de multimedia"); - buttonEl.addEventListener("click", (event) => { - event.preventDefault(); - - const list = getValidParentInSelection({ editor, type: "multimedia" }); - - const el = multimedia.create(editor); - list[0].insertBefore(el, list[1].nextElementSibling); - select(editor, el); - - return false; - }); -} diff --git a/app/javascript/editor/types/parentBlocks.ts b/app/javascript/editor/types/parentBlocks.ts deleted file mode 100644 index ffe40bdf..00000000 --- a/app/javascript/editor/types/parentBlocks.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Editor } from "editor/editor"; -import { - safeGetSelection, - safeGetRangeAt, - moveChildren, - blockNames, - parentBlockNames, -} from "editor/utils"; -import { EditorNode, getType, getValidParentInSelection } from "editor/types"; - -function makeParentBlock( - tag: string, - create: EditorNode["create"] -): EditorNode { - return { - selector: tag, - allowedChildren: [...blockNames, "multimedia"], - handleEmpty: "remove", - create, - }; -} - -// TODO: añadir blockquote -// XXX: si agregás algo acá, probablemente le quieras hacer un botón -// en app/views/posts/attributes/_content.haml -export const parentBlocks: { [propName: string]: EditorNode } = { - left: makeParentBlock("div[data-align=left]", () => { - const el = document.createElement("div"); - el.dataset.align = "left"; - el.style.textAlign = "left"; - return el; - }), - center: makeParentBlock("div[data-align=center]", () => { - const el = document.createElement("div"); - el.dataset.align = "center"; - el.style.textAlign = "center"; - return el; - }), - right: makeParentBlock("div[data-align=right]", () => { - const el = document.createElement("div"); - el.dataset.align = "right"; - el.style.textAlign = "right"; - return el; - }), -}; - -export function setupButtons(editor: Editor): void { - for (const [name, type] of Object.entries(parentBlocks)) { - const buttonEl = editor.toolbarEl.querySelector( - `[data-editor-button="parentBlock-${name}"]` - ); - if (!buttonEl) continue; - buttonEl.addEventListener("click", (event) => { - event.preventDefault(); - - // TODO: Esto solo mueve el bloque en el que está el final de la selección - // (anchorNode). quizás lo podemos hacer al revés (iterar desde contentEl - // para encontrar los bloques que están seleccionados y moverlos/cambiarles - // el parentBlock) - - const list = getValidParentInSelection({ editor, type: name }); - - const replacementEl = type.create(editor); - if (list[0] == editor.contentEl) { - // no está en un parentBlock - editor.contentEl.insertBefore(replacementEl, list[1]); - replacementEl.appendChild(list[1]); - } else { - // está en un parentBlock - moveChildren(list[0], replacementEl, null); - editor.contentEl.replaceChild(replacementEl, list[0]); - } - window.getSelection()?.collapse(replacementEl); - - return false; - }); - } -} diff --git a/app/javascript/editor/utils.ts b/app/javascript/editor/utils.ts deleted file mode 100644 index 167c0a6d..00000000 --- a/app/javascript/editor/utils.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Editor } from "editor/editor"; - -export const blockNames = [ - "paragraph", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "unordered_list", - "ordered_list", -]; -export const markNames = [ - "bold", - "italic", - "deleted", - "underline", - "sub", - "super", - "mark", - "link", - "small", -]; -export const parentBlockNames = ["left", "center", "right"]; - -export function moveChildren(from: Element, to: Element, toRef: Node | null) { - while (from.firstChild) to.insertBefore(from.firstChild, toRef); -} - -export function isDirectChild(node: Node, supposedChild: Node): boolean { - for (const child of node.childNodes) { - if (child == supposedChild) return true; - } - return false; -} - -export function safeGetSelection(editor: Editor): Selection | null { - const sel = window.getSelection(); - if (!sel) return null; - // XXX: no damos la selección si esta fuera o _es_ el contentEl, ¿quizás - // deberíamos mostrar un error? - if ( - !editor.contentEl.contains(sel.anchorNode) || - !editor.contentEl.contains(sel.focusNode) || - sel.anchorNode == editor.contentEl || - sel.focusNode == editor.contentEl - ) - return null; - return sel; -} - -export function safeGetRangeAt(selection: Selection, num = 0): Range | null { - try { - return selection.getRangeAt(num); - } catch (error) { - return null; - } -} - -interface SplitNode { - range: Range; - node: Node; -} - -export function splitNode(node: Element, range: Range): [SplitNode, SplitNode] { - const [left, right] = [ - { range: document.createRange(), node: node.cloneNode(false) }, - { range: document.createRange(), node: node.cloneNode(false) }, - ]; - - if (node.firstChild) left.range.setStartBefore(node.firstChild); - left.range.setEnd(range.startContainer, range.startOffset); - left.range.surroundContents(left.node); - - right.range.setStart(range.endContainer, range.endOffset); - if (node.lastChild) right.range.setEndAfter(node.lastChild); - right.range.surroundContents(right.node); - - if (!node.parentElement) - throw new Error("No pude separar los nodos por que no tiene parentNode"); - - moveChildren(node, node.parentElement, node); - node.parentElement.removeChild(node); - - return [left, right]; -} - -export function setAuxiliaryToolbar( - editor: Editor, - bar: HTMLElement | null -): void { - for (const { parentEl } of Object.values(editor.toolbar.auxiliary)) { - delete parentEl.dataset.editorAuxiliaryActive; - } - if (bar) bar.dataset.editorAuxiliaryActive = "active"; -} -export function clearSelected(editor: Editor): void { - const selectedEl = editor.contentEl.querySelector("[data-editor-selected]"); - if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected; -} From a4ca89a36b382e88d3bd5451d33575df998ccabc Mon Sep 17 00:00:00 2001 From: f Date: Sat, 11 Sep 2021 20:07:51 -0300 Subject: [PATCH 07/12] @suttyweb/editor@0.0.8 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 9560cdf3..23ed3e5e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@rails/activestorage": "^6.1.3-1", "@rails/ujs": "^6.1.3-1", "@rails/webpacker": "5.2.1", - "@suttyweb/editor": "0.0.7", + "@suttyweb/editor": "0.0.8", "babel-loader": "^8.2.2", "circular-dependency-plugin": "^5.2.2", "commonmark": "^0.29.0", diff --git a/yarn.lock b/yarn.lock index 604ee871..86e54004 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1171,10 +1171,10 @@ resolved "https://registry.yarnpkg.com/@stimulus/webpack-helpers/-/webpack-helpers-1.1.1.tgz#eff60cd4e58b921d1a2764dc5215f5141510f2c2" integrity sha512-XOkqSw53N9072FLHvpLM25PIwy+ndkSSbnTtjKuyzsv8K5yfkFB2rv68jU1pzqYa9FZLcvZWP4yazC0V38dx9A== -"@suttyweb/editor@0.0.7": - version "0.0.7" - resolved "https://registry.yarnpkg.com/@suttyweb/editor/-/editor-0.0.7.tgz#019c1ab2d43e6a6fb7d4ebf44c44f79ebd5a6896" - integrity sha512-fZepXH1pRTdDQWxKSF10lYVlYjoczkqGi00vTaSWDRTL/rnZrgOEakcdFyutcJzYnZWdWsWsF6AUidKvQvd1dw== +"@suttyweb/editor@0.0.8": + version "0.0.8" + resolved "https://registry.yarnpkg.com/@suttyweb/editor/-/editor-0.0.8.tgz#5803b9bcbab69fc4bf40fb939d1ec2283d44d2fd" + integrity sha512-vBBfTaGwu8IH4Gd+Q8cFC+XjjeEZ/8gSqT830hCO0kHzEvHEPTSEokffVR5DffBkS7ZKCvwsNXKzz/QuvkfHuQ== "@types/caseless@*": version "0.12.2" From 6154b3667083dfca0a4b4b1b6268855f67e17adb Mon Sep 17 00:00:00 2001 From: f Date: Fri, 21 Oct 2022 16:31:54 -0300 Subject: [PATCH 08/12] 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; +} From 5f4b589f4fbb46a648a1c9df50ce68cc2c368b9f Mon Sep 17 00:00:00 2001 From: f Date: Fri, 21 Oct 2022 16:32:09 -0300 Subject: [PATCH 09/12] Revert "Deprecar el editor" This reverts commit 6467a265d3fd2c1b77d576d2cd5412c6f189ac0d. --- app/javascript/editor/editor.ts | 313 ++++++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) diff --git a/app/javascript/editor/editor.ts b/app/javascript/editor/editor.ts index c254a650..233cc3c0 100644 --- a/app/javascript/editor/editor.ts +++ b/app/javascript/editor/editor.ts @@ -1,7 +1,320 @@ +import { storeContent, restoreContent, forgetContent } from "editor/storage"; +import { + isDirectChild, + moveChildren, + safeGetSelection, + safeGetRangeAt, + setAuxiliaryToolbar, + parentBlockNames, + clearSelected, +} from "editor/utils"; +import { types, getValidChildren, getType } from "editor/types"; +import { setupButtons as setupMarksButtons } from "editor/types/marks"; +import { setupButtons as setupBlocksButtons } from "editor/types/blocks"; +import { setupButtons as setupParentBlocksButtons } from "editor/types/parentBlocks"; +import { setupAuxiliaryToolbar as setupLinkAuxiliaryToolbar } from "editor/types/link"; +import { + setupAuxiliaryToolbar as setupMultimediaAuxiliaryToolbar, + setupButtons as setupMultimediaButtons, +} from "editor/types/multimedia"; +import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from "editor/types/mark"; + /// @ts-ignore import SuttyEditor from "@suttyweb/editor"; import "@suttyweb/editor/dist/style.css"; +// Esta funcion corrije errores que pueden haber como: +// * que un nodo que no tiene 'text' permitido no tenga children (se les +// inserta un allowedChildren[0]) +// * TODO: que haya una imágen sin
    o que no esté como bloque (se ponen +// después del bloque en el que están como bloque de por si) +// * convierte y en y +// Lo hace para que siga la estructura del documento y que no se borren por +// cleanContent luego. +function fixContent(editor: Editor, node: Element = editor.contentEl): void { + if (node.tagName === "SCRIPT" || node.tagName === "STYLE") { + node.parentElement?.removeChild(node); + return; + } + + if (node.tagName === "I") { + const el = document.createElement("em"); + moveChildren(node, el, null); + node.parentElement?.replaceChild(el, node); + node = el; + } + if (node.tagName === "B") { + const el = document.createElement("strong"); + moveChildren(node, el, null); + node.parentElement?.replaceChild(el, node); + node = el; + } + + if (node instanceof HTMLImageElement) { + node.dataset.multimediaInner = ""; + const figureEl = types.multimedia.create(editor); + + let targetEl = node.parentElement; + if (!targetEl) throw new Error("No encontré lx objetivo"); + while (true) { + const type = getType(targetEl); + if (!type) throw new Error("lx objetivo tiene tipo"); + if (type.type.allowedChildren.includes("multimedia")) break; + if (!targetEl.parentElement) throw new Error("No encontré lx objetivo"); + targetEl = targetEl.parentElement; + } + + let parentEl = [...targetEl.childNodes].find((el) => el.contains(node)); + if (!parentEl) throw new Error("no encontré lx pariente"); + targetEl.insertBefore(figureEl, parentEl); + + const innerEl = figureEl.querySelector("[data-multimedia-inner]"); + if (!innerEl) throw new Error("Raro."); + figureEl.replaceChild(node, innerEl); + + node = figureEl; + } + + const _type = getType(node); + if (!_type) return; + + const { typeName, type } = _type; + + if (type.allowedChildren !== "ignore-children") { + const sel = safeGetSelection(editor); + const range = sel && safeGetRangeAt(sel); + + if (getValidChildren(node, type).length == 0) { + if (typeof type.handleEmpty !== "string") { + const el = type.handleEmpty.create(editor); + // mover cosas que pueden haber + // por ejemplo: cuando convertís a un
      , queda texto fuera del li que + // creamos acá + moveChildren(node, el, null); + node.appendChild(el); + if (range?.intersectsNode(node)) sel?.collapse(el); + } + } + + for (const child of node.childNodes) { + if (!(child instanceof Element)) continue; + fixContent(editor, child); + } + } +} + +// Esta funcion hace que los elementos del editor sigan la estructura. +// TODO: nos falta borrar atributos (style, y básicamente cualquier otra cosa) +// Edge cases: +// * no borramos los
      por que se requieren para que los navegadores +// funcionen bien al escribir. no se deberían mostrar de todas maneras +function cleanContent(editor: Editor, node: Element = editor.contentEl): void { + const _type = getType(node); + if (!_type) { + node.parentElement?.removeChild(node); + return; + } + + const { type } = _type; + + if (type.allowedChildren !== "ignore-children") { + for (const child of node.childNodes) { + if ( + child.nodeType === Node.TEXT_NODE && + !type.allowedChildren.includes("text") + ) { + node.removeChild(child); + continue; + } + + if (!(child instanceof Element)) continue; + + const childType = getType(child); + if (childType?.typeName === "br") continue; + if (!childType || !type.allowedChildren.includes(childType.typeName)) { + // XXX: esto extrae las cosas de adentro para que no sea destructivo + moveChildren(child, node, child); + node.removeChild(child); + return; + } + + cleanContent(editor, child); + } + + // solo contar children válido para ese nodo + const validChildrenLength = getValidChildren(node, type).length; + + const sel = safeGetSelection(editor); + const range = sel && safeGetRangeAt(sel); + if ( + type.handleEmpty === "remove" && + validChildrenLength == 0 + //&& (!range || !range.intersectsNode(node)) + ) { + node.parentNode?.removeChild(node); + return; + } + } +} + +function routine(editor: Editor): void { + try { + fixContent(editor); + cleanContent(editor); + storeContent(editor); + + editor.htmlEl.value = editor.contentEl.innerHTML; + } catch (error) { + console.error("Hubo un problema corriendo la rutina", editor, error); + } +} + +export interface Editor { + editorEl: HTMLElement; + toolbarEl: HTMLElement; + toolbar: { + auxiliary: { + mark: { + parentEl: HTMLElement; + colorEl: HTMLInputElement; + textColorEl: HTMLInputElement; + }; + multimedia: { + parentEl: HTMLElement; + fileEl: HTMLInputElement; + uploadEl: HTMLButtonElement; + altEl: HTMLInputElement; + removeEl: HTMLButtonElement; + }; + link: { + parentEl: HTMLElement; + urlEl: HTMLInputElement; + }; + }; + }; + contentEl: HTMLElement; + wordAlertEl: HTMLElement; + htmlEl: HTMLTextAreaElement; +} + +function getSel(parentEl: HTMLElement, selector: string): T { + const el = parentEl.querySelector(selector); + if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``); + return el; +} + +function setupEditor(editorEl: HTMLElement): void { + // XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor? + document.execCommand("defaultParagraphSeparator", false, "p"); + + const editor: Editor = { + editorEl, + toolbarEl: getSel(editorEl, ".editor-toolbar"), + toolbar: { + auxiliary: { + mark: { + parentEl: getSel(editorEl, "[data-editor-auxiliary=mark]"), + colorEl: getSel( + editorEl, + "[data-editor-auxiliary=mark] [name=mark-color]" + ), + textColorEl: getSel( + editorEl, + "[data-editor-auxiliary=mark] [name=mark-text-color]" + ), + }, + multimedia: { + parentEl: getSel(editorEl, "[data-editor-auxiliary=multimedia]"), + fileEl: getSel( + editorEl, + "[data-editor-auxiliary=multimedia] [name=multimedia-file]" + ), + uploadEl: getSel( + editorEl, + "[data-editor-auxiliary=multimedia] [name=multimedia-file-upload]" + ), + altEl: getSel( + editorEl, + "[data-editor-auxiliary=multimedia] [name=multimedia-alt]" + ), + removeEl: getSel( + editorEl, + "[data-editor-auxiliary=multimedia] [name=multimedia-remove]" + ), + }, + link: { + parentEl: getSel(editorEl, "[data-editor-auxiliary=link]"), + urlEl: getSel( + editorEl, + "[data-editor-auxiliary=link] [name=link-url]" + ), + }, + }, + }, + contentEl: getSel(editorEl, ".editor-content"), + wordAlertEl: getSel(editorEl, ".editor-aviso-word"), + htmlEl: getSel(editorEl, "textarea"), + }; + console.debug("iniciando editor", editor); + + // Recuperar el contenido si hay algo guardado, si tuviéramos un campo + // de última edición podríamos saber si el artículo fue editado + // después o la versión local es la última. + // + // TODO: Preguntar si se lo quiere recuperar. + restoreContent(editor); + + // Word alert + editor.contentEl.addEventListener("paste", () => { + editor.wordAlertEl.style.display = "block"; + }); + + // Setup routine listeners + const observer = new MutationObserver(() => routine(editor)); + observer.observe(editor.contentEl, { + childList: true, + attributes: true, + subtree: true, + characterData: true, + }); + + document.addEventListener("selectionchange", () => routine(editor)); + + // Capture onClick + editor.contentEl.addEventListener( + "click", + (event) => { + const target = event.target! as Element; + const type = getType(target); + if (!type || !type.type.onClick) { + setAuxiliaryToolbar(editor, null); + clearSelected(editor); + return true; + } + type.type.onClick(editor, target); + return false; + }, + true + ); + + // Clean seleted + const selectedEl = editor.contentEl.querySelector("[data-editor-selected]"); + if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected; + + // Setup botones + setupMarksButtons(editor); + setupBlocksButtons(editor); + setupParentBlocksButtons(editor); + setupMultimediaButtons(editor); + + setupLinkAuxiliaryToolbar(editor); + setupMultimediaAuxiliaryToolbar(editor); + setupMarkAuxiliaryToolbar(editor); + + // Finally... + routine(editor); +} + document.addEventListener("turbolinks:load", () => { const flash = document.querySelector(".js-flash"); From 8221ae63c860946066996f2c1c1a1e53ecdfaf1f Mon Sep 17 00:00:00 2001 From: f Date: Fri, 21 Oct 2022 16:33:10 -0300 Subject: [PATCH 10/12] Revert "usar @suttyweb/editor" This reverts commit 7e0600779ed259f2bedc3603d5205d18bc21316b. --- app/javascript/editor/editor.ts | 15 +-- app/views/posts/attributes/_content.haml | 117 ++++++++++++++++++++++- package.json | 1 - yarn.lock | 5 - 4 files changed, 118 insertions(+), 20 deletions(-) diff --git a/app/javascript/editor/editor.ts b/app/javascript/editor/editor.ts index 233cc3c0..880547de 100644 --- a/app/javascript/editor/editor.ts +++ b/app/javascript/editor/editor.ts @@ -19,10 +19,6 @@ import { } from "editor/types/multimedia"; import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from "editor/types/mark"; -/// @ts-ignore -import SuttyEditor from "@suttyweb/editor"; -import "@suttyweb/editor/dist/style.css"; - // Esta funcion corrije errores que pueden haber como: // * que un nodo que no tiene 'text' permitido no tenga children (se les // inserta un allowedChildren[0]) @@ -334,15 +330,10 @@ document.addEventListener("turbolinks:load", () => { ".editor[data-editor]" )) { try { - new SuttyEditor({ - target: editorEl, - props: { - textareaEl: editorEl.parentElement!.querySelector("textarea"), - }, - }); + setupEditor(editorEl); } catch (error) { - console.error(error); - alert(error); + // TODO: mostrar error + console.error("no se pudo iniciar el editor, error completo", error); } } }); diff --git a/app/views/posts/attributes/_content.haml b/app/views/posts/attributes/_content.haml index 65462397..4ae70ba0 100644 --- a/app/views/posts/attributes/_content.haml +++ b/app/views/posts/attributes/_content.haml @@ -9,6 +9,119 @@ .alert.alert-info :markdown #{t('editor.alert')} - = text_area_tag "#{base}[#{attribute}]", metadata.value.html_safe, + = text_area_tag "#{base}[#{attribute}]", '', dir: dir, lang: locale, - **field_options(attribute, metadata) + **field_options(attribute, metadata), class: 'd-none' + + -# + el > se come el salto de línea y hace que los botones no tengan + espacio adicional + + TODO: Eliminar todo el espacio en blanco para minificar HTML + .editor-toolbar{ style: 'z-index: 1' } + .editor-primary-toolbar.scrollbar-black + %button.btn{ type: 'button', title: t('editor.multimedia'), data: { editor_button: 'multimedia' } }> + %i.fa.fa-fw.fa-upload> + %span.sr-only>= t('editor.multimedia') + %button.btn{ type: 'button', title: t('editor.bold'), data: { editor_button: 'mark-bold' } }> + %i.fa.fa-fw.fa-bold> + %span.sr-only>= t('editor.bold') + %button.btn{ type: 'button', title: t('editor.italic'), data: { editor_button: 'mark-italic' } }> + %i.fa.fa-fw.fa-italic> + %span.sr-only>= t('editor.italic') + %button.btn{ type: 'button', title: t('editor.mark'), data: { editor_button: 'mark-mark' } }> + %i.fa.fa-fw.fa-tint> + %span.sr-only>= t('editor.mark') + %button.btn{ type: 'button', title: t('editor.link'), data: { editor_button: 'mark-link' } }> + %i.fa.fa-fw.fa-link> + %span.sr-only>= t('editor.link') + %button.btn{ type: 'button', title: t('editor.deleted'), data: { editor_button: 'mark-deleted' } }> + %i.fa.fa-fw.fa-strikethrough> + %span.sr-only>= t('editor.deleted') + %button.btn{ type: 'button', title: t('editor.underline'), data: { editor_button: 'mark-underline' } }> + %i.fa.fa-fw.fa-underline> + %span.sr-only>= t('editor.underline') + %button.btn{ type: 'button', title: t('editor.super'), data: { editor_button: 'mark-super' } }> + %i.fa.fa-fw.fa-superscript> + %span.sr-only>= t('editor.super') + %button.btn{ type: 'button', title: t('editor.sub'), data: { editor_button: 'mark-sub' } }> + %i.fa.fa-fw.fa-subscript> + %span.sr-only>= t('editor.sub') + %button.btn{ type: 'button', title: t('editor.small'), data: { editor_button: 'mark-small' } }> + %i.fa.fa-fw.fa-subscript> + %span.sr-only>= t('editor.small') + %button.btn.mr-0{ type: 'button', title: t('editor.h1'), data: { editor_button: 'block-h1' } }> + %i.fa.fa-fw.fa-heading> + 1 + %span.sr-only>= t('editor.h1') + %details.d-inline> + %summary.d-inline> + %span.btn.ml-0{ role: 'button', title: t('editor.more') }> + %i.fa.fa-caret-right> + %span.sr-only= t('editor.more') + .d-inline> + %button.btn{ type: 'button', title: t('editor.h2'), data: { editor_button: 'block-h2' } }> + %i.fa.fa-fw.fa-heading> + 2 + %span.sr-only>= t('editor.h2') + %button.btn{ type: 'button', title: t('editor.h3'), data: { editor_button: 'block-h3' } }> + %i.fa.fa-fw.fa-heading> + 3 + %span.sr-only>= t('editor.h3') + %button.btn{ type: 'button', title: t('editor.h4'), data: { editor_button: 'block-h4' } }> + %i.fa.fa-fw.fa-heading> + 4 + %span.sr-only>= t('editor.h4') + %button.btn{ type: 'button', title: t('editor.h5'), data: { editor_button: 'block-h5' } }> + %i.fa.fa-fw.fa-heading> + 5 + %span.sr-only>= t('editor.h5') + %button.btn{ type: 'button', title: t('editor.h6'), data: { editor_button: 'block-h6' } }> + %i.fa.fa-fw.fa-heading> + 6 + %span.sr-only>= t('editor.h6') + %button.btn{ type: 'button', title: t('editor.ul'), data: { editor_button: 'block-unordered_list' } }> + %i.fa.fa-fw.fa-list-ul> + %span.sr-only>= t('editor.ul') + %button.btn{ type: 'button', title: t('editor.ol'), data: { editor_button: 'block-ordered_list' } }> + %i.fa.fa-fw.fa-list-ol> + %span.sr-only>= t('editor.ol') + %button.btn{ type: 'button', title: t('editor.left'), data: { editor_button: 'parentBlock-left' } }> + %i.fa.fa-fw.fa-align-left> + %span.sr-only>= t('editor.left') + %button.btn{ type: 'button', title: t('editor.center'), data: { editor_button: 'parentBlock-center' } }> + %i.fa.fa-fw.fa-align-center> + %span.sr-only>= t('editor.center') + %button.btn{ type: 'button', title: t('editor.right'), data: { editor_button: 'parentBlock-right' } }> + %i.fa.fa-fw.fa-align-right> + %span.sr-only>= t('editor.right') + + -# HAML cringe + .editor-auxiliary-toolbar.mt-1.scrollbar-black{ data: { editor_auxiliary_toolbar: '' } } + .form-group{ data: { editor_auxiliary: 'mark' } } + %label{ for: 'mark-color' }= t('editor.color') + %input.form-control{ type: 'color', name: 'mark-color' }/ + %label{ for: 'mark-text-color' }= t('editor.text-color') + %input.form-control{ type: 'color', name: 'mark-text-color' }/ + + %div{ data: { editor_auxiliary: 'multimedia' } } + .form-group + .custom-file + %input.custom-file-input{ type: 'file', id: 'multimedia-file', name: 'multimedia-file' }/ + %label.custom-file-label{ for: 'multimedia-file' }= t('editor.multimedia-select') + .form-group + %label{ for: 'multimedia-alt' }= t('editor.description') + %input.form-control{ type: 'text', id: 'multimedia-alt', name: 'multimedia-alt' }/ + .form-group + %button.btn{ type: 'button', id: 'multimedia-file-upload', name: 'multimedia-file-upload' }= t('editor.multimedia-upload') + %button.btn{ type: 'button', id: 'multimedia-remove', name: 'multimedia-remove' }= t('editor.multimedia-remove') + + .form-group{ data: { editor_auxiliary: 'link' } } + %label{ for: 'link-url' }= t('editor.url') + %input.form-control{ type: 'url', id: 'link-url', name: 'link-url' }/ + + .editor-aviso-word.alert.alert-info + %p= t('editor.word') + + .editor-content.form-control.h-auto.mt-1{ contenteditable: 'true' } + = metadata.value.html_safe diff --git a/package.json b/package.json index 23ed3e5e..0a2458a6 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "@rails/activestorage": "^6.1.3-1", "@rails/ujs": "^6.1.3-1", "@rails/webpacker": "5.2.1", - "@suttyweb/editor": "0.0.8", "babel-loader": "^8.2.2", "circular-dependency-plugin": "^5.2.2", "commonmark": "^0.29.0", diff --git a/yarn.lock b/yarn.lock index 86e54004..11ff78cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1171,11 +1171,6 @@ resolved "https://registry.yarnpkg.com/@stimulus/webpack-helpers/-/webpack-helpers-1.1.1.tgz#eff60cd4e58b921d1a2764dc5215f5141510f2c2" integrity sha512-XOkqSw53N9072FLHvpLM25PIwy+ndkSSbnTtjKuyzsv8K5yfkFB2rv68jU1pzqYa9FZLcvZWP4yazC0V38dx9A== -"@suttyweb/editor@0.0.8": - version "0.0.8" - resolved "https://registry.yarnpkg.com/@suttyweb/editor/-/editor-0.0.8.tgz#5803b9bcbab69fc4bf40fb939d1ec2283d44d2fd" - integrity sha512-vBBfTaGwu8IH4Gd+Q8cFC+XjjeEZ/8gSqT830hCO0kHzEvHEPTSEokffVR5DffBkS7ZKCvwsNXKzz/QuvkfHuQ== - "@types/caseless@*": version "0.12.2" resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" From bed39ab4cfcdfded1f643213a0942eebc61e515d Mon Sep 17 00:00:00 2001 From: f Date: Fri, 21 Oct 2022 16:33:40 -0300 Subject: [PATCH 11/12] usar @suttyweb/editor 0.1.0 --- package.json | 1 + yarn.lock | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/package.json b/package.json index 0a2458a6..1ead57ee 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@rails/activestorage": "^6.1.3-1", "@rails/ujs": "^6.1.3-1", "@rails/webpacker": "5.2.1", + "@suttyweb/editor": "^0.1.0", "babel-loader": "^8.2.2", "circular-dependency-plugin": "^5.2.2", "commonmark": "^0.29.0", diff --git a/yarn.lock b/yarn.lock index 11ff78cb..97f4941c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1171,6 +1171,11 @@ resolved "https://registry.yarnpkg.com/@stimulus/webpack-helpers/-/webpack-helpers-1.1.1.tgz#eff60cd4e58b921d1a2764dc5215f5141510f2c2" integrity sha512-XOkqSw53N9072FLHvpLM25PIwy+ndkSSbnTtjKuyzsv8K5yfkFB2rv68jU1pzqYa9FZLcvZWP4yazC0V38dx9A== +"@suttyweb/editor@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@suttyweb/editor/-/editor-0.1.0.tgz#2dc451ceb7df39aaf2ab4c0372db177c4586014b" + integrity sha512-4P/lD3acOJM9iVo+c+OIf3yjA6NHuIVSNh782XfyDLGz8ZrJj7MEvpmJqiNHsqz7MT0gz5WHakM8GnU/C3bzXg== + "@types/caseless@*": version "0.12.2" resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" From 87f638355db928437040fc1ef1d4f0eba69b10d4 Mon Sep 17 00:00:00 2001 From: f Date: Sat, 22 Oct 2022 12:15:06 -0300 Subject: [PATCH 12/12] permitir atributos de cuidados en links sutty/editor#32 --- app/models/metadata_template.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/metadata_template.rb b/app/models/metadata_template.rb index 5baa7a4a..d96b4b04 100644 --- a/app/models/metadata_template.rb +++ b/app/models/metadata_template.rb @@ -195,7 +195,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, def allowed_attributes @allowed_attributes ||= %w[style href src alt controls data-align data-multimedia data-multimedia-inner id - name].freeze + name rel target referrerpolicy].freeze end def allowed_tags