Iván Hernández Cazorla - Bloghttps://blog.ivanhercaz.com2022-10-31T19:50:29ZHistorias, aventuras y desventuras de un alquimista macaronésicoivanhercazSubir archivos multimedia con Trix Editor y Phoenix + Waffle2022-11-27T17:21:24Zhttps://blog.ivanhercaz.com/phoenix_trix_rich_text_editor_integrate_uploads<p><strong>Nota</strong> (2023-11-20): tengo pendiente hacerle una actualización a este artículo tras la revisión que
le hizo un gran alquimista, y mejor amigo. Cualquier duda, ¡no duden en preguntar! En el
pie del artículo indico dónde.</p>
<p>Hace unas semanas publiqué la entrada <em><a href="https://blog.ivanhercaz.com/phoenix_trix_rich_text_editor" rel="nofollow">Integrar Trix Editor con Phoenix</a></em>,
con la que quise enseñarles qué es Trix y cómo integrarlo en una instancia de Phoenix.
Si quieren saber más sobre Trix o cómo integrar Trix en Phoenix, les recomiendo
la entrada. En esta ocasión vamos a ver cómo podemos integrar la subida de archivos
con Trix.</p>
<p>Integrar Trix con Phoenix no conlleva muchas líneas de código, ya que principalmente
hay que crear un hook que facilite la comunicación de los eventos de Trix con Phoenix
y crear el campo para la entrada de texto por medio de un <a href="https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#hidden_input/3" rel="nofollow"><code>hidden_input/4</code></a>
y la etiqueta <code><trixeditor></code>.</p>
<h2 id="por-qu-no-funciona-la-subida-de-archivos-multimedia-con-trix-por-defecto"><a class="anchor" href="#por-qu-no-funciona-la-subida-de-archivos-multimedia-con-trix-por-defecto" rel="nofollow">#</a> Por qué no funciona la subida de archivos multimedia con Trix por defecto</h2>
<p>Antes de empezar con la parte práctica, vamos a entender por qué no podemos adjuntar
archivos multimedia, más concretamente imágenes, con Trix a pesar de estar integrado.</p>
<p>Por una parte, Trix no está programado para subir el archivo por sí mismo, sea con
Phoenix o cualquier otro framework, quien integre Trix es quien se encarga de programar
la subida y gestión de los archivos. Lo que sí proporciona Trix es una serie de
eventos que podemos escuchar para gestionar la subida de archivos:</p>
<ul>
<li><code>trix-attachment-add</code>, que se dispara cuando se adjunta un archivo al documento.</li>
<li><code>trix-attachment-remove</code>, que se dispara cuando se elimina un archivo del documento.</li>
<li><code>trix-file-accept</code>, que se dispara cuando se arrastra o inserta un archivo
en el editor.</li>
</ul>
<p>En esta ocasión solo vamos a utilizar <code>trix-attachment-add</code>.</p>
<h2 id="subida-de-archivos-a-phoenix-con-waffle"><a class="anchor" href="#subida-de-archivos-a-phoenix-con-waffle" rel="nofollow">#</a> Subida de archivos a Phoenix con Waffle</h2>
<p>Se podría gestionar la propia subida de archivos por medio de JavaScript, pero como
estamos en una instancia de Phoenix, nuestro objetivo es intentar que todo quede
lo mejor integrado posible. Para ello vamos a utilizar <a href="https://github.com/elixir-waffle/waffle" rel="nofollow">Waffle</a>,
un paquete de Elixir que facilita la subida de archivos, el almacenamiento de la
imagen en la base de datos con <a href="https://github.com/elixir-waffle/waffle_ecto" rel="nofollow">Waffle.Ecto</a>
e incluso la integración con Amazon S3, aunque esto último no lo veremos en esta
entrada.</p>
<p>Lo primero sería configurar <code>waffle</code>, por lo que debemos introducir las dependencias
en nuestro <code>mix.exs</code>:</p>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">defp</span> <span class="n">deps</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"> <span class="p">[</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"> <span class="n">...</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"> <span class="p">{</span><span class="ss">:waffle</span><span class="p">,</span> <span class="s2">"~> 1.1.6"</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"> <span class="p">{</span><span class="ss">:waffle_ecto</span><span class="p">,</span> <span class="s2">"~> 0.0.11"</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"> <span class="p">]</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="k">end</span>
</span></span></code></pre><p>Hacemos <code>mix deps.get</code>, ¡y ya tenemos Waffle instalado. Ahora debemos especificar
en nuestros archivos de configuración (<code>config/*.exs</code>) cómo vamos a almacenar los
archivos según el entorno en el que despleguemos nuestra aplicación (por ej., podría
guardarse en S3 si la aplicación está desplegada en producción y en local si estamos
en el entorno de desarrollo. En nuestro caso solo veremos como guardarlos nosotros
mismos en un directorio local:</p>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"><span class="n">config</span> <span class="ss">:waffle</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"> <span class="ss">storage</span><span class="p">:</span> <span class="nc">Waffle.Storage.Local</span>
</span></span></code></pre><h3 id="contexto"><a class="anchor" href="#contexto" rel="nofollow">#</a> Contexto</h3>
<p>Si no tenemos ya un contexto con el que gestionar las imágenes, debemos crear uno:</p>
<pre><code>mix phx.gen.html Gallery Image images image:string
</code></pre>
<p>Ahora tenemos en nuestra aplicación lo siguiente:</p>
<ul>
<li><code>lib/app/gallery.ex</code>: el módulo del contexto.</li>
<li><code>lib/app/gallery/images.ex</code>: el esquema para la gestión de la tabla <code>images</code>.</li>
<li><code>lib/app_web/views/image_view.ex</code>, la vista, y <code>lib/app_web/controllers/image_controller.ex</code>,
el controlador.</li>
<li>Y las correspondientes plantillas CRUD en <code>lib/app_web/templates/image</code>.</li>
</ul>
<p>Introducimos la ruta para este controlador en <code>router.ex</code>:</p>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">defmodule</span> <span class="nc">AppWeb.Router</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"> <span class="n">scope</span> <span class="s2">"/"</span><span class="p">,</span> <span class="nc">AppWeb</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"> <span class="n">pipe_through</span> <span class="ss">:browser</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"> <span class="n">...</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"> <span class="n">resources</span> <span class="s2">"/images"</span><span class="p">,</span> <span class="nc">ImageController</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"> <span class="k">end</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="k">end</span>
</span></span></code></pre><p>Y finalmente realizamos la migración con <code>mix ecto.migrate</code>.</p>
<h2 id="gestor-de-subida-de-archivos"><a class="anchor" href="#gestor-de-subida-de-archivos" rel="nofollow">#</a> Gestor de subida de archivos</h2>
<p>Ahora tenemos que generar el gestor de subida con la tarea <code>mix waffle.g file_image</code>,
que generará un archivo en <code>lib/app_web/uploaders/file_image.ex</code>. El contenido de
ese archivo está comentado en su mayoría, por lo que debemos entrar en él y ajustarlo
según nuestras necesidades. En este caso inyectamos <code>Waffle.Definition</code> y <code>Waffle.Ecto.Definition</code>
y descomentamos la función <code>validate/1</code>, con la que podremos validar si la imagen
tiene una extensión que admitimos. El conjunto quedaría de la siguiente manera:</p>
<pre class="chroma"><code><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">defmodule</span> <span class="nc">AppWeb.FileImage</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"> <span class="kn">use</span> <span class="nc">Waffle.Definition</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"> <span class="kn">use</span> <span class="nc">Waffle.Ecto.Definition</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"> <span class="na">@versions</span> <span class="p">[</span><span class="ss">:original</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="n">...</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"> <span class="c1"># Whitelist file extensions:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"> <span class="kd">def</span> <span class="n">validate</span><span class="p">({</span><span class="n">file</span><span class="p">,</span> <span class="n">_</span><span class="p">})</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"> <span class="n">file_extensions</span> <span class="o">=</span> <span class="n">file</span><span class="o">.</span><span class="n">file_name</span> <span class="o">|></span> <span class="nc">Path</span><span class="o">.</span><span class="n">extname</span><span class="p">()</span> <span class="o">|></span> <span class="nc">String</span><span class="o">.</span><span class="n">downcase</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"> <span class="k">case</span> <span class="nc">Enum</span><span class="o">.</span><span class="n">member?</span><span class="p">(</span><span class="sx">~w(.jpg .jpeg .gif .png)</span><span class="p">,</span> <span class="n">file_extension</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"> <span class="no">true</span> <span class="o">-></span> <span class="ss">:ok</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"> <span class="no">false</span> <span class="o">-></span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="s2">"tipo de archivo inválido"</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"> <span class="k">end</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"> <span class="k">end</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"> <span class="n">...</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="k">end</span>
</span></span></code></pre><p>Con respecto al resto de funciones comentadas, tenemos la opción de dejarlas ahí
por si nos fuese de utilidad en el futuro u optar por eliminarlas y dejar el archivo
más pequeño y limpio. Aunque, en este paso es importante destacar que los archivos
se guardan por defecto en la carpeta <code>/uploads</code>. Si quisiésemos otra ubicación,
tendríamos que descomentar las líneas de la función <code>storage_dir/2</code> y ajustarla
a nuestras necesidades.</p>
<p>Ahora debemos modificar el esquema que creamos previamente para inyectar <code>Waffle.Ecto.Schema</code>
y corregir el tipo del campo <code>image</code>.</p>
<pre class="chroma"><code><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">defmodule</span> <span class="nc">App.Gallery.Image</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"> <span class="kn">use</span> <span class="nc">Ecto.Schema</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"> <span class="kn">use</span> <span class="nc">Waffle.Ecto.Schema</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"> <span class="kn">import</span> <span class="nc">Ecto.Changeset</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="n">schema</span> <span class="s2">"images"</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"> <span class="n">field</span> <span class="ss">:image</span><span class="p">,</span> <span class="nc">App.FileImage.Type</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"> <span class="k">end</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"> <span class="n">...</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">end</span>
</span></span></code></pre><p>Finalmente, para acabar con el gestor de subida de imágenes, tendríamos que añadir
a nuestro <code>endpoint.ex</code> otro plug para la gestión de los archivos estáticos (digo
otro, porque ya está el definido por Phoenix).</p>
<pre class="chroma"><code><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">defmodule</span> <span class="nc">AppWeb.Endpoint</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"> <span class="n">...</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"> <span class="n">plug</span> <span class="nc">Plug.Static</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"> <span class="ss">at</span><span class="p">:</span> <span class="s2">"/uploads"</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"> <span class="ss">from</span><span class="p">:</span> <span class="nc">Path</span><span class="o">.</span><span class="n">expand</span><span class="p">(</span><span class="s2">"./uploads"</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="ss">gzip</span><span class="p">:</span> <span class="no">false</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"> <span class="n">...</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">end</span>
</span></span></code></pre><p>En el caso de que hayan definido otro directorio por medio de <code>storage_dir/2</code> en
el módulo <code>App.FileImage</code>, tendrán que ajustar esa misma ruta en el <code>plug</code>.</p>
<h2 id="ajustar-las-plantillas"><a class="anchor" href="#ajustar-las-plantillas" rel="nofollow">#</a> Ajustar las plantillas</h2>
<p>Cuando generamos las plantillas para el controlador de las imágenes, estas se generan
con la estructura por defecto. En nuestro caso, en <code>lib/app_web/templates/image</code>
tenemos <code>index.html.heex</code> y <code>show.html.heex</code>, ambas con <code><%= @image.image %></code>, que
en este caso nos proporcionaría un mapa que no nos sirve. Para poder obtener la
url de la imagen debemos utilizar <code>App.FileImage.url/1</code> y combinarla con la etiqueta
HTML que necesitásemos o con alguna de las funciones definidas por <code>Phoenix.HTML</code>,
como por ejemplo <a href="https://hexdocs.pm/phoenix_html/Phoenix.HTML.Link.html#link/2" rel="nofollow"><code>link/2</code></a>
o <a href="https://hexdocs.pm/phoenix_html/Phoenix.HTML.Tag.html#img_tag/2" rel="nofollow"><code>img_tag/2</code></a>.
Veamos algunos ejemplos, uno con cada una de ellas:</p>
<ul>
<li>Generar un enlace que dirija a la imagen.</li>
</ul>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"><span class="n">link</span> <span class="s2">"texto del enlace"</span><span class="p">,</span> <span class="ss">to</span><span class="p">:</span> <span class="nc">App.FileImage</span><span class="o">.</span><span class="n">url</span><span class="p">({</span><span class="n">image</span><span class="o">.</span><span class="n">image</span><span class="p">,</span> <span class="n">image</span><span class="p">})</span>
</span></span></code></pre><ul>
<li>Generar un enlace con el nombre del archivo que dirija a la imagen.</li>
</ul>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"><span class="n">link</span> <span class="n">image</span><span class="o">.</span><span class="n">image</span><span class="o">.</span><span class="n">file_name</span><span class="p">,</span> <span class="ss">to</span><span class="p">:</span> <span class="nc">App.FileImage</span><span class="o">.</span><span class="n">url</span><span class="p">({</span><span class="n">image</span><span class="o">.</span><span class="n">image</span><span class="p">,</span> <span class="n">image</span><span class="p">})</span>
</span></span></code></pre><ul>
<li>Mostrar la imagen.</li>
</ul>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"><span class="n">img_tag</span> <span class="nc">App.FileImage</span><span class="o">.</span><span class="n">url</span><span class="p">({</span><span class="n">image</span><span class="o">.</span><span class="n">image</span><span class="p">,</span> <span class="n">image</span><span class="p">}),</span> <span class="ss">signed</span><span class="p">:</span> <span class="no">true</span>
</span></span></code></pre><h2 id="integracin-con-trix"><a class="anchor" href="#integracin-con-trix" rel="nofollow">#</a> Integración con Trix</h2>
<p>Llegado a este punto solo nos queda integrar este sistema de subida de imágenes con
el editor. Para ello primero vamos a proporcionar a crear una pequeña API que nos
permita trabajar con esas imágenes.</p>
<h3 id="api"><a class="anchor" href="#api" rel="nofollow">#</a> API</h3>
<p>Para simplificar esta tarea podemos tomar como punto de partida el <code>AppWeb.ImageController</code>
que generamos previamente: creamos el directorio <code>api</code> dentro del directorio de controladores
de Phoenix. Al archivo copiado le cambiamos el nombre a <code>AppWeb.Api.ImageController</code> y
cambiamos la extensión <code>html</code> por <code>json</code> en todas las funciones <code>render/3</code>. Llegados
a este punto podemos entretenernos o no en redefinirlo más adecuadamente según el uso
que le vamos a dar, pero esta entrada ya se está alargando mucho, por lo que eso queda
como decisión de cada uno.</p>
<p>Eso sí, la función <code>create/2</code> debemos ajustarla para que en lugar de que nos redireccione
a la imagen una vez se cree, nos devuelva la URL de la imagen:</p>
<pre class="chroma"><code><span class="line"><span class="ln"> 1</span><span class="cl"> <span class="kd">def</span> <span class="n">create</span><span class="p">(</span><span class="n">conn</span><span class="p">,</span> <span class="p">%{</span><span class="s2">"image"</span> <span class="o">=></span> <span class="n">image_params</span><span class="p">})</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"> <span class="k">case</span> <span class="nc">MediaResources</span><span class="o">.</span><span class="n">create_image</span><span class="p">(</span><span class="n">image_params</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"> <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">image</span><span class="p">}</span> <span class="o">-></span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"> <span class="n">url</span> <span class="o">=</span> <span class="nc">AppWeb.Router.Helpers</span><span class="o">.</span><span class="n">image_path</span><span class="p">(</span><span class="n">conn</span><span class="p">,</span> <span class="ss">:show</span><span class="p">,</span> <span class="n">image</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"> <span class="n">image</span> <span class="o">=</span> <span class="nc">Map</span><span class="o">.</span><span class="n">merge</span><span class="p">(</span><span class="n">image</span><span class="p">,</span> <span class="p">%{</span><span class="ss">url</span><span class="p">:</span> <span class="n">url</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"> <span class="n">conn</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="o">|></span> <span class="n">render</span><span class="p">(</span><span class="s2">"show.json"</span><span class="p">,</span> <span class="ss">image</span><span class="p">:</span> <span class="n">image</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%</span><span class="nc">Ecto.Changeset</span><span class="p">{}</span> <span class="o">=</span> <span class="n">changeset</span><span class="p">}</span> <span class="o">-></span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"> <span class="n">render</span><span class="p">(</span><span class="n">conn</span><span class="p">,</span> <span class="s2">"new.json"</span><span class="p">,</span> <span class="ss">changeset</span><span class="p">:</span> <span class="n">changeset</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"> <span class="k">end</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"> <span class="k">end</span>
</span></span></code></pre><p>Lo que hacemos en este caso es obtener la ruta de la imagen en la variable <code>url</code> a
partir de su <code>image.id</code> y luego introducimos esa variable en la clave <code>url</code> del mapa
<code>image</code>.</p>
<h3 id="ajustar-el-hook"><a class="anchor" href="#ajustar-el-hook" rel="nofollow">#</a> Ajustar el hook</h3>
<p>En la entrada <a href="https://blog.ivanhercaz.com/phoenix_trix_rich_text_editor" rel="nofollow">Integrar Trix Editor con Phoenix</a>
creamos un hook muy sencillito para añadir Trix a nuestro proyecto Phoenix. El hook
resultante es el siguiente:</p>
<pre class="chroma"><code><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">const</span> <span class="nx">TrixHook</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"> <span class="nx">initListener</span><span class="p">(</span><span class="nx">trix</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"> <span class="nx">trix</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="nx">event</span><span class="p">,</span> <span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`event </span><span class="si">${</span><span class="nx">event</span><span class="si">}</span><span class="sb"> fired!`</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"> <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="nx">bindTrixEditor</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"> <span class="cm">/*
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="cm"> this.el = elemento en el que está el hook
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="cm"> el siguiente elemento sería el contenedor "page-form_body-editor"
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="cm"> el hijo "1" se refiere a la etiqueta <trix-editor>, el hijo "0" es la barra
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="cm"> de tareas del editor
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="cm"> */</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"> <span class="kd">let</span> <span class="nx">trix</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">el</span><span class="p">.</span><span class="nx">nextElementSibling</span><span class="p">.</span><span class="nx">children</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"> <span class="k">this</span><span class="p">.</span><span class="nx">initListener</span><span class="p">(</span><span class="nx">trix</span><span class="p">,</span> <span class="s2">"trix-change"</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"> <span class="p">},</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"> <span class="nx">mounted</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">"Hello, Trix!"</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"> <span class="k">this</span><span class="p">.</span><span class="nx">bindTrixEditor</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"> <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="kr">export</span> <span class="k">default</span> <span class="nx">TrixHook</span><span class="p">;</span>
</span></span></code></pre><p>Vamos a empezar por añadir un nuevo listener a nuestro <code>bindTrixEditor(self)</code>:</p>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"> <span class="nx">self</span><span class="p">.</span><span class="nx">initListener</span><span class="p">(</span><span class="nx">self</span><span class="p">,</span> <span class="nx">trix</span><span class="p">,</span> <span class="s2">"trix-attachment-add"</span><span class="p">)</span>
</span></span></code></pre><p>Esto lo añadimos por debajo del que ya tenemos (<code>trix-change</code>) y, en <code>initListener(self, trix, event)</code>
vamos a pedirle al sistema que cuando se produzca ese evento, se suba el archivo
con una función que desarrollaremos en el siguiente paso:</p>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"> <span class="n">initListener</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">trix</span><span class="p">,</span> <span class="n">event</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"> <span class="n">trix</span><span class="o">.</span><span class="n">addEventListener</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">function</span><span class="p">(</span><span class="n">e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"> <span class="k">if</span> <span class="p">(</span><span class="n">event</span> <span class="o">===</span> <span class="s2">"trix-attachment-add"</span> <span class="o">&&</span> <span class="n">e</span><span class="o">.</span><span class="n">attachment</span><span class="o">.</span><span class="n">file</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"> <span class="n">self</span><span class="o">.</span><span class="n">uploadFileAttachment</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">e</span><span class="o">.</span><span class="n">attachment</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"> <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"> <span class="p">})</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"> <span class="p">},</span>
</span></span></code></pre><p><code>e.attachment.file</code> hace referencia al archivo que ha seleccionado el usuario en
el diálogo de selección de archivos. Esto lo pasamos a la función <code>uploadFileAttachment(self, attachment)</code>,
que definimos de la siguiente manera:</p>
<pre class="chroma"><code><span class="line"><span class="ln"> 1</span><span class="cl"> <span class="nx">uploadFileAttachment</span><span class="p">(</span><span class="nx">self</span><span class="p">,</span> <span class="nx">attachment</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"> <span class="kd">let</span> <span class="nx">file</span> <span class="o">=</span> <span class="nx">attachment</span><span class="p">.</span><span class="nx">file</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"> <span class="kd">let</span> <span class="nx">form</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">FormData</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"> <span class="nx">form</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="s2">"Content-Type"</span><span class="p">,</span> <span class="nx">file</span><span class="p">.</span><span class="nx">type</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"> <span class="nx">form</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="s2">"image[image]"</span><span class="p">,</span> <span class="nx">file</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="kd">let</span> <span class="nx">csrf_token</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s2">"meta[name='csrf-token']"</span><span class="p">).</span><span class="nx">getAttribute</span><span class="p">(</span><span class="s2">"content"</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"> <span class="kd">let</span> <span class="nx">xhr</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">XMLHttpRequest</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"> <span class="nx">xhr</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="s2">"POST"</span><span class="p">,</span> <span class="s2">"/api/images"</span><span class="p">,</span> <span class="kc">true</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"> <span class="nx">xhr</span><span class="p">.</span><span class="nx">setRequestHeader</span><span class="p">(</span><span class="s2">"X-CSRF-Token"</span><span class="p">,</span> <span class="nx">csrf_token</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"> <span class="nx">xhr</span><span class="p">.</span><span class="nx">upload</span><span class="p">.</span><span class="nx">onprogress</span> <span class="o">=</span> <span class="kd">function</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"> <span class="kd">let</span> <span class="nx">progress</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">loaded</span> <span class="o">/</span> <span class="nx">event</span><span class="p">.</span><span class="nx">total</span> <span class="o">*</span> <span class="mi">100</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"> <span class="nx">attachment</span><span class="p">.</span><span class="nx">setUploadProgress</span><span class="p">(</span><span class="nx">progress</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"> <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"> <span class="nx">xhr</span><span class="p">.</span><span class="nx">onload</span> <span class="o">=</span> <span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"> <span class="k">if</span> <span class="p">(</span><span class="nx">xhr</span><span class="p">.</span><span class="nx">status</span> <span class="o">==</span> <span class="mi">200</span> <span class="o">||</span> <span class="nx">xhr</span><span class="p">.</span><span class="nx">status</span> <span class="o">==</span> <span class="mi">201</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"> <span class="kd">let</span> <span class="nx">response</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">xhr</span><span class="p">.</span><span class="nx">responseText</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl"> <span class="k">return</span> <span class="nx">attachment</span><span class="p">.</span><span class="nx">setAttributes</span><span class="p">({</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"> <span class="nx">url</span><span class="o">:</span> <span class="nx">response</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">image_url</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"> <span class="nx">href</span><span class="o">:</span> <span class="nx">response</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">image_url</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"> <span class="p">})</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"> <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"> <span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl"> <span class="k">return</span> <span class="nx">xhr</span><span class="p">.</span><span class="nx">send</span><span class="p">(</span><span class="nx">form</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl"> <span class="p">},</span>
</span></span></code></pre><p>En esta función creamos un objeto <a href="https://developer.mozilla.org/en-US/docs/Web/API/FormData/FormData" rel="nofollow"><code>FormData</code></a>
y le añadimos el tipo de archivo y el archivo en sí mismo.</p>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"> <span class="kd">let</span> <span class="nx">file</span> <span class="o">=</span> <span class="nx">attachment</span><span class="p">.</span><span class="nx">file</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"> <span class="kd">let</span> <span class="nx">form</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">FormData</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"> <span class="nx">form</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="s2">"Content-Type"</span><span class="p">,</span> <span class="nx">file</span><span class="p">.</span><span class="nx">type</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"> <span class="nx">form</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="s2">"image[image]"</span><span class="p">,</span> <span class="nx">file</span><span class="p">)</span>
</span></span></code></pre><p><code>csrf_token</code> nos devuelve el token CSRF del formulario a partir de la selección
del valor existente en la etiqueta <code><meta name="csrf-token"></code>. Luego creamos un
nuevo objeto <code>XMLHttpRequest</code>, inicializamos la petición a nuestra API y establecemos
el token CSRF.</p>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"> <span class="kd">let</span> <span class="nx">xhr</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">XMLHttpRequest</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"> <span class="nx">xhr</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="s2">"POST"</span><span class="p">,</span> <span class="s2">"/api/images"</span><span class="p">,</span> <span class="kc">true</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"> <span class="nx">xhr</span><span class="p">.</span><span class="nx">setRequestHeader</span><span class="p">(</span><span class="s2">"X-CSRF-Token"</span><span class="p">,</span> <span class="nx">csrf_token</span><span class="p">)</span>
</span></span></code></pre><p>Hacemos uso de <code>xhr.upload.onprogress</code> para indicar el progreso de la subida
del archivo.</p>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"> <span class="nx">xhr</span><span class="p">.</span><span class="nx">upload</span><span class="p">.</span><span class="nx">onprogress</span> <span class="o">=</span> <span class="kd">function</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"> <span class="kd">let</span> <span class="nx">progress</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">loaded</span> <span class="o">/</span> <span class="nx">event</span><span class="p">.</span><span class="nx">total</span> <span class="o">*</span> <span class="mi">100</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"> <span class="nx">attachment</span><span class="p">.</span><span class="nx">setUploadProgress</span><span class="p">(</span><span class="nx">progress</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"> <span class="p">}</span>
</span></span></code></pre><p>Antes de enviar la petición le indicamos que compruebe si devuelve un estado 200 (<code>OK</code>)
o 201 (<code>CREATED</code>). Teóricamente debería bastar con el 200, pero en mi caso a veces
me fallaba sin el 201. Obtenemos la respuesta a la petición con <code>JSON.parse(xhr.response.Text)</code>
y devolvemos el <code>attachment</code> con dos nuevos atributos: <code>url</code> y <code>href</code>, que en nuestro caso
lo asociamos con el mismo valor, <code>image_url</code>, porque queremos que nos abra la imagen a parte
al hacerle clic.</p>
<pre class="chroma"><code><span class="line"><span class="ln"> 1</span><span class="cl"> <span class="nx">xhr</span><span class="p">.</span><span class="nx">onload</span> <span class="o">=</span> <span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"> <span class="k">if</span> <span class="p">(</span><span class="nx">xhr</span><span class="p">.</span><span class="nx">status</span> <span class="o">==</span> <span class="mi">200</span> <span class="o">||</span> <span class="nx">xhr</span><span class="p">.</span><span class="nx">status</span> <span class="o">==</span> <span class="mi">201</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"> <span class="kd">let</span> <span class="nx">response</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">xhr</span><span class="p">.</span><span class="nx">responseText</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"> <span class="k">return</span> <span class="nx">attachment</span><span class="p">.</span><span class="nx">setAttributes</span><span class="p">({</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"> <span class="nx">url</span><span class="o">:</span> <span class="nx">response</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">image_url</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="nx">href</span><span class="o">:</span> <span class="nx">response</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">image_url</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"> <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"> <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"> <span class="p">}</span>
</span></span></code></pre><p>Y, finalmente, enviamos la petición devolviendo <code>xhr.send(form)</code>.</p>
<h2 id="conclusiones-y-agradecimientos"><a class="anchor" href="#conclusiones-y-agradecimientos" rel="nofollow">#</a> Conclusiones y agradecimientos</h2>
<p>Con esto tendríamos Trix y la opción de subida de archivos integrada en nuestro
proyecto Phoenix. Si juntamos los archivos, ¡no es tanto el proceso! Solo que yo he
querido detallar cada parte porque cuando lo quise montar me sentí un poco perdido
al no encontrar muchos recursos sobre este tema.</p>
<p>La entrada empecé a escribirla poco después de la primera sobre Trix, ¡en noviembre!
Desde entonces ha habido novedades muy interesantes en Elixir y Phoenix. Incluso los
compañeros de ElixirCasts publicaron un <a href="https://elixircasts.io/trix-editor" rel="nofollow">episodio dedicado a integrar Trix Editor en
Phoenix</a>, el cual les recomiendo ver.</p>
<p>Como dije, la entrada se me ha demorado muchísimo, porque me fui liando con quehaceres
laborales y de la vida. Ahora mismo le haría algunos cambios a este enfoque, aunque
la base seguiría siendo la misma, sobre todo lo que se refiere al hook.</p>
<p>En cuanto pueda intentaré hacer esto mismo siguiendo las nuevas convenciones recomendadas
para crear APIs con Phoenix 1.7 e intentaré montar un repositorio con una aplicación
de ejemplo.</p>
<p>Y ya para terminar, dar las gracias a Mateusz Tatarski por su entrada sobre Elixir y Waffle,
y a Chris Oliver por su screencast sobre cómo integrar Trix Editor y la subidad de archivos
en un proyecto Ruby on Rails.</p>
<ul>
<li>Mateusz Tatarski, Curiosum: <a href="https://curiosum.com/blog/how-upload-file-elixir-waffle" rel="nofollow">How to upload a file in Elixir with Waffle</a>.</li>
<li>Chris Oliver, Go Rails: <a href="https://gorails.com/episodes/trix-editor" rel="nofollow">Using the Trix Editor plus File Upload Attachments</a></li>
</ul>
<hr>
<p>Cualquier comentario, duda o sugerencia me la pueden hacer llegar por Telegram, ya sea <a href="t.me/ivanhercaz" rel="nofollow">por privado</a>
o mencionarme (<code>@ivanhercaz</code>) en el grupo <a href="https://t.me/elixirES" rel="nofollow">elixirES</a>.</p>
<p>Y si encuentran algún error o no funciona, díganmelo por favor, porque la entrada la hice
en dos momentos muy distanciados entre sí y puede que se me haya escapado algún paso.</p>
<hr/>
<p>Formo parte de <a href="https://codeberg.org/molecula" rel="nofollow">Molécula</a>, un grupo de tres cowboys del espacio y alquimistas que programan mucho e intentan contribuir a la comunidad del software libre. Si te gusta lo que escribo o lo que contribuyo, puedes <a href="https://ko-fi.com/molecula" rel="nofollow">invitarnos a un café, té o maté</a>. ¡Estamos en proceso de liberar varios proyectos!</p>
<p>Todas las entradas de este blog están bajo la licencia <a href="https://creativecommons.org/licenses/by-sa/4.0/deed.es" rel="nofollow">CC BY-SA 4.0</a>. Las imágenes y otros recursos que no son de mi autoría tienen especificada su respectiva licencia. En caso de no tenerla, no dudes
en reportármelo.</p>
<p>We are a team of three space cowboys and alchemists who develop software and try to contribute to the free software community. If you like what I write or what I contribute, you can <a href="https://ko-fi.com/molecula" rel="nofollow">invite us for a coffee, tea or maté</a>. I still have have to finish developing several projects and, of course, release them!</p>
<p>All blog posts are licensed under <a href="https://creativecommons.org/licenses/by-sa/4.0/deed.en" rel="nofollow">CC BY-SA 4.0</a>.
The images or other resources that are not my autorship have their licenses specified. In case there is something it hasn't,
don't hesitate to report it to me.</p>
Trix, este es un editor de texto enriquecido sencillo y fácil de integrar en Phoenix. ¡Solo nos falta aprender a integrar la subida de archivos!Integrar Trix Editor con Phoenix2022-11-01T09:51:00Zhttps://blog.ivanhercaz.com/phoenix_trix_rich_text_editor<p>Llevaba tiempo dándole vueltas a esta idea: integrar un editor de texto enriquecido
en un sitio web que utilice <a href="https://www.phoenixframework.org/" rel="nofollow">Phoenix</a>. Sin embargo,
hasta hace poco no me ha surgido la necesidad real y, casi siempre, si no siempre,
las necesidades reales son las que imperan sobre cualquier otra.</p>
<p>El caso es que hace poco me propusieron un proyecto muy sencillo: un sitio web para
un proyecto, en el que la usuaria pueda crear páginas a modo de memoria de proyecto.
Pensé en diferentes opciones basadas en Elixir, entre ellas probar <a href="https://github.com/BeaconCMS/beacon" rel="nofollow">BeaconCMS</a>,
que no terminó con buenos resultados. Luego le di vueltas a <a href="https://github.com/alfredbaudisch/pardall_markdown" rel="nofollow">PardallMarkdown</a>,
sugerido por un amigo, y si bien está bastante interesante y guay ese proyecto,
no puedo proponerle a mi clienta que que trabaje con archivos Markdown y se adapte
a ese <em>modus operandi</em>. Por lo que al final desistí y decidí crear un sitio web
con Phoenix, con el que simplemente gestionar la creación de páginas y la navegación
por medio de formularios sencillos.</p>
<p>Una vez tengo la estructura del formulario me encuentro con el problema: integrar
un editor de texto enriquecido.</p>
<h2 id="editores-de-texto-enriquecido"><a class="anchor" href="#editores-de-texto-enriquecido" rel="nofollow">#</a> Editores de texto enriquecido</h2>
<p>Generalmente no me gustan. Suelo preferir a utilizar algún lenguaje de marcado,
como por ejemplo Markdown, Wikitexto cuando trabajo en alguna wiki MediaWiki o
incluso el propio HTML.</p>
<p>Entre los editores de texto enriquecido, el nombre que más resonaba en mis oídos era
<a href="https://ckeditor.com/" rel="nofollow">CKEditor</a>, pero me parecía matar moscas a cañonazos. Por
ello seguí buscando y me encontré con Trix.</p>
<h2 id="trix-editor"><a class="anchor" href="#trix-editor" rel="nofollow">#</a> Trix Editor</h2>
<p><a href="https://trix-editor.org/" rel="nofollow">Trix</a> es un editor de texto enriquecido programado en CoffeeScript por el equipo
de Basecamp, que suele programar en Ruby, lo que me animó aún más a echarle un
vistazo profundo.</p>
<p>Pero tampoco podía observarlo muy profundamente: Trix es lo que es, un editor sencillo
que ofrece suficientes características para crear un documento con un buen formato,
pero sin caer en tediosas configuraciones o incontables opciones que apenas se suelen
usar.</p>
<h2 id="integrar-trix-en-phoenix-con-un-hook"><a class="anchor" href="#integrar-trix-en-phoenix-con-un-hook" rel="nofollow">#</a> Integrar Trix en Phoenix con un hook</h2>
<p>Para integrar Trix en Phoenix debemos recurrir a la interoperabilidad de Phoenix
con JavaScript. Si no sabes de lo que estoy hablando, te recomiendo leer la sección
<a href="https://hexdocs.pm/phoenix_live_view/js-interop.html#content" rel="nofollow">JavaScript interoperability</a>
de la documentación de <a href="http://www.phoenixframework.org/" rel="nofollow">Phoenix LiveView</a>. ¡Vamos a empezar!</p>
<p>Lo primero que tenemos que hacer es añadir Trix como dependencia. Podemos descargarla
de <a href="https://github.com/basecamp/trix/releases" rel="nofollow">su repositorio</a> y añadirla a nuestra
carpeta <code>vendor</code> dentro de los <code>assets</code> de Phoenix. Pero yo iba con prisa y preferí
utilizar cdnjs para realizar la prueba de fuego, por lo que añadí las siguientes
en mi <code>app_web/templates/layout/root.html.heex</code>:</p>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"><span class="p"><</span><span class="nt">head</span><span class="p">></span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"> <span class="p"><</span><span class="nt">script</span> <span class="na">src</span><span class="o">=</span><span class="s">"https://cdnjs.cloudflare.com/ajax/libs/trix/1.3.1/trix.min.js"</span> <span class="na">integrity</span><span class="o">=</span><span class="s">"sha512-2RLMQRNr+D47nbLnsbEqtEmgKy67OSCpWJjJM394czt99xj3jJJJBQ43K7lJpfYAYtvekeyzqfZTx2mqoDh7vg=="</span> <span class="na">crossorigin</span><span class="o">=</span><span class="s">"anonymous"</span> <span class="na">referrerpolicy</span><span class="o">=</span><span class="s">"no-referrer"</span><span class="p">></</span><span class="nt">script</span><span class="p">></span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"> <span class="p"><</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">"stylesheet"</span> <span class="na">href</span><span class="o">=</span><span class="s">"https://cdnjs.cloudflare.com/ajax/libs/trix/1.3.1/trix.min.css"</span> <span class="na">integrity</span><span class="o">=</span><span class="s">"sha512-5m1IeUDKtuFGvfgz32VVD0Jd/ySGX7xdLxhqemTmThxHdgqlgPdupWoSN8ThtUSLpAGBvA8DY2oO7jJCrGdxoA=="</span> <span class="na">crossorigin</span><span class="o">=</span><span class="s">"anonymous"</span> <span class="na">referrerpolicy</span><span class="o">=</span><span class="s">"no-referrer"</span> <span class="p">/></span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p"></</span><span class="nt">head</span><span class="p">></span>
</span></span></code></pre><p>Luego, en el componente del formulario, <code>PageLive.FormComponent</code> en este caso,
introduje lo siguiente:</p>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"><span class="err"><</span>%= label f, :body %>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="err"><</span>%= hidden_input f, :body, rows: 9, phx_hook: "TrixHook" %>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p"><</span><span class="nt">div</span> <span class="na">id</span><span class="o">=</span><span class="s">"page-form_body-editor"</span> <span class="na">phx-update</span><span class="o">=</span><span class="s">"ignore"</span><span class="p">></span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"> <span class="p"><</span><span class="nt">trix-editor</span> <span class="na">input</span><span class="o">=</span><span class="s">"page-form_body"</span><span class="p">></</span><span class="nt">trix-editor</span><span class="p">></span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p"></</span><span class="nt">div</span><span class="p">></span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="err"><</span>%= error_tag f, :body %>
</span></span></code></pre><p>¿Qué es cada parte?</p>
<ul>
<li>La primera línea simplemente muestra la etiqueta para el campo <code>:body</code>.</li>
<li>En la segunda línea se utiliza <code>hidden_input/3</code> para crear área de texto invisible.
De esta parte lo más importante es el atributo <code>phx_hook="TrixHook"</code>.</li>
<li>En la tercera línea iniciamos un contenedor al que le asignamos un identificador
e indicamos con un <code>phx-update="ignore"</code> que LiveView ignore los cambios en ese
contenedor a la hora de actualizar DOM.</li>
<li>En la cuarta línea, dentro del contenedor creado, insertamos la etiquet propia
de Trix, <code><trix-editor></code>, a la que le añadimos el atributo <code>input=page-form_body</code>.
Este atributo indica que el contenido del editor debe ser el valor del campo <code>:body</code>,
previamente indicado con el <code>hidden_input</code>. ¿Por qué <code>page-form-body</code>? Porque es
el identificador final que genera Phoenix.</li>
</ul>
<p>Una vez tenemos esta estructura, ya podemos visualizar el editor en el formulario.</p>
<p><img src="/trix_editor.png" alt="Trix Editor en el formulario."></p>
<p>Ya solo queda darle forma a ese hook que hemos utilizado. En mi caso yo tengo la
siguiente estructura en mis <em>assets</em>:</p>
<pre><code>assets
|-- css
|-- js
|-- app.js
|-- hooks.js
|-- hooks
|-- trix_hook.js
</code></pre>
<p><code>trix_hook.js</code> es el hook que hemos creado para vincular los diferentes eventos
de Trix y Phoenix.</p>
<pre class="chroma"><code><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">const</span> <span class="nx">TrixHook</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"> <span class="nx">initListener</span><span class="p">(</span><span class="nx">trix</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"> <span class="nx">trix</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="nx">event</span><span class="p">,</span> <span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`event </span><span class="si">${</span><span class="nx">event</span><span class="si">}</span><span class="sb"> fired!`</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"> <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="nx">bindTrixEditor</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"> <span class="cm">/*
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="cm"> this.el = elemento en el que está el hook
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="cm"> el siguiente elemento sería el contenedor "page-form_body-editor"
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="cm"> el hijo "1" se refiere a la etiqueta <trix-editor>, el hijo "0" es la barra
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="cm"> de tareas del editor
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="cm"> */</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"> <span class="kd">let</span> <span class="nx">trix</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">el</span><span class="p">.</span><span class="nx">nextElementSibling</span><span class="p">.</span><span class="nx">children</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"> <span class="k">this</span><span class="p">.</span><span class="nx">initListener</span><span class="p">(</span><span class="nx">trix</span><span class="p">,</span> <span class="s2">"trix-change"</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"> <span class="p">},</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"> <span class="nx">mounted</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">"Hello, Trix!"</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"> <span class="k">this</span><span class="p">.</span><span class="nx">bindTrixEditor</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"> <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="kr">export</span> <span class="k">default</span> <span class="nx">TrixHook</span><span class="p">;</span>
</span></span></code></pre><p>En el objeto <code>TrixHook</code> creamos el método <code>initListener(trix, event)</code>, que simplemente
nos ayuda a añadir el <em>event listener</em> al editor. En el método <code>bindTrixEditor()</code>
vinculamos el editor por medio de <code>this.el.nextElementSibling.children[1]</code> (véanse
los comentarios en el código para entender el porqué del índice <code>1</code>. Yo me he decantado
por esta opción para trabajar directamente con el elemento en el que se utiliza
el hook, lo que conlleva respetar la estructura:</p>
<pre><code>hidden_input
div
|-- trix-editor
</code></pre>
<p>Pero también se podría utilizar <code>document.querySelector("trix-editor")</code> o el método
que se les ocurra. ¡Lo importante es llegar hasta <code><trix-editor></code>!.</p>
<p>Si quisiésemos ejecutar una función concreta según el evento que se haya iniciado,
se podría determinar en el <code>initListener(trix, event)</code> o crear un método que gestione
cada evento con su propia lógica.</p>
<p>Finalmente, en <code>TrixHook</code> añadimos la <em>callback</em> <code>mounted()</code>, en la que se hace
uso del método <code>bindTrix</code>. En <code>hooks.js</code> se importa el hook recien cocinado:</p>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">import</span> <span class="nx">TrixHook</span> <span class="nx">from</span> <span class="s2">"./hooks/trix_hook"</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">let</span> <span class="nx">Hooks</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"> <span class="nx">TrixHook</span><span class="o">:</span> <span class="nx">TrixHook</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="kr">export</span> <span class="k">default</span> <span class="nx">Hooks</span><span class="p">;</span>
</span></span></code></pre><p>Y en <code>app.js</code> se importan los hooks y se añaden al socket:</p>
<pre class="chroma"><code><span class="line"><span class="ln">1</span><span class="cl"><span class="p">...</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kr">import</span> <span class="nx">Hooks</span> <span class="nx">from</span> <span class="s2">"./hooks"</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">...</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="kd">let</span> <span class="nx">liveSocket</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">LiveSocket</span><span class="p">(</span><span class="s2">"/live"</span><span class="p">,</span> <span class="nx">Socket</span><span class="p">,</span> <span class="p">{</span><span class="nx">hooks</span><span class="o">:</span> <span class="nx">Hooks</span><span class="p">,</span> <span class="nx">params</span><span class="o">:</span> <span class="p">{</span><span class="nx">_csrf_token</span><span class="o">:</span> <span class="nx">csrfToken</span><span class="p">}})</span>
</span></span></code></pre><p>Si ahora vamos a nuestro formulario y añadimos un texto formateado, nuestra vista
mostrará el texto con las etiquetas HTML, pero no el formato. Para poder mostrar
el texto formateado tenemos que hacer unos pequeños cambios en la plantilla de la
LiveView, tanto en la dedicada al listado de páginas como a la de cada página, en
mi caso, <code>PageLive.Index</code> y <code>PageLive.Show</code> respectivamente. En <code>app_web/live/page_live/show.html.heex</code>
tengo lo siguiente (es un proyecto recién generado):</p>
<pre class="chroma"><code><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p"><</span><span class="nt">ul</span><span class="p">></span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"> <span class="p"><</span><span class="nt">li</span><span class="p">></span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"> <span class="p"><</span><span class="nt">strong</span><span class="p">></span>Title:<span class="p"></</span><span class="nt">strong</span><span class="p">></span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"> <span class="err"><</span>%= @page.title %>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"> <span class="p"></</span><span class="nt">li</span><span class="p">></span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"> <span class="p"><</span><span class="nt">li</span><span class="p">></span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"> <span class="p"><</span><span class="nt">strong</span><span class="p">></span>Body:<span class="p"></</span><span class="nt">strong</span><span class="p">></span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"> <span class="err"><</span>%= @page.body %>
</span></span><span class="line"><span class="ln">11</span><span class="cl"> <span class="p"></</span><span class="nt">li</span><span class="p">></span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"> <span class="p"><</span><span class="nt">li</span><span class="p">></span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"> <span class="p"><</span><span class="nt">strong</span><span class="p">></span>Author:<span class="p"></</span><span class="nt">strong</span><span class="p">></span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"> <span class="err"><</span>%= @page.author %>
</span></span><span class="line"><span class="ln">16</span><span class="cl"> <span class="p"></</span><span class="nt">li</span><span class="p">></span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl"> <span class="p"><</span><span class="nt">li</span><span class="p">></span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"> <span class="p"><</span><span class="nt">strong</span><span class="p">></span>Slug:<span class="p"></</span><span class="nt">strong</span><span class="p">></span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"> <span class="err"><</span>%= @page.slug %>
</span></span><span class="line"><span class="ln">21</span><span class="cl"> <span class="p"></</span><span class="nt">li</span><span class="p">></span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p"></</span><span class="nt">ul</span><span class="p">></span>
</span></span></code></pre><p>Aquí hay que cambiar <code><%= @page.body %></code> por <code><%= @page.body |> raw() |> html_escape() %></code>.
<code>raw/1</code> hace que la cadena no escape las etiquetas HTML y <code>html_escape/1</code> devuelve una cadena segura.</p>
<p>Cuando llegué a este punto me surgieron dudas sobre seguridad, sobre todo con si
este sería el método correcto a aplicar. En estos momentos no se me ocurre otra opción
que marcar la cadena del campo con <code>raw/1</code> y posteriormente utilizar <code>html_escape/1</code>.</p>
<h2 id="adjuntar-archivos"><a class="anchor" href="#adjuntar-archivos" rel="nofollow">#</a> Adjuntar archivos</h2>
<p>Gracias al hook que hemos montado podemos crear métodos dentro del propio objeto
para interactuar con cada uno de los botones. Si bien he podido comprobar que la
mayoría funcionan sin que tengamos que hacer nada por nuestra parte, si hay un botón
que si pulsamos funcionará a medias: el clip para adjuntar archivos.</p>
<p>Si hacemos clic en ese botón, saltará una ventana para que seleccionemos el archivo
que queremos adjuntar, pero se nos cerrará el editor y volveremos a la visualización
de la página.</p>
<p>Para hacer funcionar este botón debemos integrar el <em>event listener</em> <code>trix-attachment-add</code>.
Por suerte, los programadores de Trix han previsto que esta función es algo que
muchos usuarios probablemente quieran integrar en su editor, por lo que proporcionan
un ejemplo con el que podemos realizar esta integración. Véase <a href="https://github.com/basecamp/trix#storing-attached-files" rel="nofollow"><em>Storing Attached Files</em></a>
en su repositorio.</p>
<p>Ahora mismo no lo tengo integrado, pero pronto lo intentaré. Cuando haya conseguido
integrar esta función de Trix, escribiré otra entrada en la que explicaré el cómo
y los aspectos a tener en cuenta.</p>
<h2 id="eventos-de-trix"><a class="anchor" href="#eventos-de-trix" rel="nofollow">#</a> Eventos de Trix</h2>
<p>Por supuesto, <code>trix-change</code> y <code>trix-attachment-add</code> no son los únicos eventos que
emite Trix. Les recomiendo echarle un vistazo a los <a href="https://github.com/basecamp/trix#observing-editor-changes" rel="nofollow">eventos</a>
que mencionan en su repositorio.</p>
<hr>
<p>Cualquier comentario, duda o sugerencia me la pueden hacer llegar por Telegram, ya sea <a href="t.me/ivanhercaz" rel="nofollow">por privado</a>
o mencionarme (<code>@ivanhercaz</code>) en el grupo <a href="https://t.me/elixirES" rel="nofollow">elixirES</a>.</p>
<hr/>
<p>Formo parte de <a href="https://codeberg.org/molecula" rel="nofollow">Molécula</a>, un grupo de tres cowboys del espacio y alquimistas que programan mucho e intentan contribuir a la comunidad del software libre. Si te gusta lo que escribo o lo que contribuyo, puedes <a href="https://ko-fi.com/molecula" rel="nofollow">invitarnos a un café, té o maté</a>. ¡Estamos en proceso de liberar varios proyectos!</p>
<p>Todas las entradas de este blog están bajo la licencia <a href="https://creativecommons.org/licenses/by-sa/4.0/deed.es" rel="nofollow">CC BY-SA 4.0</a>. Las imágenes y otros recursos que no son de mi autoría tienen especificada su respectiva licencia. En caso de no tenerla, no dudes
en reportármelo.</p>
<p>We are a team of three space cowboys and alchemists who develop software and try to contribute to the free software community. If you like what I write or what I contribute, you can <a href="https://ko-fi.com/molecula" rel="nofollow">invite us for a coffee, tea or maté</a>. I still have have to finish developing several projects and, of course, release them!</p>
<p>All blog posts are licensed under <a href="https://creativecommons.org/licenses/by-sa/4.0/deed.en" rel="nofollow">CC BY-SA 4.0</a>.
The images or other resources that are not my autorship have their licenses specified. In case there is something it hasn't,
don't hesitate to report it to me.</p>
Trix es un editor de texto enriquecido programado en CoffeeScript. ¿Cómo se puede integrar en una instancia de Phoenix? ¡No es magia, son hooks!