Cómo funciona mis-notas.html — lección de programación para principiantes

Este documento acompaña al archivo mis-notas.html (un anotador con pestañas, hecho en HTML/CSS/JavaScript "vanilla", sin frameworks). Está pensado para alguien que no sabe programar, y va explicando, función por función, qué hace cada parte del código y por qué está escrita así.

No hace falta leerlo todo de una sentada: cada parte es bastante autocontenida, aunque se referencian conceptos de partes anteriores.

Índice

  1. Las bases: estado, localStorage e IDs — Qué es el objeto S (el "cerebro" de la app), cómo localStorage guarda datos como texto, JSON.stringify/JSON.parse, generación de IDs únicos con uid(), y el arranque de la app con init() y persist().

  2. saveNote(), loadNote() y básicos del DOM — Cómo se sincroniza el textarea con el objeto S en ambas direcciones, más una introducción a getElementById, createElement, appendChild, textContent y classList. Arranca la explicación de render(): cómo se construye cada pestaña.

  3. render(): clic vs. doble clic — El botón de cerrar pestaña (✕) y stopPropagation(). El truco con setTimeout/clearTimeout y closures para distinguir un clic simple (cambiar de pestaña) de un doble clic (renombrar). Cierre de render() con el botón "+" y la diferencia entre pasar una función como referencia vs. ejecutarla.

  4. addTab(), removeTab(), inlineRename() — Cómo se agregan y eliminan notas del array (push, splice), el ajuste del índice activo al borrar, y el renombrado "in place" reemplazando un <span> por un <input>, con manejo de Enter/Escape/blur.

  5. saveFile() y doImport() — Exportar las notas como .json usando Blob y URL.createObjectURL, simulando un clic en un link de descarga. Importar un archivo con FileReader, lectura asíncrona, validaciones y manejo de errores con try/catch.

  6. Modal de confirmación, toast() y tecla Escape — El patrón de mostrar/ocultar elementos agregando y quitando una clase CSS (resetAll, closeConfirm, confirmReset). Cómo funciona el mensaje flotante toast() y por qué guarda su temporizador en t._t. El listener global de la tecla Escape y la llamada final a init().

  7. handleClip() (función para el bookmarklet) + repaso general — Cómo se leen parámetros de la URL con URLSearchParams, búsqueda/creación de la pestaña "Recortes" con findIndex, armado del encabezado con fecha y fuente, y limpieza de la URL con history.replaceState. Cierra con un repaso del patrón general: estado → persist()render().


Parte 1: Las bases — el "estado" de la app, localStorage y los IDs

Antes de mirar función por función, conviene entender tres ideas que se repiten todo el tiempo en este código.

La variable S: el "cerebro" de la app

Al principio del script hay esto:

var S = {notes:[], active:0};

S es un objeto (una especie de caja con etiquetas) que guarda todo el estado de la aplicación en un solo lugar: la lista de notas (notes, un array) y cuál es la pestaña activa (active, un número que indica la posición en ese array). Cualquier cambio que el usuario hace —escribir, renombrar, agregar o borrar una pestaña— termina modificando este objeto. Después, otras funciones se encargan de "dibujar" la pantalla según lo que dice S, y de guardarlo para que no se pierda al cerrar el navegador.

Esta es una técnica muy común: separar los datos (qué hay) de la interfaz (cómo se ve). Vas a ver que casi todas las funciones siguen el patrón "modificar S → guardar S → redibujar la pantalla a partir de S".

localStorage: la memoria del navegador

localStorage es una especie de "cajón" que cada navegador le da a cada sitio web para guardar texto de forma permanente (sobrevive a cerrar la pestaña, reiniciar la PC, etc.). Solo guarda strings (texto), nada más. Por eso, para guardar un objeto como S, primero hay que convertirlo a texto.

Ahí entran dos funciones de JavaScript que vas a ver todo el tiempo:

Es como guardar una receta: el objeto es la comida lista, el JSON es la receta escrita en una hoja de papel, y localStorage es el cajón donde guardás esa hoja.

persist(): guardar el estado

function persist(){
  localStorage.setItem('notas_sb', JSON.stringify(S));
}

Esta función toma todo el objeto S (con todas tus notas), lo convierte a texto con JSON.stringify, y lo guarda en el cajón localStorage bajo la llave 'notas_sb'. Se llama cada vez que algo cambia (escribís texto, renombrás una pestaña, etc.), para que esos cambios no se pierdan.

uid(): generar identificadores únicos

function uid(){
  return Math.random().toString(36).slice(2,9);
}

Cada nota necesita un "documento de identidad" (id) único, para poder diferenciarla de las demás aunque dos notas tengan el mismo nombre. Esta función:

  1. Math.random() genera un número decimal al azar entre 0 y 1 (ej: 0.4839201...).
  2. .toString(36) lo convierte a base 36, que usa números y letras (0-9 y a-z), dando algo como "0.gx3k9pq1". Es una forma de obtener strings cortos pero con muchísimas combinaciones posibles.
  3. .slice(2,9) recorta ese string, descartando el "0." del principio y quedándose con 7 caracteres, ej: "gx3k9pq".

El resultado es un código corto, alfanumérico y prácticamente irrepetible, que sirve como id de cada nota.

init(): el arranque de la app

function init(){
  var raw = localStorage.getItem('notas_sb');
  if(raw){try{Object.assign(S,JSON.parse(raw));}catch(e){}}
  if(!S.notes||!S.notes.length){S.notes=[{id:uid(),name:'Nota 1',content:''}];S.active=0;}
  var clipAdded = handleClip();
  render();
  if(clipAdded) toast('Recorte agregado a "Recortes"');
}

Esta es la primera función que se ejecuta cuando abrís la página (al final del script hay un init(); suelto que la dispara). Paso a paso:

  1. localStorage.getItem('notas_sb') busca si ya guardaste algo antes bajo esa llave. Si nunca usaste la app, devuelve null.
  2. Si hay algo guardado (raw no es null), lo convierte de texto a objeto con JSON.parse y lo copia dentro de S usando Object.assign. El try{...}catch(e){} es una "red de seguridad": si el texto guardado estuviera corrupto y JSON.parse fallara, en vez de romper toda la app, simplemente ignora el error y sigue.
  3. Si después de todo eso S.notes sigue vacío (primera vez que se usa, o el dato guardado estaba mal), crea una nota inicial llamada "Nota 1".
  4. Llama a handleClip() (la función para recibir recortes desde el bookmarklet — ver Parte 7).
  5. Llama a render(), que dibuja toda la interfaz según el estado actual de S.

Por ahora alcanza con saber que render() "dibuja la pantalla"; en la Parte 2 vemos en detalle cómo lo hace, junto con saveNote() y loadNote(), que son las funciones que conectan el cuadro de texto con S.


Parte 2: saveNote(), loadNote() y los básicos del DOM

Ahora vamos al ciclo que conecta lo que escribís en pantalla con el objeto S.

saveNote(): de la pantalla al estado

function saveNote(){
  var n = S.notes[S.active];
  if(n) n.content = document.getElementById('editor').value;
  persist();
}

document.getElementById('editor') busca en el HTML el elemento que tiene id="editor" (el <textarea>) y devuelve una referencia a él. La propiedad .value de un textarea es, justamente, el texto que el usuario escribió ahí.

S.notes[S.active] es "la nota número S.active dentro del array notes" — recordá que S.active es simplemente un número (0, 1, 2...) que indica cuál pestaña está abierta. El if(n) es otra red de seguridad: si por algún motivo no hubiera ninguna nota en esa posición (n sería undefined), evita que el programa intente hacer undefined.content = ... y se rompa.

En resumen: esta función toma lo que hay ahora en el textarea y lo copia a la nota activa dentro de S, y después llama a persist() para guardarlo en localStorage. Se ejecuta cada vez que tipeás algo (mirá el oninput="saveNote()" en el HTML del textarea).

loadNote(): del estado a la pantalla

function loadNote(){
  var n = S.notes[S.active];
  document.getElementById('editor').value = n ? n.content : '';
}

Es el camino inverso: toma el contenido guardado en la nota activa y lo pone en el textarea para que se vea. Usa el operador ternario condición ? valorSiVerdadero : valorSiFalso — es un if/else resumido en una sola línea. Si n existe, usa n.content; si no (por ejemplo, si S.active apuntara a una posición que no existe), usa un string vacío '' para no mostrar undefined en pantalla.

Entre estas dos funciones, saveNote() y loadNote() son como las dos puertas de un mismo pasillo: una lleva de la pantalla al dato guardado, la otra del dato guardado a la pantalla.

Conceptos de DOM que necesitás para lo que viene

El "DOM" (Document Object Model) es la representación que el navegador hace de tu HTML como una estructura de objetos que podés manipular con JavaScript. Las herramientas básicas son:

render(): empezando a construir las pestañas

function render(){
  var bar = document.getElementById('tab-bar');
  bar.innerHTML = '';
  S.notes.forEach(function(note, i){
    var tab = document.createElement('div');
    tab.className = 'tab' + (i===S.active?' on':'');
    tab.title = 'Clic: cambiar · Doble clic: renombrar';

    var ns = document.createElement('span');
    ns.className = 'tab-name';
    ns.textContent = note.name;
    tab.appendChild(ns);
    // ... (sigue en la Parte 3)

Primero, bar.innerHTML = '' vacía completamente la barra de pestañas — es una forma simple (aunque no la más eficiente) de "borrar todo y volver a dibujar desde cero" cada vez que algo cambia.

Después, S.notes.forEach(function(note, i){...}) recorre el array de notas una por una. forEach es un método de los arrays que ejecuta una función para cada elemento; note es la nota actual y i es su posición (0, 1, 2...) dentro del array — ese número i es clave porque se compara contra S.active para saber cuál pestaña está "activa".

Por cada nota, se crea un <div> (tab) que va a representar la pestaña. La línea tab.className = 'tab' + (i===S.active?' on':'') le pone la clase "tab" siempre, y le agrega " on" solo si esa nota es la activa — eso es lo que en el CSS hace que la pestaña activa se vea resaltada (fondo distinto, subrayado de color).

Luego se crea un <span> con la clase "tab-name" y se le pone como texto el nombre de la nota (note.name), y se lo agrega como hijo de tab con appendChild.

En la Parte 3 seguimos con lo más entretenido de render(): el botón de cerrar pestaña, y el truco para distinguir un clic simple (cambiar de pestaña) de un doble clic (renombrarla).


Parte 3: render() — el clic vs. doble clic, y el botón "+"

Seguimos dentro de render(), justo donde quedamos: ya tenemos el <div> de la pestaña (tab) con su nombre adentro. Ahora viene la parte más "ingeniosa" de toda la app.

El botón ✕ para cerrar pestañas

if(S.notes.length > 1){
  var cx = document.createElement('span');
  cx.className = 'tab-close';
  cx.innerHTML = '✕';
  cx.onclick = function(e){e.stopPropagation(); removeTab(i);};
  tab.appendChild(cx);
}

El if(S.notes.length > 1) es una regla de negocio simple: solo se crea el botón de cerrar si hay más de una nota. Así, la app garantiza que siempre quede al menos una pestaña — evita el caso incómodo de "borré todo y ahora no tengo ni una nota ni botón para crear una nueva".

cx.onclick = function(e){...} asigna una función que se ejecuta cuando se hace clic en el ✕. Esa función recibe automáticamente un parámetro e (el "evento"), que trae información sobre el clic.

e.stopPropagation() es clave acá. En el HTML, el ✕ está dentro del <div> de la pestaña. Si hacés clic en el ✕, en realidad estás haciendo clic también en la pestaña que lo contiene — los eventos "burbujean" hacia arriba por defecto. stopPropagation() le dice al navegador "este clic es mío, no lo dejes seguir subiendo". Sin esa línea, al cerrar una pestaña también se dispararía el evento de "cambiar a esta pestaña", con resultados raros.

Por último, removeTab(i) — donde i es la posición de esa nota en el array — hace el borrado real (ver Parte 4).

El truco del clic simple vs. doble clic

var timer = null;
tab.addEventListener('click', function(e){
  if(e.target.classList.contains('tab-close')) return;
  if(timer){clearTimeout(timer);timer=null;inlineRename(ns,note);return;}
  timer = setTimeout(function(){timer=null; saveNote(); S.active=i; render(); loadNote(); persist();}, 200);
});

JavaScript no tiene un evento nativo de "doble clic con un solo clic mientras tanto no hace nada" — el evento dblclick existe, pero antes de que se dispare, ya se disparó dos veces el click. Esta app resuelve el problema con un patrón clásico: esperar un poquito antes de actuar, por si llega un segundo clic.

Paso a paso:

  1. var timer = null — se declara fuera del evento, una sola vez por pestaña. Gracias a un concepto llamado closure (cierre), la función que se ejecuta en cada clic "recuerda" esta variable timer entre clic y clic, aunque en teoría cada llamada a la función debería ser independiente. Es como si timer fuera una pizarra compartida que solo esta pestaña puede ver y escribir.

  2. if(e.target.classList.contains('tab-close')) return; — si el clic vino del botón ✕ (que ya tiene su propio manejo), no hacer nada más acá. e.target es el elemento exacto donde ocurrió el clic, y classList.contains(...) chequea si tiene esa clase CSS.

  3. Primer clic: timer es null, así que no entra al if(timer). En cambio, hace timer = setTimeout(función, 200). setTimeout dice "ejecutá esta función dentro de 200 milisegundos, pero no ahora". Mientras tanto, el programa sigue funcionando normalmente — no se "congela" esperando.

  4. Si no llega un segundo clic: pasan los 200ms, se ejecuta la función pendiente: guarda la nota actual (saveNote()), cambia S.active a la pestaña clickeada, vuelve a dibujar todo (render()), carga el contenido de la nueva pestaña activa (loadNote()) y guarda el estado (persist()). Esto es el comportamiento de "clic simple = cambiar de pestaña".

  5. Si llega un segundo clic dentro de esos 200ms: ahora timer ya no es null (tiene el identificador del temporizador pendiente). Entra al if(timer), hace clearTimeout(timer) — esto cancela la acción que estaba programada para dentro de poco, evitando que "cambiar de pestaña" se ejecute — y en su lugar llama a inlineRename(ns, note), que activa el modo de renombrado (Parte 4).

En criollo: cada clic dice "esperá un cachito, por si viene otro clic enseguida". Si no viene nada más, se interpreta como clic simple. Si viene otro clic rápido, se cancela esa espera y se interpreta como doble clic.

Cerrando render(): el botón "+" y la carga final

    bar.appendChild(tab);
  });

  var add = document.createElement('div');
  add.className = 'tab-add';
  add.textContent = '+';
  add.title = 'Nueva pestaña';
  add.onclick = addTab;
  bar.appendChild(add);

  loadNote();
}

Una vez armada cada pestaña (tab), se agrega a la barra con bar.appendChild(tab) — recién ahí se vuelve visible en pantalla.

Después del forEach, se crea el botón "+": un <div> con texto "+". Fijate que add.onclick = addTabsin paréntesis. Esto es importante: addTab (sin ()) es una referencia a la función, como decir "cuando hagas clic, ejecutá esta función". Si hubiera puesto addTab(), eso ejecutaría la función inmediatamente al armar la página, en lugar de guardarla para más adelante. Es un error muy común al empezar con JavaScript.

Por último, loadNote() — la función de la Parte 2 — vuelca en el textarea el contenido de la nota que quedó activa. Esto cierra el ciclo: render() siempre termina asegurándose de que el textarea muestre lo correcto.


Parte 4: addTab(), removeTab() e inlineRename()

Estas tres funciones son las que realmente modifican el array S.notes — agregar, quitar y renombrar pestañas.

addTab(): crear una pestaña nueva

function addTab(){
  saveNote();
  S.notes.push({id:uid(), name:'Nota '+(S.notes.length+1), content:''});
  S.active = S.notes.length-1;
  render();
  persist();
  document.getElementById('editor').focus();
}

Lo primero que hace es saveNote(). Esto es importante: si no se hiciera, y estabas escribiendo algo en la pestaña actual sin que se haya guardado el último cambio, al crear una pestaña nueva (y por lo tanto redibujar todo) podrías perder esos últimos caracteres. Es una regla general en esta app: antes de cambiar de contexto (cambiar de pestaña, agregar una, borrar una), primero guardá lo que hay.

S.notes.push({...}) agrega un objeto nuevo al final del array notes. push es el método estándar de JS para "agregar un elemento al final de un array". El objeto nuevo tiene:

S.active = S.notes.length-1 — después del push, S.notes.length ya incluye a la nota nueva. Como los arrays empiezan en el índice 0, la posición del último elemento es length - 1. Esto hace que la pestaña recién creada quede activa automáticamente.

Después, render() redibuja todo (aparece la pestaña nueva), persist() guarda el estado, y document.getElementById('editor').focus() pone el cursor directamente en el textarea, para que puedas empezar a escribir sin tener que hacer clic.

removeTab(i): borrar una pestaña

function removeTab(i){
  saveNote();
  S.notes.splice(i,1);
  if(S.active >= S.notes.length) S.active = S.notes.length-1;
  render();
  persist();
}

De nuevo, saveNote() primero por la misma razón de antes.

S.notes.splice(i, 1) es el método para "eliminar elementos de un array en una posición dada". El primer argumento (i) es desde dónde empezar, y el segundo (1) es cuántos elementos eliminar. Acá borra exactamente la nota en la posición i (la que tenía el ✕ que clickeaste), y el resto de los elementos se "corren" para llenar el hueco automáticamente.

La línea if(S.active >= S.notes.length) S.active = S.notes.length-1 es un ajuste necesario. Pensalo así: si tenías 3 pestañas (posiciones 0, 1, 2) y S.active era 2 (la última), y borrás justamente esa pestaña, ahora el array solo tiene posiciones 0 y 1 — la posición 2 ya no existe. Esta línea detecta esa situación (S.active quedó "fuera de rango") y lo corrige apuntando a la nueva última pestaña.

inlineRename(el, note): renombrar sin ventanas emergentes

function inlineRename(el, note){
  var old = note.name;
  var inp = document.createElement('input');
  inp.className = 'iei';
  inp.value = old;
  el.replaceWith(inp);
  inp.focus(); inp.select();
  var done = false;
  function finish(){
    if(done) return; done=true;
    note.name = inp.value.trim() || old;
    persist(); render();
  }
  inp.addEventListener('blur', finish);
  inp.addEventListener('keydown', function(e){
    if(e.key==='Enter') inp.blur();
    if(e.key==='Escape'){note.name=old; done=true; render();}
  });
}

Esta función recibe dos cosas: el (el <span> con el nombre visible de la pestaña, el que vimos en la Parte 2) y note (el objeto de la nota correspondiente dentro de S.notes).

Primero guarda el nombre actual en old — un "respaldo" por si el usuario cancela.

Luego crea un <input> de texto (document.createElement('input')), le pone como valor inicial el nombre actual (inp.value = old), y con el.replaceWith(inp) reemplaza el <span> por este <input> en el DOM — de ahí el nombre "inline" (en el lugar), no aparece ningún cuadro de diálogo aparte, el campo de texto literalmente toma el lugar del nombre.

inp.focus() pone el cursor ahí, y inp.select() selecciona todo el texto — así, si el usuario empieza a escribir directamente, reemplaza el nombre anterior en vez de tener que borrarlo a mano primero.

Ahora la parte más sutil: var done = false y la función interna finish(). ¿Por qué hace falta esto? Porque hay dos formas distintas de terminar el renombrado — perder el foco (blur, por ejemplo al hacer clic en otro lado) o presionar Enter (que a su vez provoca un blur). Sin la bandera done, podría ejecutarse finish() dos veces para la misma acción, lo cual no rompería nada grave acá, pero es una buena práctica: done asegura que el renombrado se "cierre" una sola vez.

Dentro de finish(): note.name = inp.value.trim() || old. .trim() quita espacios en blanco al principio y al final (para que no queden pestañas tituladas " "). El operador || significa "si lo de la izquierda es un valor 'falsy' (string vacío, en este caso), usá lo de la derecha". Entonces: si el usuario borró todo y dejó el campo vacío, se vuelve al nombre anterior (old) en lugar de quedar con un nombre vacío.

Los dos addEventListener:

Con esto ya cubrimos todo el ciclo de vida de una pestaña: crear, cambiar, renombrar y borrar.


Parte 5: saveFile() y doImport() — exportar e importar tus notas

Estas dos funciones le permiten a la app comunicarse con el sistema de archivos: descargar tus notas como un archivo .json, y volver a cargarlas desde uno.

saveFile(): descargar las notas

function saveFile(){
  var blob = new Blob([JSON.stringify(S,null,2)], {type:'application/json'});
  var url = URL.createObjectURL(blob);
  var a = document.createElement('a'); a.href=url; a.download='notas.json'; a.click();
  URL.revokeObjectURL(url);
  toast('Guardado como notas.json');
}

Primero, JSON.stringify(S, null, 2) — ya vimos JSON.stringify en la Parte 1, pero acá aparece con dos argumentos extra. El null es un parámetro que casi nunca se usa (sirve para filtrar qué propiedades incluir; null significa "todas"). El 2 le dice "indentá el resultado con 2 espacios por nivel" — así el JSON queda legible para humanos, con saltos de línea y sangría, en vez de todo en una sola línea apretada. Si abrís el notas.json resultante en un editor de texto, vas a ver algo prolijo y fácil de leer.

new Blob([texto], {type:'application/json'}) — un Blob ("Binary Large Object") es la forma que tiene JavaScript de representar datos como si fueran un archivo, pero todo en memoria, sin que exista todavía en el disco. Acá se crea un Blob que contiene el texto JSON, y se le indica que su tipo es application/json (una "etiqueta" que ayuda al navegador a saber qué tipo de contenido es).

URL.createObjectURL(blob) toma ese Blob en memoria y genera una URL temporal y especial (algo como blob:https://...) que apunta a él. Es como ponerle una "dirección" a algo que vive solo en la RAM de tu navegador, para poder referenciarlo como si fuera un archivo descargable.

Las siguientes tres líneas son un patrón muy común para forzar una descarga sin que el usuario tenga que hacer clic en un link visible:

URL.revokeObjectURL(url) libera la URL temporal de la memoria — una vez que ya se usó para la descarga, no hace falta mantenerla viva, y dejarla ocuparía memoria innecesariamente.

Por último, toast('Guardado como notas.json') muestra el mensajito flotante de confirmación (ver Parte 6).

doImport(e): leer un archivo .json

function doImport(e){
  var f = e.target.files[0]; if(!f) return;
  var r = new FileReader();
  r.onload = function(ev){
    try{
      Object.assign(S, JSON.parse(ev.target.result));
      if(!S.notes||!S.notes.length){S.notes=[{id:uid(),name:'Nota 1',content:''}];S.active=0;}
      if(S.active >= S.notes.length) S.active=0;
      persist(); render();
      toast('Importado ✓');
    }catch(err){toast('Error al importar JSON');}
  };
  r.readAsText(f); e.target.value='';
}

Esta función está conectada al <input type="file"> oculto del HTML (onchange="doImport(event)"). Recibe e, el evento de "cambio", que entre otras cosas trae e.target.files — una lista de los archivos que el usuario seleccionó.

var f = e.target.files[0]; if(!f) return; toma el primer (y único, en este caso) archivo seleccionado. Si el usuario abrió el selector de archivos y lo cerró sin elegir nada, files[0] sería undefined, y el if(!f) return corta la función ahí mismo para no seguir con algo inexistente.

new FileReader() crea un objeto especial cuyo trabajo es leer el contenido de archivos del disco del usuario (algo que JavaScript no puede hacer libremente por razones de seguridad — solo puede leer archivos que el propio usuario eligió explícitamente a través de un <input>).

La lectura de un archivo es asíncrona: no termina al instante, así que en lugar de "esperar" el resultado, se define r.onload = function(ev){...} — una función que el FileReader va a ejecutar automáticamente cuando termine de leer el archivo. Dentro de esa función, ev.target.result contiene el contenido del archivo como texto.

Dentro del try:

El catch(err){toast('Error al importar JSON')} es el plan B: si el archivo elegido no era un JSON válido (por ejemplo, si el usuario por error seleccionó otro tipo de archivo), JSON.parse lanzaría un error — y en vez de que la app se rompa silenciosamente o tire un error críptico en la consola, se atrapa ese error y se le muestra al usuario un mensaje claro.

Finalmente, r.readAsText(f) es lo que efectivamente inicia la lectura del archivo (todo lo de arriba solo la "preparó"; recién acá empieza el proceso, y cuando termine disparará el onload). Y e.target.value='' limpia el valor del input de archivo — esto es un detalle técnico importante: si no se hace, y el usuario quisiera importar el mismo archivo dos veces seguidas, el navegador no dispararía el evento onchange la segunda vez (porque "no cambió nada" desde su perspectiva). Resetear el valor garantiza que siempre se pueda volver a importar.


Parte 6: el modal de confirmación, toast() y el listener de Escape

Las funciones más chicas, pero que muestran patrones muy útiles para cualquier interfaz.

El modal de confirmación: resetAll(), closeConfirm(), confirmReset()

function resetAll(){
  document.getElementById('confirm-modal').classList.add('open');
}
function closeConfirm(){
  document.getElementById('confirm-modal').classList.remove('open');
}
function confirmReset(){
  closeConfirm();
  localStorage.removeItem('notas_sb');
  S = {notes:[{id:uid(),name:'Nota 1',content:''}], active:0};
  persist(); render();
  toast('Notas eliminadas');
}

Si mirás el HTML, hay un <div id="confirm-modal"> que por CSS tiene opacity:0; pointer-events:none por defecto — es decir, existe en la página pero es invisible y no se puede interactuar con él. Cuando tiene la clase .open, el CSS cambia esos valores a opacity:1; pointer-events:all, y además hay una transition que anima ese cambio suavemente.

Entonces estas tres funciones son simplemente "interruptores de esa clase":

Este patrón de "modal con clase open controlada por dos o tres funciones cortas" es extremadamente común — lo vas a encontrar en prácticamente cualquier interfaz con confirmaciones, menús desplegables, ventanas emergentes, etc.

toast(msg): el mensaje flotante

function toast(msg){
  var t = document.getElementById('toast');
  t.textContent = msg; t.classList.add('show');
  clearTimeout(t._t);
  t._t = setTimeout(function(){t.classList.remove('show');}, 2800);
}

toast es el nombrecito que se le suele dar a esos mensajitos chiquitos que aparecen un momento y desaparecen solos (lo usás constantemente en apps de celular).

t.textContent = msg pone el texto recibido dentro del <div id="toast">. t.classList.add('show') le agrega la clase que lo hace visible (de nuevo, vía CSS con opacity y transition).

Ahora la parte interesante: clearTimeout(t._t) y t._t = setTimeout(...). Acá _t no es nada especial de JavaScript — es simplemente una propiedad que esta función inventa y guarda directamente sobre el elemento del DOM (t), para tener dónde "anotar" cuál fue el último temporizador programado.

¿Por qué hace falta esto? Imaginá que el usuario hace varias acciones rápido (por ejemplo, guarda el archivo dos veces seguidas). Cada llamada a toast() programa un setTimeout para ocultar el mensaje en 2800ms. Si no se cancelara el anterior, podrías terminar con el toast mostrando "Guardado" pero ocultándose justo cuando aparece "Importado ✓", porque el primer temporizador (de la primera llamada) seguiría corriendo. Al guardar el ID del temporizador en t._t y cancelarlo (clearTimeout) antes de programar uno nuevo, cada llamada a toast() "resetea el reloj" — el mensaje siempre se queda visible 2800ms desde la última vez que se llamó a toast(), sin importar cuántas veces se llamó antes.

El final del script: init() y la tecla Escape

init();

document.addEventListener('keydown', function(e){
  if(e.key==='Escape') closeConfirm();
});

init() — la primera función que vimos en la Parte 1 — se llama acá, al final del archivo. Como el <script> está al final del <body>, para cuando el navegador llega a esta línea ya existe todo el HTML (los <div>, el <textarea>, etc.), así que es seguro que init() pueda buscarlos con getElementById sin problema.

document.addEventListener('keydown', function(e){...}) — a diferencia de los onclick que vimos antes (que se ponen sobre un elemento específico), este listener está en document, es decir, escucha cualquier tecla presionada en cualquier parte de la página. e.key === 'Escape' chequea si la tecla presionada fue específicamente "Escape", y si es así, llama a closeConfirm().

Notá que closeConfirm() es segura de llamar aunque el modal no esté abierto: classList.remove('open') simplemente no hace nada si la clase no estaba presente. Así que presionar Escape en cualquier momento, modal abierto o no, nunca causa un error.


Parte 7: handleClip() — la función del bookmarklet, y un repaso general

Pequeño código para agragar a los marcadores

Seleccionando un texto, pulsando en bookmarklet se envía a mis-notas.html a lapestaña Recortes, si no existe la crea.
javascript:(function(){var sel=window.getSelection().toString().trim();if(!sel){alert('Seleccion%C3%A1 texto primero');return;}var url='https://orquidealucinada.net/mis-notas.html?clip='+encodeURIComponent(sel)+'&src='+encodeURIComponent(location.href);window.open(url,'misNotas');})();

Esta es la función agregada para que funcione el bookmarklet de "enviar texto seleccionado al anotador". Es un buen cierre porque combina ideas de casi todas las partes anteriores, más un par de herramientas nuevas (URLSearchParams y history).

function handleClip(){
  var params = new URLSearchParams(location.search);
  var clip = params.get('clip');
  if(!clip) return false;
  var src = params.get('src') || '';

  var idx = S.notes.findIndex(function(n){return n.name === 'Recortes';});
  if(idx === -1){
    S.notes.push({id:uid(), name:'Recortes', content:''});
    idx = S.notes.length - 1;
  }

  var ts = new Date().toLocaleString('es-AR');
  var prev = S.notes[idx].content || '';
  var header = (prev ? '\n' : '') + '--- ' + ts + ' ---\n';
  if(src) header += 'Fuente: ' + src + '\n';
  S.notes[idx].content = prev + header + clip + '\n';
  S.active = idx;
  persist();

  history.replaceState(null, '', location.pathname);
  return true;
}

Leer parámetros de la URL

Cuando el bookmarklet abre la página con una URL como mis-notas.html?clip=Hola%20mundo&src=https..., todo lo que está después del ? se llama "query string" — pares clave=valor separados por &. location.search te da exactamente esa parte (incluyendo el ?).

URLSearchParams es un objeto que sabe "desarmar" ese texto en pares clave/valor utilizables, y además decodifica automáticamente los caracteres especiales (por eso el bookmarklet usa encodeURIComponent para "empaquetar" el texto seleccionado en la URL, y acá no hace falta hacer nada manual para "desempaquetarlo": params.get('clip') ya devuelve el texto original tal cual).

if(!clip) return false — si la página se abrió sin ese parámetro (es decir, el usuario simplemente abrió mis-notas.html normalmente, sin pasar por el bookmarklet), clip sería null, y la función termina ahí mismo devolviendo false. Esto es lo que init() usa para decidir si mostrar el toast de "Recorte agregado" o no.

Buscar (o crear) la pestaña "Recortes"

var idx = S.notes.findIndex(function(n){return n.name === 'Recortes';});
if(idx === -1){
  S.notes.push({id:uid(), name:'Recortes', content:''});
  idx = S.notes.length - 1;
}

findIndex es parecido al forEach que vimos en render(), pero en lugar de "hacer algo con cada elemento", busca y devuelve la posición del primer elemento para el cual la función pasada devuelve true. Acá la condición es n.name === 'Recortes' — "¿esta nota se llama 'Recortes'?". Si ninguna nota cumple esa condición, findIndex devuelve -1 (un valor especial que significa "no encontrado").

Si idx === -1, se crea la pestaña "Recortes" igual que hace addTab() (Parte 4) — usando push y uid() — y se actualiza idx para que apunte a esa posición recién creada (S.notes.length - 1, el último elemento).

Armar el contenido nuevo

var ts = new Date().toLocaleString('es-AR');
var prev = S.notes[idx].content || '';
var header = (prev ? '\n' : '') + '--- ' + ts + ' ---\n';
if(src) header += 'Fuente: ' + src + '\n';
S.notes[idx].content = prev + header + clip + '\n';
S.active = idx;
persist();

new Date() crea un objeto que representa el momento actual; .toLocaleString('es-AR') lo convierte a un texto legible con formato argentino (14/6/2026, 15:32:10).

prev guarda el contenido que ya tenía la nota "Recortes" antes de este recorte (|| '' por si la nota está vacía y content fuera algo raro).

La línea del header deja una sola línea en blanco de separación entre recortes: (prev ? '\n' : '') es otro operador ternario — "si ya había contenido antes, agregá un salto de línea extra al principio; si la nota estaba vacía, no agregues nada". Después se concatena '--- ' + ts + ' ---\n'.

Si src existe (vino la URL de origen), se le agrega una línea más al header con 'Fuente: ' + src + '\n'.

Finalmente, S.notes[idx].content = prev + header + clip + '\n' reconstruye el contenido completo de la nota: lo que ya había (prev) + el encabezado nuevo (header) + el texto recortado (clip) + un salto de línea final.

S.active = idx cambia la pestaña activa a "Recortes", para que al abrirse la página el usuario vea directamente dónde cayó su recorte. persist() guarda todo.

Limpiar la URL

history.replaceState(null, '', location.pathname);

history es el objeto que representa el historial de navegación de la pestaña. replaceState permite cambiar la URL que se muestra en la barra de direcciones sin recargar la página ni agregar una entrada nueva al historial (a diferencia de navegar a otra URL).

location.pathname es la URL sin el ?clip=...&src=... — solo https://orquidealucinada.net/mis-notas.html. Al ejecutar esta línea, la barra de direcciones "vuelve a la normalidad", como si nunca hubiera tenido esos parámetros. Esto es importante porque si el usuario, más tarde, recarga la página (F5) con la URL todavía conteniendo ?clip=..., handleClip() se ejecutaría de nuevo y duplicaría el recorte. Limpiar la URL apenas se procesa evita ese problema.

return true le avisa a init() que efectivamente se procesó un recorte, para que muestre el toast correspondiente.


Repaso general

Si juntás todo lo visto en estas 7 partes, vas a notar que toda la app gira alrededor de un mismo ciclo repetido una y otra vez:

  1. Algo pasa (el usuario escribe, hace clic, importa un archivo, llega un recorte por URL).
  2. Se modifica el objeto S (los datos).
  3. Se llama a persist() (se guarda en localStorage).
  4. Se llama a render() y/o loadNote() (se actualiza lo que se ve en pantalla).

Y las herramientas que se repiten para lograr esto son: manipulación del DOM (getElementById, createElement, appendChild, classList), almacenamiento (localStorage, JSON), eventos (onclick, addEventListener, oninput), closures (la variable timer, la propiedad t._t), y APIs del navegador para archivos y URLs (Blob, FileReader, URLSearchParams, history).

Si entendiste estas 7 partes, ya tenés una base sólida para leer y modificar prácticamente cualquier script en JavaScript "vanilla" (sin frameworks) — son exactamente los mismos patrones que se repiten en otras apps similares (notepads, kanbans, gestores de archivos, etc.). ¡Buena suerte con lo que sigue!