editor: fixes y links

This commit is contained in:
void 2021-02-13 01:14:36 +00:00
parent a45d3c898f
commit 4dfba17941
8 changed files with 148 additions and 46 deletions

View file

@ -1,17 +1,10 @@
import { storeContent, restoreContent } from 'editor/storage' import { storeContent, restoreContent } from 'editor/storage'
import { isDirectChild, moveChildren, safeGetSelection, safeGetRangeAt } from 'editor/utils' import { isDirectChild, moveChildren, safeGetSelection, safeGetRangeAt, setAuxiliaryToolbar } from 'editor/utils'
import { types, getValidChildren, getType } from 'editor/types' import { types, getValidChildren, getType } from 'editor/types'
import { setupButtons as setupMarksButtons } from 'editor/types/marks' import { setupButtons as setupMarksButtons } from 'editor/types/marks'
import { setupButtons as setupBlocksButtons } from 'editor/types/blocks' import { setupButtons as setupBlocksButtons } from 'editor/types/blocks'
import { setupButtons as setupParentBlocksButtons } from 'editor/types/parentBlocks' import { setupButtons as setupParentBlocksButtons } from 'editor/types/parentBlocks'
import { setupAuxiliaryToolbar as setupLinkAuxiliaryToolbar } from 'editor/types/link'
export interface Editor {
editorEl: HTMLElement,
toolbarEl: HTMLElement,
contentEl: HTMLElement,
wordAlertEl: HTMLElement,
htmlEl: HTMLTextAreaElement,
}
// Esta funcion corrije errores que pueden haber como: // Esta funcion corrije errores que pueden haber como:
// * que un nodo que no tiene 'text' permitido no tenga children (se les // * que un nodo que no tiene 'text' permitido no tenga children (se les
@ -50,7 +43,7 @@ function fixContent (editor: Editor, node: Element = editor.contentEl): void {
if (getValidChildren(node, type).length == 0) { if (getValidChildren(node, type).length == 0) {
if (typeof type.handleEmpty !== 'string') { if (typeof type.handleEmpty !== 'string') {
const el = type.handleEmpty.create() const el = type.handleEmpty.create(editor)
// mover cosas que pueden haber // mover cosas que pueden haber
// por ejemplo: cuando convertís a un <ul>, queda texto fuera del li que // por ejemplo: cuando convertís a un <ul>, queda texto fuera del li que
// creamos acá // creamos acá
@ -129,25 +122,66 @@ function routine (editor: Editor): void {
} }
} }
export interface Editor {
editorEl: HTMLElement,
toolbarEl: HTMLElement,
toolbar: {
auxiliary: {
mark: {
parentEl: HTMLElement,
colorEl: HTMLInputElement,
},
multimedia: {
parentEl: HTMLElement,
fileEl: HTMLInputElement,
uploadEl: HTMLButtonElement,
altEl: HTMLInputElement,
},
link: {
parentEl: HTMLElement,
urlEl: HTMLInputElement,
},
},
},
contentEl: HTMLElement,
wordAlertEl: HTMLElement,
htmlEl: HTMLTextAreaElement,
}
function getSel<T extends Element>(parentEl: HTMLElement, selector: string): T {
const el = parentEl.querySelector<T>(selector)
if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``)
return el
}
function setupEditor (editorEl: HTMLElement): void { function setupEditor (editorEl: HTMLElement): void {
// XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor? // XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor?
document.execCommand('defaultParagraphSeparator', false, 'p') document.execCommand('defaultParagraphSeparator', false, 'p')
const toolbarEl = editorEl.querySelector<HTMLElement>('.editor-toolbar')
if (!toolbarEl) throw new Error('No pude encontrar .editor-toolbar')
const contentEl = editorEl.querySelector<HTMLElement>('.editor-content')
if (!contentEl) throw new Error('No pude encontrar .editor-content')
const wordAlertEl = editorEl.querySelector<HTMLElement>('.editor-aviso-word')
if (!wordAlertEl) throw new Error('No pude encontrar .editor-aviso-word')
const htmlEl = editorEl.querySelector<HTMLTextAreaElement>('textarea')
if (!htmlEl) throw new Error('No pude encontrar el textarea para el HTML')
const editor: Editor = { const editor: Editor = {
editorEl, editorEl,
toolbarEl, toolbarEl: getSel(editorEl, '.editor-toolbar'),
contentEl, toolbar: {
wordAlertEl, auxiliary: {
htmlEl, mark: {
parentEl: getSel(editorEl, '[data-editor-auxiliary=mark]'),
colorEl: getSel(editorEl, '[data-editor-auxiliary=mark] [name=mark-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]'),
},
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) console.debug('iniciando editor', editor)
@ -165,7 +199,7 @@ function setupEditor (editorEl: HTMLElement): void {
// Setup routine listeners // Setup routine listeners
const observer = new MutationObserver(() => routine(editor)) const observer = new MutationObserver(() => routine(editor))
observer.observe(contentEl, { observer.observe(editor.contentEl, {
childList: true, childList: true,
attributes: true, attributes: true,
subtree: true, subtree: true,
@ -174,19 +208,43 @@ function setupEditor (editorEl: HTMLElement): void {
document.addEventListener("selectionchange", () => routine(editor)) 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)
const selectedEl = editor.contentEl.querySelector('[data-editor-selected]')
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected
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 // Setup botones
setupMarksButtons(editor) setupMarksButtons(editor)
setupBlocksButtons(editor) setupBlocksButtons(editor)
setupParentBlocksButtons(editor) setupParentBlocksButtons(editor)
setupLinkAuxiliaryToolbar(editor)
// Finally... // Finally...
routine(editor) routine(editor)
} }
document.addEventListener("turbolinks:load", () => { document.addEventListener("turbolinks:load", () => {
for (const editorEl of document.querySelectorAll<HTMLElement>('.editor')) { for (const editorEl of document.querySelectorAll<HTMLElement>('.editor[data-editor]')) {
if (!editorEl.querySelector('.editor-toolbar')) continue try {
setupEditor(editorEl)
setupEditor(editorEl) } catch (error) {
//alert(`No pude iniciar el editor: ${error}`)
// TODO: mostrar error
console.error('no se pudo iniciar el editor, error completo', error)
}
} }
}) })

View file

@ -1,7 +1,8 @@
import { Editor } from 'editor/editor'
import { marks } from 'editor/types/marks' import { marks } from 'editor/types/marks'
import { blocks, li, EditorBlock } from 'editor/types/blocks' import { blocks, li, EditorBlock } from 'editor/types/blocks'
import { parentBlocks } from 'editor/types/parentBlocks' import { parentBlocks } from 'editor/types/parentBlocks'
import { blockNames } from 'editor/utils' import { blockNames, parentBlockNames } from 'editor/utils'
export interface EditorNode { export interface EditorNode {
selector: string, selector: string,
@ -18,7 +19,9 @@ export interface EditorNode {
// ej: ul: { handleNothing: li } // ej: ul: { handleNothing: li }
handleEmpty: 'do-nothing' | 'remove' | EditorBlock, handleEmpty: 'do-nothing' | 'remove' | EditorBlock,
create: () => HTMLElement, create: (editor: Editor) => HTMLElement,
onClick?: (editor: Editor, target: Element) => void,
} }
export const types: { [propName: string]: EditorNode } = { export const types: { [propName: string]: EditorNode } = {
@ -28,7 +31,7 @@ export const types: { [propName: string]: EditorNode } = {
...parentBlocks, ...parentBlocks,
contentEl: { contentEl: {
selector: '.editor-content', selector: '.editor-content',
allowedChildren: [...blockNames, ...Object.keys(parentBlocks)], allowedChildren: [...blockNames, ...parentBlockNames],
handleEmpty: blocks.paragraph, handleEmpty: blocks.paragraph,
create: () => { throw new Error('se intentó crear contentEl') } create: () => { throw new Error('se intentó crear contentEl') }
}, },

View file

@ -82,7 +82,7 @@ export function setupButtons (editor: Editor): void {
? blocks.paragraph ? blocks.paragraph
: type : type
const el = replacementType.create() const el = replacementType.create(editor)
moveChildren(blockEl, el, null) moveChildren(blockEl, el, null)
parentEl.replaceChild(el, blockEl) parentEl.replaceChild(el, blockEl)
sel.collapse(el) sel.collapse(el)

View file

@ -0,0 +1,32 @@
import { Editor } from 'editor/editor'
import { EditorNode } from 'editor/types'
import { markNames, setAuxiliaryToolbar } from 'editor/utils'
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')
el.dataset.editorSelected = ''
editor.toolbar.auxiliary.link.urlEl.value = el.href
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.link.parentEl)
}
}
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<HTMLAnchorElement>('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()
})
}

View file

@ -4,7 +4,9 @@ import {
safeGetSelection, safeGetRangeAt, safeGetSelection, safeGetRangeAt,
moveChildren, moveChildren,
markNames, markNames,
setAuxiliaryToolbar,
} from 'editor/utils' } from 'editor/utils'
import { link } from 'editor/types/link'
function makeMark (name: string, tag: string): EditorNode { function makeMark (name: string, tag: string): EditorNode {
return { return {
@ -24,7 +26,7 @@ export const marks: { [propName: string]: EditorNode } = {
sub: makeMark('sub', 'sub'), sub: makeMark('sub', 'sub'),
super: makeMark('super', 'sup'), super: makeMark('super', 'sup'),
mark: makeMark('mark', 'mark'), mark: makeMark('mark', 'mark'),
link: makeMark('link', 'a'), link,
} }
function recursiveFilterSelection ( function recursiveFilterSelection (
@ -78,7 +80,7 @@ export function setupButtons (editor: Editor): void {
// TODO: mostrar error // TODO: mostrar error
return console.error("No puedo marcar cosas a través de distintos bloques!") return console.error("No puedo marcar cosas a través de distintos bloques!")
const tagEl = type.create() const tagEl = type.create(editor)
tagEl.appendChild(range.extractContents()) tagEl.appendChild(range.extractContents())

View file

@ -76,7 +76,7 @@ export function setupButtons (editor: Editor): void {
if (!parentEl) if (!parentEl)
throw new Error('no') throw new Error('no')
const replacementEl = type.create() const replacementEl = type.create(editor)
if (parentEl == editor.contentEl) { if (parentEl == editor.contentEl) {
// no está en un parentBlock // no está en un parentBlock
editor.contentEl.insertBefore(replacementEl, blockEl) editor.contentEl.insertBefore(replacementEl, blockEl)

View file

@ -1,7 +1,7 @@
import { Editor } from 'editor/editor' import { Editor } from 'editor/editor'
export const blockNames = ['paragraph', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'unordered_list', 'ordered_list'] export const blockNames = ['paragraph', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'unordered_list', 'ordered_list']
export const markNames = ['bold', 'italic', 'deleted', 'underline', 'sub', 'super', 'mark', 'a'] export const markNames = ['bold', 'italic', 'deleted', 'underline', 'sub', 'super', 'mark', 'link']
export const parentBlockNames = ['left', 'center', 'right'] export const parentBlockNames = ['left', 'center', 'right']
export function moveChildren (from: Element, to: Element, toRef: Node | null) { export function moveChildren (from: Element, to: Element, toRef: Node | null) {
@ -64,3 +64,10 @@ export function splitNode (node: Element, range: Range): [SplitNode, SplitNode]
return [left, right] 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'
}

View file

@ -3,7 +3,7 @@
= render 'posts/attribute_feedback', = render 'posts/attribute_feedback',
post: post, attribute: attribute, metadata: metadata post: post, attribute: attribute, metadata: metadata
.editor{ id: attribute } .editor{ id: attribute, data: { editor: '' } }
-# Esto es para luego decirle al navegador que se olvide estas cosas. -# Esto es para luego decirle al navegador que se olvide estas cosas.
= hidden_field_tag 'storage_keys[]', "#{request.original_url}##{attribute}", data: { target: 'storage-key' } = hidden_field_tag 'storage_keys[]', "#{request.original_url}##{attribute}", data: { target: 'storage-key' }
.alert.alert-info .alert.alert-info
@ -40,24 +40,24 @@
HAML cringe HAML cringe
TODO: generar IDs para labels TODO: generar IDs para labels
.editor-auxiliary-toolbar.mt-1.scrollbar-black{ data: { 'editor_auxiliary_toolbar': '' } } .editor-auxiliary-toolbar.mt-1.scrollbar-black{ data: { 'editor_auxiliary_toolbar': '' } }
%form.form-group{ data: { editor: { auxiliary: 'mark' } } } .form-group{ data: { editor: { auxiliary: 'mark' } } }
%label{ for: 'mark-color' }= t('editor.color') %label{ for: 'mark-color' }= t('editor.color')
%input.form-control{ type: 'color', data: { prop: 'mark-color' } }/ %input.form-control{ type: 'color', name: 'mark-color' }/
%form{ data: { editor: { auxiliary: 'multimedia' } } } %div{ data: { editor: { auxiliary: 'multimedia' } } }
.row .row
.col-12.col-lg.form-group.d-flex.align-items-end .col-12.col-lg.form-group.d-flex.align-items-end
.custom-file .custom-file
%input.custom-file-input{ type: 'file', data: { prop: 'multimedia-file' }, }/ %input.custom-file-input{ type: 'file', name: 'multimedia-file' }/
%label.custom-file-label{ for: 'multimedia-file' }= t('editor.file.multimedia') %label.custom-file-label{ for: 'multimedia-file' }= t('editor.file.multimedia')
%button.btn{ type: 'button', data: { prop: 'multimedia-file-upload' }, }= t('editor.file.multimedia-upload') %button.btn{ type: 'button', name: 'multimedia-file-upload' }= t('editor.file.multimedia-upload')
.col-12.col-lg.form-group .col-12.col-lg.form-group
%label{ for: 'multimedia-alt' }= t('editor.description') %label{ for: 'multimedia-alt' }= t('editor.description')
%input.form-control{ type: 'text', data: { prop: 'multimedia-alt' } }/ %input.form-control{ type: 'text', name: 'multimedia-alt' }/
%form.form-group{ data: { editor: { auxiliary: 'link' } } } .form-group{ data: { editor: { auxiliary: 'link' } } }
%label{ for: 'a-href' }= t('editor.url') %label{ for: 'link-url' }= t('editor.url')
%input.form-control{ type: 'url', data: { prop: 'a-href' } }/ %input.form-control{ type: 'url', name: 'link-url' }/
.editor-aviso-word.alert.alert-info .editor-aviso-word.alert.alert-info
%p= t('editor.word') %p= t('editor.word')