Creación de un editor de texto enriquecido (WYSIWYG)

 

 

 

  • Register!
  • Deploy Fast. Deploy Smart

  • Índice
    1. Comprender la estructura del documento
      1. Nodos de documento
      2. Atributos
      3. Ubicaciones y selección
    2. Configurando el editor
      1. Estilos de personajes
    3. Agregar una barra de herramientas
      1. Toggling Character Styles

    En este artículo, aprenderemos cómo crear un editor de texto enriquecido/WYSIWYG que admita texto enriquecido, imágenes, enlaces y algunas características matizadas de aplicaciones de procesamiento de textos. Usaremos SlateJS para construir el shell del editor y luego agregaremos una barra de herramientas y configuraciones personalizadas. El código de la aplicación está disponible en GitHub como referencia.

     

    En los últimos años, el campo de la creación y representación de contenidos en plataformas digitales ha experimentado una disrupción masiva. El éxito generalizado de productos como Quip, Google Docs y Dropbox Paper ha demostrado cómo las empresas se apresuran a crear la mejor experiencia para los creadores de contenido en el ámbito empresarial y tratan de encontrar formas innovadoras de romper los moldes tradicionales de cómo se comparte y consume el contenido. Aprovechando el alcance masivo de las plataformas de redes sociales, existe una nueva ola de creadores de contenido independientes que utilizan plataformas como Medium para crear contenido y compartirlo con su audiencia.

    Dado que tantas personas de diferentes profesiones y orígenes intentan crear contenido en estos productos, es importante que estos productos brinden una experiencia fluida y eficaz de creación de contenido y cuenten con equipos de diseñadores e ingenieros que desarrollen cierto nivel de experiencia en el dominio con el tiempo en este espacio. . Con este artículo, intentamos no solo sentar las bases para la creación de un editor, sino también brindarles a los lectores una idea de cómo las pequeñas funcionalidades, cuando se combinan, pueden crear una excelente experiencia de usuario para un creador de contenido.

    Comprender la estructura del documento

    Antes de sumergirnos en la construcción del editor, veamos cómo se estructura un documento para un editor de texto enriquecido y cuáles son los diferentes tipos de estructuras de datos involucradas.

     

    Nodos de documento

    Los nodos de documento se utilizan para representar el contenido del documento. Los tipos comunes de nodos que podría contener un documento de texto enriquecido son párrafos, encabezados, imágenes, videos, bloques de código y comillas. Algunos de estos pueden contener otros nodos como hijos dentro de ellos (por ejemplo, los nodos de párrafo contienen nodos de texto dentro de ellos). Los nodos también contienen propiedades específicas del objeto que representan que son necesarias para representar esos nodos dentro del editor. (por ejemplo, los nodos de imagen contienen una srcpropiedad de imagen, los bloques de código pueden contener una languagepropiedad, etc.).

    Existen en gran medida dos tipos de nodos que representan cómo deben representarse:

    • Nodos de bloque (análogos al concepto HTML de elementos a nivel de bloque) que se representan cada uno en una nueva línea y ocupan el ancho disponible. Los nodos de bloque podrían contener otros nodos de bloque o nodos en línea dentro de ellos. Una observación aquí es que los nodos de nivel superior de un documento siempre serán nodos de bloque.
    • Nodos en línea (análogos al concepto HTML de elementos en línea) que comienzan a renderizarse en la misma línea que el nodo anterior. Existen algunas diferencias en cómo se representan los elementos en línea en diferentes bibliotecas de edición. SlateJS permite que los elementos en línea sean nodos. DraftJS, otra biblioteca popular de edición de texto enriquecido, le permite utilizar el concepto de entidades para representar elementos en línea. Los enlaces y las imágenes en línea son ejemplos de nodos en línea.
    • Nodos vacíos: SlateJS también permite esta tercera categoría de nodos que usaremos más adelante en este artículo para representar medios.

    Si desea obtener más información sobre estas categorías, la documentación de SlateJS sobre Nodos es un buen lugar para comenzar.

    Atributos

    De manera similar al concepto de atributos de HTML, los atributos en un documento de texto enriquecido se utilizan para representar propiedades sin contenido de un nodo o sus hijos. Por ejemplo, un nodo de texto puede tener atributos de estilo de carácter que nos indican si el texto está en negrita/cursiva/subrayado, etc. Aunque este artículo representa los encabezados como nodos en sí, otra forma de representarlos podría ser que los nodos tengan estilos de párrafo ( paragraph h1-h6) como atributos.

    La imagen siguiente muestra un ejemplo de cómo se describe la estructura de un documento (en JSON) a un nivel más granular utilizando nodos y atributos que resaltan algunos de los elementos de la estructura de la izquierda.

     

    Documento de ejemplo y su representación estructural. ( Vista previa grande )

    Algunas de las cosas que vale la pena mencionar aquí con la estructura son:

    • Los nodos de texto se representan como{text: 'text content'}
    • Las propiedades de los nodos se almacenan directamente en el nodo (por ejemplo, urlpara enlaces e captionimágenes)
    • La representación específica de SlateJS de los atributos de texto divide los nodos de texto para que sean sus propios nodos si el estilo del carácter cambia. Por lo tanto, el texto ' Duis aute irure dolor ' es un nodo de texto propio con bold: trueset. Lo mismo ocurre con el texto en cursiva, subrayado y estilo de código en este documento.

    Ubicaciones y selección

    Al crear un editor de texto enriquecido, es fundamental comprender cómo se puede representar la parte más granular de un documento (por ejemplo, un carácter) con algún tipo de coordenadas. Esto nos ayuda a navegar por la estructura del documento en tiempo de ejecución para comprender en qué parte de la jerarquía del documento nos encontramos. Lo más importante es que los objetos de ubicación nos brindan una forma de representar la selección del usuario que se utiliza ampliamente para adaptar la experiencia del usuario del editor en tiempo real. Usaremos la selección para construir nuestra barra de herramientas más adelante en este artículo. Ejemplos de estos podrían ser:

    • ¿El cursor del usuario se encuentra actualmente dentro de un enlace? ¿Quizás deberíamos mostrarle un menú para editar/eliminar el enlace?
    • ¿El usuario ha seleccionado una imagen? Quizás les demos un menú para cambiar el tamaño de la imagen.
    • Si el usuario selecciona cierto texto y presiona el botón ELIMINAR, determinamos cuál fue el texto seleccionado por el usuario y lo eliminamos del documento.

    El documento de SlateJS sobre Ubicación explica ampliamente estas estructuras de datos, pero las repasamos aquí rápidamente a medida que usamos estos términos en diferentes momentos del artículo y mostramos un ejemplo en el diagrama que sigue.

    • Ruta
      Representada por una serie de números, una ruta es la forma de llegar a un nodo en el documento. Por ejemplo, una ruta [2,3]representa el tercer nodo secundario del segundo nodo del documento.
    • Punto
      Ubicación más granular del contenido representada por ruta + desplazamiento. Por ejemplo, un punto {path: [2,3], offset: 14}representa el carácter 14 del tercer nodo secundario dentro del segundo nodo del documento.
    • Rango
      Un par de puntos (llamados anchory focus) que representan un rango de texto dentro del documento. Este concepto proviene de la API de selección de Web, donde anchores donde comienza la selección del usuario y focusdonde termina. Un rango/selección colapsado indica dónde los puntos de anclaje y de enfoque son los mismos (piense en un cursor parpadeante en una entrada de texto, por ejemplo).

    Como ejemplo, digamos que la selección del usuario en nuestro ejemplo de documento anterior es ipsum:

    El usuario selecciona la palabra ipsum. ( Vista previa grande )

     

    La selección del usuario se puede representar como:

    { anchor: {path: [2,0], offset: 5}, /*0th text node inside the paragraph node which itself is index 2 in the document*/ focus: {path: [2,0], offset: 11}, // space + 'ipsum'}`

    Configurando el editor

    En esta sección, configuraremos la aplicación y obtendremos un editor de texto enriquecido básico con SlateJS. La aplicación estándar tendría create-react-appdependencias de SlateJS agregadas. Estamos construyendo la interfaz de usuario de la aplicación utilizando componentes de react-bootstrap. ¡Empecemos!

    Cree una carpeta llamada wysiwyg-editory ejecute el siguiente comando desde dentro del directorio para configurar la aplicación de reacción. Luego ejecutamos un yarn startcomando que debería activar el servidor web local (el puerto predeterminado es 3000) y mostrarle una pantalla de bienvenida de React.

    npx create-react-app .yarn start

    Luego pasamos a agregar las dependencias de SlateJS a la aplicación.

    yarn add slate slate-react

    slatees el paquete principal de SlateJS e slate-reactincluye el conjunto de componentes de React que usaremos para renderizar los editores de Slate. SlateJS expone algunos paquetes más organizados por funcionalidad que uno podría considerar agregar a su editor.

    Primero creamos una utilscarpeta que contiene todos los módulos de utilidad que creamos en esta aplicación. Comenzamos creando un ExampleDocument.jsque devuelve una estructura de documento básica que contiene un párrafo con algo de texto. Este módulo se parece a continuación:

    const ExampleDocument = [ { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, ], },];export default ExampleDocument;

    Ahora agregamos una carpeta llamada componentsque contendrá todos nuestros componentes de React y hacemos lo siguiente:

    • Agregue nuestro primer componente React Editor.js. Sólo devuelve a divpor ahora.
    • Actualice el App.jscomponente para mantener el documento en su estado inicializado al ExampleDocumentanterior.
    • Represente el Editor dentro de la aplicación y pase el estado del documento y un onChangecontrolador al Editor para que el estado de nuestro documento se actualice a medida que el usuario lo actualiza.
    • También utilizamos los componentes Nav de React bootstrap para agregar una barra de navegación a la aplicación.

    App.jsEl componente ahora se ve así:

    import Editor from './components/Editor';function App() { const [document, updateDocument] = useState(ExampleDocument); return ( Navbar bg="dark" variant="dark" Navbar.Brand href="#" img src="/app-icon.png" className="d-inline-block align-top" /{" "} WYSIWYG Editor /Navbar.Brand /Navbar div className="App" Editor document={document} onChange={updateDocument} / /div / );

    Dentro del componente Editor, creamos una instancia del editor SlateJS y lo mantenemos dentro de useMemopara que el objeto no cambie entre renderizaciones.

     

    // dependencies imported as below.import { withReact } from "slate-react";import { createEditor } from "slate";const editor = useMemo(() = withReact(createEditor()), []);

    createEditornos proporciona la editorinstancia de SlateJS que utilizamos ampliamente en la aplicación para acceder a selecciones, ejecutar transformaciones de datos, etc. withReact es un complemento de SlateJS que agrega comportamientos React y DOM al objeto editor. Los complementos de SlateJS son funciones de Javascript que reciben el editorobjeto y le adjuntan alguna configuración. Esto permite a los desarrolladores web agregar configuraciones a su instancia del editor SlateJS de forma componible.

    Ahora importamos y renderizamos Slate /componentes Editable /de SlateJS con el accesorio del documento que obtenemos de App.js. Slateexpone un montón de contextos de React que usamos para acceder en el código de la aplicación. Editablees el componente que representa la jerarquía del documento para su edición. En general, el Editor.jsmódulo en esta etapa se ve a continuación:

    import { Editable, Slate, withReact } from "slate-react";import { createEditor } from "slate";import { useMemo } from "react";export default function Editor({ document, onChange }) { const editor = useMemo(() = withReact(createEditor()), []); return ( Slate editor={editor} value={document} onChange={onChange} Editable / /Slate );}

    En este punto, hemos agregado los componentes necesarios de React y el editor está lleno de un documento de ejemplo. Nuestro editor ahora debería estar configurado, permitiéndonos escribir y cambiar el contenido en tiempo real, como se muestra en el screencast a continuación.

    Nodos de encabezados y párrafos en el editor. ( Vista previa grande )

    Estilos de personajes

    De manera similar a renderElement, SlateJS proporciona una función llamada renderLeaf que se puede usar para personalizar la representación de los nodos de texto ( Leafrefiriéndose a los nodos de texto que son las hojas/nodos de nivel más bajo del árbol del documento). Siguiendo el ejemplo de renderElement, escribimos una implementación para renderLeaf.

    export default function useEditorConfig(editor) { return { renderElement, renderLeaf };}// ...function renderLeaf({ attributes, children, leaf }) { let el = {children}/; if (leaf.bold) { el = strong{el}/strong; } if (leaf.code) { el = code{el}/code; } if (leaf.italic) { el = em{el}/em; } if (leaf.underline) { el = u{el}/u; } return span {...attributes}{el}/span;}

    Una observación importante de la implementación anterior es que nos permite respetar la semántica HTML para los estilos de caracteres. Dado que renderLeaf nos da acceso al nodo de texto leafen sí, podemos personalizar la función para implementar una representación más personalizada. Por ejemplo, es posible que tenga una manera de permitir que los usuarios elijan un highlightColortexto y verifiquen la propiedad de la hoja aquí para adjuntar los estilos respectivos. Todo sobre Pinganillos

     

    Ahora actualizamos el componente Editor para usar lo anterior, ExampleDocumenttener algunos nodos de texto en el párrafo con combinaciones de estos estilos y verificar que se representen como se esperaba en el Editor con las etiquetas semánticas que usamos.

    # src/components/Editor.jsconst { renderElement, renderLeaf } = useEditorConfig(editor);return ( ... Editable renderElement={renderElement} renderLeaf={renderLeaf} /);
    # src/utils/ExampleDocument.js{ type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, { text: "Bold text.", bold: true, code: true }, { text: "Italic text.", italic: true }, { text: "Bold and underlined text.", bold: true, underline: true }, { text: "variableFoo", code: true }, ], },

    Estilos de caracteres en la interfaz de usuario y cómo se representan en el árbol DOM. ( Vista previa grande )

    Agregar una barra de herramientas

    Comencemos agregando un nuevo componente Toolbar.jsal que agregamos algunos botones para estilos de caracteres y un menú desplegable para estilos de párrafo y los conectamos más adelante en la sección.

    const PARAGRAPH_STYLES = ["h1", "h2", "h3", "h4", "paragraph", "multiple"];const CHARACTER_STYLES = ["bold", "italic", "underline", "code"];export default function Toolbar({ selection, previousSelection }) { return ( div className="toolbar" {/* Dropdown for paragraph styles */} DropdownButton className={"block-style-dropdown"} disabled={false} title={getLabelForBlockStyle("paragraph")} {PARAGRAPH_STYLES.map((blockType) = ( Dropdown.Item eventKey={blockType} key={blockType} {getLabelForBlockStyle(blockType)} /Dropdown.Item ))} /DropdownButton {/* Buttons for character styles */} {CHARACTER_STYLES.map((style) = ( ToolBarButton key={style} icon={i className={`bi ${getIconForButton(style)}`} /} isActive={false} / ))} /div );}function ToolBarButton(props) { const { icon, isActive, ...otherProps } = props; return ( Button variant="outline-primary" className="toolbar-btn" active={isActive} {...otherProps} {icon} /Button );}

    Extraemos los botones del ToolbarButtoncomponente que envuelve el componente del botón React Bootstrap. Luego representamos la barra de herramientas sobre el componente Editableinterno Editory verificamos que la barra de herramientas aparezca en la aplicación.

    Barra de herramientas con botones ( vista previa grande )

    Estas son las tres funcionalidades clave que necesitamos que admita la barra de herramientas:

    1. Cuando el cursor del usuario está en un lugar determinado del documento y hace clic en uno de los botones de estilo de carácter, debemos alternar el estilo del texto que puede escribir a continuación.
    2. Cuando el usuario selecciona un rango de texto y hace clic en uno de los botones de estilo de carácter, debemos alternar el estilo para esa sección específica.
    3. Cuando el usuario selecciona un rango de texto, queremos actualizar el menú desplegable de estilo de párrafo para reflejar el tipo de párrafo de la selección. Si seleccionan un valor diferente de la selección, queremos actualizar el estilo de párrafo de toda la selección para que sea el que seleccionaron.

    Veamos cómo funcionan estas funcionalidades en el Editor antes de comenzar a implementarlas.

     

    is one such library that enables building selectors.

    We don’t use selection inside the toolbar until later but passing it down as a prop makes the toolbar re-render each time the selection changes on the Editor. We do this because we cannot rely solely on the document content change to trigger a re-render on the hierarchy (App - Editor - Toolbar) as users might just keep clicking around the document thereby changing selection but never actually changing the document content itself.

    Toggling Character Styles

    We now move to getting what the active character styles are from SlateJS and using those inside the Editor. Let’s add a new JS module EditorUtils that will host all the util functions we build going forward to get/do stuff with SlateJS. Our first function in the module is getActiveStyles that gives a Set of active styles in the editor. We also add a function to toggle a style on the editor function — toggleStyle:

    # src/utils/EditorUtils.jsimport { Editor } from "slate";export function getActiveStyles(editor) { return new Set(Object.keys(Editor.marks(editor) ?? {}));}export function toggleStyle(editor, style) { const activeStyles = getActiveStyles(editor); if (activeStyles.has(style)) { Editor.removeMark(editor, style); } else { Editor.addMark(editor, style, true); }}

    Both the functions take the editor object which is the Slate instance as a parameter as will a lot of util functions we add later in the article.In Slate terminology, formatting styles are called Marks and we use helper methods on Editor interface to get, add and remove these marks.We import these util functions inside the Toolbar and wire them to the buttons we added earlier.

    # src/components/Toolbar.jsimport { getActiveStyles, toggleStyle } from "../utils/EditorUtils";import { useEditor } from "slate-react";export default function Toolbar({ selection }) { const editor = useEditor();return div... {CHARACTER_STYLES.map((style) = ( ToolBarButton key={style} characterStyle={style} icon={i className={`bi ${getIconForButton(style)}`} /} isActive={getActiveStyles(editor).has(style)} onMouseDown={(event) = { event.preventDefault(); toggleStyle(editor, style); }} / ))}/div

    useEditor is a Slate hook that gives us access to the Slate instance from the context where it was attached by the lt;Slate component higher up in the render hierarchy.

    One might wonder why we use onMouseDown here instead of onClick? There is an open Github Issue about how Slate turns the selection to null when the editor loses focus in any way. So, if we attach onClick handlers to our toolbar buttons, the selection becomes null and users lose their cursor position trying to toggle a style which is not a great experience. We instead toggle the style by attaching a onMouseDown event which prevents the selection from getting reset. Another way to do this is to keep track of the selection ourselves so we know what the last selection was and use that to toggle the styles. We do introduce the concept of previousSelection later in the article but to solve a different problem.

     

    SlateJS allows us to configure event handlers on the Editor. We use that to wire up keyboard shortcuts to toggle the character styles. To do that, we add a KeyBindings object inside useEditorConfig where we expose a onKeyDown event handler attached to the Editable component. We use the is-hotkey util to determine the key combination and toggle the corresponding style.

    # src/hooks/useEditorConfig.jsexport default function useEditorConfig(editor) { const onKeyDown = useCallback( (event) = KeyBindings.onKeyDown(editor, event), [editor] ); return { renderElement, renderLeaf, onKeyDown };}const KeyBindings = { onKeyDown: (editor, event) = { if (isHotkey("mod+b", event)) { toggleStyle(editor, "bold"); return; } if (isHotkey("mod+i", event)) { toggleStyle(editor, "italic"); return; } if (isHotkey("mod+c", event)) { toggleStyle(editor, "code"); return; } if (isHotkey("mod+u", event)) { toggleStyle(editor, "underline"); return; } },};# src/components/Editor.js... Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} /

    — A helper function commonly used to search for nodes in a tree filtered by different options.

    nodes( editor: Editor, options?: { at?: Location | Span match?: NodeMatchT mode?: 'all' | 'highest' | 'lowest' universal?: boolean reverse?: boolean voids?: boolean } ) = GeneratorNodeEntryT, void, undefined

    The helper function takes an Editor instance and an options object that is a way to filter nodes in the tree as it traverses it. The function returns a generator of NodeEntry. A NodeEntry in Slate terminology is a tuple of a node and the path to it — [node, pathToNode]. The options found here are available on most of the Slate helper functions. Let’s go through what each of those means:

    • at
      This can be a Path/Point/Range that the helper function would use to scope down the tree traversal to. This defaults to editor.selection if not provided. We also use the default for our use case below as we’re interested in nodes within user’s selection.
    • match
      This is a matching function one can provide that is called on each node and included if it is a match. We use this parameter in our implementation below to filter to block elements only.
    • mode
      Let’s the helper functions know if we’re interested in all, highest-level or lowest level nodes at the given location matching match function. This parameter (set to highest) helps us escape trying to traverse the tree up ourselves to find the top-level nodes.
    • universal
      Flag to choose between full or partial matches of the nodes. (GitHub Issue with the proposal for this flag has some examples explaining it)
    • reverse
      If the node search should be in the reverse direction of the start and end points of the location passed in.
    • voids
      If the search should filter to void elements only.

    SlateJS exposes a lot of helper functions that let you query for nodes in different ways, traverse the tree, update the nodes or selections in complex ways. Worth digging into some of these interfaces (listed towards the end of this article) when building complex editing functionalities on top of Slate.

     

    With that background on the helper function, below is an implementation of getTextBlockStyle.

    # src/utils/EditorUtils.js export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } const topLevelBlockNodesInSelection = Editor.nodes(editor, { at: editor.selection, mode: "highest", match: (n) = Editor.isBlock(editor, n), }); let blockType = null; let nodeEntry = topLevelBlockNodesInSelection.next(); while (!nodeEntry.done) { const [node, _] = nodeEntry.value; if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } nodeEntry = topLevelBlockNodesInSelection.next(); } return blockType;}

    Performance Consideration

    The current implementation of Editor.nodes finds all the nodes throughout the tree across all levels that are within the range of the at param and then runs match filters on it (check nodeEntries and the filtering later — source). This is okay for smaller documents. However, for our use case, if the user selected, say 3 headings and 2 paragraphs (each paragraph containing say 10 text nodes), it will cycle through at least 25 nodes (3 + 2 + 2*10) and try to run filters on them. Since we already know we’re interested in top-level nodes only, we could find start and end indexes of the top level blocks from the selection and iterate ourselves. Such a logic would loop through only 3 node entries (2 headings and 1 paragraph). Code for that would look something like below:

    export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } // gives the forward-direction points in case the selection was // was backwards. const [start, end] = Range.edges(selection); //path[0] gives us the index of the top-level block. let startTopLevelBlockIndex = start.path[0]; const endTopLevelBlockIndex = end.path[0]; let blockType = null; while (startTopLevelBlockIndex = endTopLevelBlockIndex) { const [node, _] = Editor.node(editor, [startTopLevelBlockIndex]); if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } startTopLevelBlockIndex++; } return blockType;}

    As we add more functionalities to a WYSIWYG Editor and need to traverse the document tree often, it is important to think about the most performant ways to do so for the use case at hand as the available API or helper methods might not always be the most efficient way to do so.

    Once we have getTextBlockStyle implemented, toggling of the block style is relatively straightforward. If the current style is not what user selected in the dropdown, we toggle the style to that. If it is already what user selected, we toggle it to be a paragraph. Because we are representing paragraph styles as nodes in our document structure, toggle a paragraph style essentially means changing the type property on the node. We use Transforms.setNodes provided by Slate to update properties on nodes.

    Our toggleBlockType’s implementation is as below:

    # src/utils/EditorUtils.jsexport function toggleBlockType(editor, blockType) { const currentBlockType = getTextBlockStyle(editor); const changeTo = currentBlockType === blockType ? "paragraph" : blockType; Transforms.setNodes( editor, { type: changeTo }, // Node filtering options supported here too. We use the same // we used with Editor.nodes above. { at: editor.selection, match: (n) = Editor.isBlock(editor, n) } );}

    Finally, we update our Paragraph-Style dropdown to use






    Tal vez te puede interesar:

    1. Creación de su propia biblioteca de validación de React: las características (Parte 2)
    2. Introducción a Quasar Framework: creación de aplicaciones multiplataforma
    3. Creación de un componente web retro que se puede arrastrar con iluminación
    4. Creación y acoplamiento de una aplicación Node.js con arquitectura sin estado con la ayuda de Kinsta

    Creación de un editor de texto enriquecido (WYSIWYG)

    Creación de un editor de texto enriquecido (WYSIWYG)

    Register! Deploy Fast. Deploy Smart Índice Comprender la estructura del documento

    programar

    es

    https://aprendeprogramando.es/static/images/programar-creacion-de-un-editor-de-texto-enriquecido-wysiwyg-1099-0.jpg

    2024-05-22

     

    Creación de un editor de texto enriquecido (WYSIWYG)
    Creación de un editor de texto enriquecido (WYSIWYG)

    Si crees que alguno de los contenidos (texto, imagenes o multimedia) en esta página infringe tus derechos relativos a propiedad intelectual, marcas registradas o cualquier otro de tus derechos, por favor ponte en contacto con nosotros en el mail [email protected] y retiraremos este contenido inmediatamente

     

     

    Top 20