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
Las bases: estado,
localStoragee IDs — Qué es el objetoS(el "cerebro" de la app), cómolocalStorageguarda datos como texto,JSON.stringify/JSON.parse, generación de IDs únicos conuid(), y el arranque de la app coninit()ypersist().saveNote(),loadNote()y básicos del DOM — Cómo se sincroniza el textarea con el objetoSen ambas direcciones, más una introducción agetElementById,createElement,appendChild,textContentyclassList. Arranca la explicación derender(): cómo se construye cada pestaña.render(): clic vs. doble clic — El botón de cerrar pestaña (✕) ystopPropagation(). El truco consetTimeout/clearTimeouty closures para distinguir un clic simple (cambiar de pestaña) de un doble clic (renombrar). Cierre derender()con el botón "+" y la diferencia entre pasar una función como referencia vs. ejecutarla.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.saveFile()ydoImport()— Exportar las notas como.jsonusandoBlobyURL.createObjectURL, simulando un clic en un link de descarga. Importar un archivo conFileReader, lectura asíncrona, validaciones y manejo de errores contry/catch.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 flotantetoast()y por qué guarda su temporizador ent._t. El listener global de la tecla Escape y la llamada final ainit().handleClip()(función para el bookmarklet) + repaso general — Cómo se leen parámetros de la URL conURLSearchParams, búsqueda/creación de la pestaña "Recortes" confindIndex, armado del encabezado con fecha y fuente, y limpieza de la URL conhistory.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:
JSON.stringify(objeto)→ convierte un objeto/array de JS en un string de texto (formato JSON).JSON.parse(texto)→ hace lo inverso: toma ese string y lo convierte de nuevo en un objeto de JS usable.
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:
Math.random()genera un número decimal al azar entre 0 y 1 (ej:0.4839201...)..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..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:
localStorage.getItem('notas_sb')busca si ya guardaste algo antes bajo esa llave. Si nunca usaste la app, devuelvenull.- Si hay algo guardado (
rawno esnull), lo convierte de texto a objeto conJSON.parsey lo copia dentro deSusandoObject.assign. Eltry{...}catch(e){}es una "red de seguridad": si el texto guardado estuviera corrupto yJSON.parsefallara, en vez de romper toda la app, simplemente ignora el error y sigue. - Si después de todo eso
S.notessigue vacío (primera vez que se usa, o el dato guardado estaba mal), crea una nota inicial llamada "Nota 1". - Llama a
handleClip()(la función para recibir recortes desde el bookmarklet — ver Parte 7). - Llama a
render(), que dibuja toda la interfaz según el estado actual deS.
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:
document.getElementById('algo')busca un elemento por su atributoidy te da una referencia a él, para poder leerlo o modificarlo.document.createElement('div')crea un elemento nuevo en memoria (todavía no se ve en la página).elemento.appendChild(otroElemento)insertaotroElementocomo hijo deelemento, dentro del HTML real — recién ahí se vuelve visible.elemento.textContent = 'hola'cambia el texto visible dentro de un elemento (de forma segura, sin interpretar HTML).elemento.className = 'tab on'asigna clases CSS a un elemento — esas clases son las que después el<style>usa para darle color, tamaño, etc.elemento.innerHTML = '✕'similar atextContent, pero interpreta el contenido como HTML (por eso se usa para el símbolo ✕, que es solo un carácter, sin riesgo).
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:
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 variabletimerentre clic y clic, aunque en teoría cada llamada a la función debería ser independiente. Es como sitimerfuera una pizarra compartida que solo esta pestaña puede ver y escribir.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.targetes el elemento exacto donde ocurrió el clic, yclassList.contains(...)chequea si tiene esa clase CSS.Primer clic:
timeresnull, así que no entra alif(timer). En cambio, hacetimer = setTimeout(función, 200).setTimeoutdice "ejecutá esta función dentro de 200 milisegundos, pero no ahora". Mientras tanto, el programa sigue funcionando normalmente — no se "congela" esperando.Si no llega un segundo clic: pasan los 200ms, se ejecuta la función pendiente: guarda la nota actual (
saveNote()), cambiaS.activea 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".Si llega un segundo clic dentro de esos 200ms: ahora
timerya no esnull(tiene el identificador del temporizador pendiente). Entra alif(timer), haceclearTimeout(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 ainlineRename(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 = addTab — sin 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:
id: uid()— un identificador único (Parte 1).name: 'Nota '+(S.notes.length+1)— un nombre tipo "Nota 4". ComoS.notes.lengthes la cantidad de notas antes de agregar la nueva, sumarle 1 da el número correcto para la nueva pestaña.content: ''— arranca vacía.
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:
'blur'(perder el foco) → llama afinish(), confirmando el cambio.'keydown'(tecla presionada) → si esEnter, haceinp.blur()(lo cual dispara el eventoblurde arriba, confirmando). Si esEscape, restauranote.name = olddirectamente, marcadone = true(para que elblurque viene después no vuelva a ejecutarfinish()y pise esta cancelación), y redibuja conrender().
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:
document.createElement('a')crea un elemento<a>(un link) en memoria, sin agregarlo a la página.a.href = urlle pone como destino la URL del Blob.a.download = 'notas.json'— este atributo le dice al navegador "no navegues a esta URL, descargala con este nombre de archivo".a.click()simula, por código, un clic en ese link — sin que el usuario lo vea ni lo toque. El navegador interpreta esto como "el usuario hizo clic en un link de descarga" y dispara el diálogo de guardado.
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:
JSON.parse(ev.target.result)convierte ese texto a un objeto JS (el camino inverso deJSON.stringify).Object.assign(S, ...)copia las propiedades de ese objeto importado encima deS— reemplazandonotesyactivepor los del archivo importado.- Las dos líneas siguientes son las mismas validaciones de seguridad que vimos en
init(): si el archivo importado no tenía notas válidas, crear una "Nota 1" por defecto; siS.activeapunta a una posición que no existe en el nuevo array, resetearlo a0. persist()yrender()guardan y redibujan con los datos nuevos.toast('Importado ✓')confirma el éxito.
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":
resetAll()(el botón "🗑 Borrar todo") agrega la claseopen→ el modal aparece con una animación de fade-in.closeConfirm()(el botón "Cancelar", o la tecla Escape) quita la claseopen→ el modal se desvanece.confirmReset()(el botón rojo "🗑 Borrar todo" dentro del modal) es el que realmente borra todo:closeConfirm()cierra el modal primero.localStorage.removeItem('notas_sb')borra la entrada guardada del "cajón" del navegador (la contraparte desetItemque vimos enpersist()).S = {notes:[{id:uid(),name:'Nota 1',content:''}], active:0}— fijate que acá no se modificaS, sino que se le asigna un objeto completamente nuevo. Esto funciona porqueSes una variable global (var S = {...}al principio del script): todas las funciones acceden a "la variableS, la que sea que esté apuntando ahora", así que reemplazarla por un objeto nuevo desde una función afecta a todas las demás por igual.persist()guarda este estado limpio,render()redibuja, ytoast(...)confirma la acción.
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:
- Algo pasa (el usuario escribe, hace clic, importa un archivo, llega un recorte por URL).
- Se modifica el objeto
S(los datos). - Se llama a
persist()(se guarda enlocalStorage). - Se llama a
render()y/oloadNote()(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!