Introducción
En el desarrollo frontend, la edición de texto enriquecido es un requisito común. Este artículo se centra en cómo personalizar la barra de herramientas del editor Quill.js.
Acerca de Quill.js
Quill.js es un editor de texto enriquecido con soporte multiplataforma y multidispositivo. Su arquitectura extensible y su API expresiva permiten una personalización completa para adaptarse a necesidades específicas. Basándose en su diseño modular y API, puedes extender el núcleo de Quill o añadir tus propias funcionalidades. Ofrece dos temas predefinidos y permite una personalización adicional mediante plugins o modificando sus hojas de estilo CSS. Quill también soporta contenido y formatos personalizados, posibilitando la integración de elementos como diapositivas o modelos 3D.
Características Principales:
- Diseño basado en API, eliminando la necesidad de parsear HTML o DOM complejo.
- Rápido, ligero y compatible con múltiples plataformas y navegadores.
- Totalmente personalizable a través de módulos y una API robusta.
- Representación del contenido en formato JSON para facilitar su manipulación y conversión.
- Dos temas incluidos para una fácil modificación de la apariencia.
Desarrollo de la Barra de Herramientas Personalizada
Para este ejemplo, utilizaremos el componente react-quill, que envuelve la funcionalidad de quill.js en un componente React, facilitando su uso en proyectos React. Puedes encontrar más información en [https://github.com/zenoamaro/react-quill](https://github.com/zenoamaro/react-quill).
Uso Básico:
import React, { useState } from 'react';
import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';
function SimpleEditor() {
const [content, setContent] = useState('');
return <ReactQuill theme="snow" value={content} onChange={setContent} />;
}
Personalización de la Barra de Herramientas:
Puedes definir una barra de herramientas personalizada pasando una configruación específica. Para los botones personalizados, puedes usar iconos SVG de bibliotecas como iconfont o clases CSS. Para simplificar, usaremos texto plano en este ejemplo.
const CustomIconButton = () => <span className="custom-icon">
Buscar
</span>;
import React, { useState, useCallback, useMemo } from 'react';
import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';
function CustomToolbarEditor() {
const [value, setValue] = useState('');
// Handler para el botón personalizado
function handleInsertCustomAction() {
console.log("Botón personalizado clickeado!");
// Lógica para la acción personalizada
}
// Definición de la barra de herramientas personalizada
const ToolbarComponent = useCallback(() => (
<div id="custom-toolbar-container">
<select className="ql-header" defaultValue={''} onChange={(e) => e.persist()}>
<option value="1"></option>
<option value="2"></option>
<option selected></option>
</select>
<button className="ql-bold"></button>
<button className="ql-italic"></button>
<button className="ql-insertCustomAction">
<CustomIconButton/>
</button>
</div>
), []);
// Configuración de módulos para Quill
const editorModules = useMemo(() => ({
toolbar: {
container: '#custom-toolbar-container',
handlers: {
insertCustomAction: handleInsertCustomAction,
},
},
}), []);
return (
<div>
<ToolbarComponent/>
<ReactQuill theme="snow" value={value} modules={editorModules} onChange={setValue}/>
</div>
);
}
Este enfoque permite crear una barra de herramientas completamente personalizada. Sin embargo, ten en cuenta que las funcionalidades predeterminadas de Quill que no se incluyan explícitamente en tu barra personalizada deberán ser implementadas manualmente o copiadas de la documentación oficial.
Ejemplo: Funcionalidad de Búsqueda y Reemplazo
Ahora, implementaremos una funcionalidad de búsqueda y reemplazo como ejemplo práctico de personalización.
Estructura del Componente Modal:
Cuando se hace clic en el botón de "Buscar", se mostrará un modal con pestañas para buscar y reemplazar.
// Asumiendo que FindModal y sus dependencias (Tabs, Input, Button) están importados
// y que el estado 'visible' controla la visibilidad del modal.
{visible ? (
<FindModal
closeFindModal={this.closeModal}
// Otros props necesarios como getEditor
/>
) : null}
Procesamiento de la Búsqueda:
La búsqueda se activa cuando el usuario introduce texto en el campo de búsqueda.
<Input
onChange={this.handleSearchInputChange}
value={this.state.searchQuery}
/>
El método onChange (que podría incluir debounce para optimizar) actualiza el estado y reinicia los resultados de búsqueda:
handleSearchInputChange = (event) => {
const { value } = event.target;
this.setState({
searchQuery: value,
searchResults: [], // Reiniciar resultados
});
// Podría llamar a una función de búsqueda aquí o esperar un debounce
};
Obtenemos todo el texto del editor:
const { getEditor } = this.props; // Asumiendo que getEditor es un prop
const quillInstance = getEditor();
const fullText = quillInstance.getText();
Convertimos la consulta de búsqueda en una expresión regular, escapando caracteres especiales y considerando la sensibilidad a mayúsculas/minúsculas:
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const searchRegex = new RegExp(escapeRegExp(this.state.searchQuery), this.state.caseSensitive ? 'g' : 'gi');
Iteramos sobre el texto para encontrar coincidencias y aplicamos un formato temporal para resaltarlas. Mantenemos un registro de las posiciones encontradas.
let match;
const foundIndices = [];
let currentIndexOffset = 0; // Para ajustar por inserciones especiales
while ((match = searchRegex.exec(fullText)) !== null) {
let matchStartIndex = match.index;
// Ajustar el índice basado en caracteres especiales previos (implementación de countSpecial aquí)
matchStartIndex = this.adjustIndexForSpecialChars(matchStartIndex, currentIndexOffset);
// Aplicar formato temporal para resaltar
quillInstance.formatText(matchStartIndex, this.state.searchQuery.length, 'searchHighlight', true, 'api');
foundIndices.push({ index: matchStartIndex, length: this.state.searchQuery.length });
currentIndexOffset = matchStartIndex + this.state.searchQuery.length; // Actualizar offset
}
if (foundIndices.length) {
// Resaltar el primer resultado encontrado
quillInstance.formatText(foundIndices[0].index, foundIndices[0].length, 'searchActive', true, 'api');
this.setState({
searchResults: foundIndices,
currentResultIndex: 0,
});
}
Manejo de Caracteres Especiales:
La función adjustIndexForSpecialChars es crucial. Quill, al obtener solo el texto con getText(), omite elementos como imágenes o emojis. Para obtener la posición correcta en el contenido real (Delta), necesitamos contar estos elementos especiales.
// Método simplificado para ilustrar el concepto
adjustIndexForSpecialChars = (textIndex, previousOffset) => {
const { getEditor } = this.props;
const quill = getEditor();
const delta = quill.getContents();
let specialCharCount = 0;
let processedLength = 0;
for (const op of delta.ops) {
if (processedLength >= textIndex) break;
if (typeof op.insert === 'object' && op.insert !== null) {
// Es un objeto (imagen, video, etc.)
specialCharCount++;
}
// Contar la longitud del contenido de la inserción
if (typeof op.insert === 'string') {
processedLength += op.insert.length;
} else if (typeof op.insert === 'object' && op.insert !== null && op.insert.image) {
// Asumiendo que las imágenes ocupan un "carácter" virtual
processedLength += 1;
}
// Podría ser necesario manejar otros tipos de inserciones
}
return textIndex + specialCharCount;
};
Este método combina la obtención de texto plano con el análisis del Delta para una indexación precisa, superando la limitación de getText() que omite elementos no textuales.
Finalización de la Búsqueda:
Una vez encontradas las coincidencias, aplicamos formatos específicos para resaltar y marcar el resultado activo.
if (this.state.searchResults.length > 0) {
const { index, length } = this.state.searchResults[this.state.currentResultIndex];
// Resaltar el resultado activo
quillInstance.formatText(index, length, 'searchActive', true, 'api');
this.setState({
// ... otros estados
});
}
Formatos Personalizados de Quill:
Quill.js permite definir formatos personalizados. Creamos un Blots para manejar el resaltado.
// Archivo: SearchHighlightBlot.js
import { Quill } from 'quill';
const Inline = Quill.import('blots/inline');
class SearchHighlightBlot extends Inline {
static blotName = 'searchHighlight';
static className = 'ql-search-highlight';
static tagName = 'span';
}
class SearchActiveBlot extends Inline {
static blotName = 'searchActive';
static className = 'ql-search-active';
static tagName = 'span';
}
// Registrar los blots
Quill.register(SearchHighlightBlot);
Quill.register(SearchActiveBlot);
Asegúrate de importar y registrar estos Blots en el punto de entrada de tu aplicación.
/* Estilos CSS */
.ql-search-highlight {
background-color: #ffe066; /* Amarillo claro */
display: inline;
}
.ql-search-active {
background-color: #337eff !important; /* Azul brillante */
color: #fff !important;
border-radius: 3px;
display: inline;
}
Navegación entre Resultados:
Se añaden botones para navegar entre los resultados encontrados.
<Input
// ... otros props
suffix={
this.state.searchResults.length ? (
<span className={'navigation-controls'} style={{ marginRight: '10px' }}>
<i className="icon-prev" onClick={this.handlePreviousResult}>⬆️</i>
{this.state.currentResultIndex + 1} / {this.state.searchResults.length}
<i className="icon-next" onClick={this.handleNextResult}>⬇️</i>
</span>
) : null
}
/>
Los manejadores handleNextResult y handlePreviousResult actualizan el índice actual, eliminan el resaltado activo anterior y aplican el nuevo resaltado activo. También incluyen lógica para desplazar la vista si el resultado activo queda fuera de pantalla.
Desplazamiento a la Vista:
Se utiliza una combinación de las APIs de Quill y el DOM para asegurar que el resultado activo sea visible.
scrollToResult = (index) => {
const { getEditor } = this.props;
const quill = getEditor();
const scrollingContainer = quill.scrollingContainer;
const bounds = quill.getBounds(index); // Obtiene los límites del texto en el índice
if (!bounds) return;
const containerRect = scrollingContainer.getBoundingClientRect();
const elementTop = bounds.top + containerRect.top; // Posición absoluta del elemento
const elementHeight = bounds.height;
if (elementTop < containerRect.top) {
// El elemento está por encima del visible
scrollingContainer.scrollTop = elementTop - containerRect.top;
} else if (elementTop + elementHeight > containerRect.bottom) {
// El elemento está por debajo del visible
scrollingContainer.scrollTop = (elementTop + elementHeight) - containerRect.bottom;
}
};
Funcionalidad de Reemplazo:
El reemplazo individual es directo: eliminar el texto antiguo e insertar el nuevo en la posición actual.
replaceSingle = () => {
const { getEditor } = this.props;
const quill = getEditor();
const { searchQuery } = this.state;
const currentResult = this.state.searchResults[this.state.currentResultIndex];
if (currentResult) {
quill.deleteText(currentResult.index, searchQuery.length, 'user');
quill.insertText(currentResult.index, this.state.replaceQuery, 'user');
// Rebuscar para actualizar índices y resaltado
this.performSearch();
}
};
Para el reemplazo de todas las ocurrencias, un enfoque eficiente es iterar en orden inverso para evitar problemas con los índices al eliminar y insertar texto.
replaceAll = () => {
const { getEditor } = this.props;
const quill = getEditor();
const { searchQuery, replaceQuery, searchResults } = this.state;
const oldStringLength = searchQuery.length;
// Procesar en orden inverso para no afectar los índices
for (let i = searchResults.length - 1; i >= 0; i--) {
quill.deleteText(searchResults[i].index, oldStringLength, 'user');
quill.insertText(searchResults[i].index, replaceQuery, 'user');
}
// Rebuscar para asegurar que todo esté actualizado
this.performSearch();
};
Conclusión
Quill.js ofrece una base sólida para construir editores de texto enriquecido personalizables. Hemos explorado cómo crear barras de herramientas a medida e implementar funcionalidades complejas como la búsqueda y el reemplazo, manejando desafíos como los caracteres especiales y la aplicación de formatos personalizados.
Consideraciones adicionales para versiones futuras o alternativas:
- Para funcionalidades avanzadas como tablas, puede ser necesario migrar a Quill.js v2.0 (en desarrollo), que introduce cambios significativos.
- Es importante estar al tanto del estado de mantenimiento de las bibliotecas utilizadas.
Este artículo ha cubierto la personalización de la interfaz de usuario y la interacción programática con el editor a través de su API.