editor: fixes y links
This commit is contained in:
parent
a45d3c898f
commit
4dfba17941
8 changed files with 148 additions and 46 deletions
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -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') }
|
||||||
},
|
},
|
||||||
|
|
|
@ -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)
|
||||||
|
|
32
app/javascript/editor/types/link.ts
Normal file
32
app/javascript/editor/types/link.ts
Normal 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()
|
||||||
|
})
|
||||||
|
}
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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'
|
||||||
|
}
|
||||||
|
|
|
@ -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')
|
||||||
|
|
Loading…
Reference in a new issue