Assistente indipendente per Pegaso, Mercatorum e San Raffaele
contenteditable, Selection, Range, liste e calloutNel Capitolo 9 abbiamo visto che il quiz HTML generato da PlumePilot non è semplicemente un documento: è una piccola applicazione offline.
Con la versione 2.35.0 quell’applicazione ha acquistato una caratteristica ancora più interessante: due campi di appunti per ogni domanda — Spiegazione e Osservazioni — non sono più semplici caselle di testo, ma piccoli editor rich-text.
L’utente può usare:
A prima vista potrebbe sembrare un problema di formattazione:
se l'utente preme B
↓
rendi il testo grassetto
In realtà un editor rich-text deve continuamente rispondere a domande molto più difficili:
Dov'è il cursore?
Che cosa è selezionato?
La selezione appartiene ancora all'editor?
Il click sulla toolbar ha distrutto la selezione?
Siamo dentro una lista?
Siamo dentro una checklist?
Siamo dentro un callout?
Il testo evidenziato ha già un altro colore?
Cosa deve succedere premendo Invio?
Dove deve continuare la digitazione dopo ESC?
Come salviamo HTML senza salvare markup arbitrario?
Come ricostruiamo l'editor dopo un rerender?
Questo capitolo usa offline-quiz-runtime.js come caso di studio per capire una cosa fondamentale:
Un rich-text editor non modifica una stringa. Modifica contemporaneamente un albero DOM, una selezione, uno stato di editing e una rappresentazione persistibile.
textarea a contenteditabletextarea non bastaUn normale <textarea> conserva essenzialmente una stringa:
"Questa è la mia spiegazione"
Possiamo conoscere facilmente:
textarea.value
textarea.selectionStart
textarea.selectionEnd
Ma il testo non contiene struttura HTML.
Non possiamo rappresentare naturalmente:
<strong>questa parte</strong>
<ul>
<li>punto uno</li>
<li>punto due</li>
</ul>
senza inventare un formato nostro o mostrare direttamente markup all’utente.
Con contenteditable, invece, un normale elemento DOM diventa modificabile:
const editor = document.createElement("div");
editor.contentEditable = "true";
PlumePilot crea due editor per domanda in offline-quiz-runtime.js:
const editor = document.createElement("div");
editor.className = "note-editor";
editor.contentEditable = "true";
editor.setAttribute("role", "textbox");
editor.setAttribute("aria-label", labelText);
editor.setAttribute("aria-multiline", "true");
Riferimento:
offline-quiz-runtime.js:470–490
La differenza concettuale è enorme.
Nel textarea lo stato principale è:
stringa
Nel contenteditable lo stato visibile diventa un albero:
editor
├── testo
├── <strong>
│ └── testo
├── <span class="highlight">
│ └── testo
├── <ul class="checklist">
│ ├── <li>
│ └── <li>
└── <div class="callout">
└── testo
Questo introduce subito il concetto di document model.
contenteditable non è un editor completoScrivere:
<div contenteditable="true"></div>
non significa aver costruito un editor.
Significa soltanto aver delegato al browser alcune operazioni di editing:
Il browser decide anche molti dettagli del DOM generato.
Una pressione di Enter, per esempio, non è semanticamente equivalente a:
editor.innerHTML += "<br>";
Il risultato dipende dal contesto in cui si trova il caret:
paragrafo
lista
list item
callout
nodo inline
editor vuoto
Quindi una lezione importante è:
contenteditablefornisce un editing host, non un modello dati di alto livello.
PlumePilot deve costruire sopra quel comportamento il proprio protocollo editoriale.
Quando diciamo:
“il cursore si trova dopo questa parola”
il browser lo rappresenta attraverso la Selection API.
PlumePilot legge la selezione con:
const selection = window.getSelection();
Una Selection descrive ciò che l’utente ha selezionato nel documento.
Può essere:
selezione non collassata
"questa [parte del testo] è importante"
oppure:
selezione collassata
"questa parte| del testo"
↑
caret
Il cursore, quindi, non è un concetto completamente separato.
È essenzialmente una selezione il cui inizio e fine coincidono.
RangeLa Selection contiene uno o più Range.
Nel caso ordinario che interessa il nostro editor possiamo immaginare un range come:
startContainer
startOffset
endContainer
endOffset
Per esempio:
<p>La risposta è importante.</p>
se selezioniamo:
risposta
il range descrive due coordinate dentro l’albero DOM.
Non contiene necessariamente una semplice coppia di indici della stringa.
Questo è cruciale perché una selezione può attraversare nodi differenti:
<p>
La <strong>risposta</strong> è <em>molto importante</em>.
</p>
Un editor rich-text deve quindi ragionare in termini di posizioni nell’albero, non soltanto caratteri.
selectedRangeNel runtime troviamo:
let activeField = null;
let selectedRange = null;
poi:
const rememberSelection = () => {
const selection = window.getSelection();
if (
activeField &&
selection.rangeCount &&
activeField.contains(selection.anchorNode) &&
activeField.contains(selection.focusNode)
) {
selectedRange = selection.getRangeAt(0).cloneRange();
}
};
Riferimento:
offline-quiz-runtime.js:246–252
Perché copiare il range?
Immaginiamo questa sequenza:
1. seleziono "risposta corretta"
2. la Selection appartiene all'editor
3. clicco il pulsante "Giallo"
4. il pulsante riceve l'interazione
5. il browser può spostare focus / selection
6. quando il click handler parte, la vecchia selezione potrebbe non essere più disponibile
Se l’editor si affidasse soltanto a:
window.getSelection()
nel momento del click, potrebbe non sapere più quale testo formattare.
Per questo memorizza:
selection.getRangeAt(0).cloneRange()
Il range clonato diventa uno snapshot della posizione editoriale.
mousedownLa funzione che crea la toolbar contiene:
item.addEventListener("mousedown", (event) => event.preventDefault());
Riferimento:
offline-quiz-runtime.js:391–409
Questo dettaglio sembra minuscolo, ma è molto importante.
Il comportamento predefinito del click su un button può spostare il focus sul controllo.
PlumePilot vuole invece che la sequenza sia più vicina a:
selection nell'editor
↓
click toolbar
↓
non perdere il contesto editoriale
↓
applica trasformazione alla selection precedente
Quindi usa due difese complementari:
preventDefault sul mousedown
+
selectedRange clonato
Questo è un esempio di defensive UI programming.
Non ci affidiamo a un unico comportamento implicito del browser quando la correttezza dell’operazione dipende dalla selezione.
Prima di eseguire un comando, runCommand() fa:
editor.focus();
if (selectedRange && editor.contains(selectedRange.commonAncestorContainer)) {
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(selectedRange);
}
poi:
document.execCommand(command, false, value);
Riferimento:
offline-quiz-runtime.js:253–263
La sequenza è quindi:
saved Range
↓
focus editor
↓
remove current ranges
↓
restore saved Range
↓
execute edit
Questa è quasi una piccola transazione editoriale.
Il runtime usa due famiglie di operazioni.
bold
italic
underline
strikeThrough
insertUnorderedList
insertOrderedList
superscript
subscript
undo
redo
removeFormat
insertText
tramite:
document.execCommand(...)
highlight colorati
study mask
checklist semantics
callout
uscita controllata dai callout
normalizzazione delle liste
sanitizzazione
Questa divisione è molto istruttiva.
PlumePilot non prova a ricostruire da zero l’intero motore di editing del browser, ma non si limita neppure ai comandi nativi.
execCommand() e il debito tecnologico consapevoledocument.execCommand() è oggi un’API deprecata e non fa parte di una specifica Web moderna stabile.
È importante non nasconderlo.
Perché usarla comunque?
Per un editor piccolo e offline offre ancora alcuni vantaggi pratici:
contenteditable;Questo ultimo punto è difficile da replicare perfettamente con semplici manipolazioni DOM.
Quindi la decisione non va letta come:
execCommand è l'API moderna corretta
ma come:
feature scope piccolo
+ compatibilità verificata sui browser target
+ valore dell'undo nativo
+ costo di un editor custom completo molto maggiore
------------------------------------------------
uso pragmatico di una API legacy
Legacy non significa automaticamente bug.
Ma significa che dobbiamo conoscere il rischio:
input / beforeinput;È simile a usare una libreria legacy stabile in un servizio circoscritto.
La domanda corretta non è:
“È vecchia?”
ma:
“Qual è il costo di sostituirla, quali garanzie ci serve ottenere e dove isoliamo la dipendenza?”
In PlumePilot la dipendenza è relativamente concentrata dentro il runtime del quiz.
Per gli highlight PlumePilot non usa execCommand().
Costruisce invece un wrapper semantico:
<span class="highlight" data-color="yellow">
testo
</span>
La funzione principale è:
wrapSelection(editor, className, key, value)
Riferimento:
offline-quiz-runtime.js:343–390
Prima controlla che esista una selezione utile:
if (
!selectedRange ||
selectedRange.collapsed ||
!editor.contains(selectedRange.commonAncestorContainer)
) return;
Quindi una selezione vuota non viene interpretata arbitrariamente.
Il codice contiene:
if (
range.startContainer.parentElement?.closest("div,li,p") !==
range.endContainer.parentElement?.closest("div,li,p")
) return;
In altre parole, PlumePilot rifiuta di applicare il wrapper custom se la selezione attraversa blocchi differenti.
Perché?
Perché trasformare:
<p>prima metà...</p>
<ul>
<li>...seconda metà</li>
</ul>
in un singolo <span> sarebbe strutturalmente problematico.
Questa è una structural guard:
selezione semplice dentro un blocco
↓
operazione consentita
selezione attraverso blocchi incompatibili
↓
fail safely
È lo stesso principio di prudenza visto nell’autoplay e nel reverse engineering:
se non possiamo preservare con sicurezza la struttura, non inventiamo una trasformazione.
extractContents() + insertNode()La trasformazione principale usa:
const contents = range.extractContents();
wrapper.appendChild(contents);
range.insertNode(wrapper);
Mentalmente:
DOM prima
<p>
abc [def ghi] lmn
</p>
extractContents() estrae la parte selezionata in un DocumentFragment:
DocumentFragment
└── "def ghi"
Poi:
wrapper.appendChild(contents);
produce:
<span class="highlight" data-color="yellow">
def ghi
</span>
Infine:
range.insertNode(wrapper);
reinserisce il nuovo nodo nella posizione corretta.
Risultato:
<p>
abc
<span class="highlight" data-color="yellow">def ghi</span>
lmn
</p>
Questo è un esempio reale di manipolazione strutturale del DOM tramite Range.
Supponiamo di avere:
<span class="highlight" data-color="yellow">
concetto importante
</span>
e di selezionare esattamente tutto il testo per renderlo blu.
PlumePilot riconosce il caso:
if (
startHighlight &&
startHighlight === endHighlight &&
editor.contains(startHighlight) &&
range.toString() === startHighlight.textContent
) {
startHighlight.dataset.color = value;
...
}
Non crea quindi:
<span class="highlight" data-color="yellow">
<span class="highlight" data-color="blue">
concetto importante
</span>
</span>
ma modifica direttamente il nodo esistente.
Questo evita markup nesting inutile.
Caso più difficile:
[giallo: abc DEF ghi]
selezioniamo solo:
DEF
per renderlo blu.
Il risultato desiderato è:
[giallo: abc ]
[blu: DEF]
[giallo: ghi]
non:
[giallo:
abc
[blu: DEF]
ghi
]
Per questo il codice controlla se il nuovo wrapper è finito dentro un vecchio highlight:
const oldParent = wrapper.parentElement;
if (className === "highlight" && oldParent?.classList.contains("highlight")) {
const before = oldParent.cloneNode(false);
const after = oldParent.cloneNode(false);
...
}
poi ricostruisce tre segmenti:
before
wrapper nuovo
after
Questo è uno dei primi punti in cui vediamo chiaramente che un rich-text editor è un problema di tree rewriting.
Uno dei problemi più insidiosi di un editor è distinguere:
formato del testo già esistente
da:
formato che verrà applicato al testo che digiteremo dopo
Immaginiamo:
H₂O|
Se il caret è ancora dentro <sub>, il prossimo carattere potrebbe continuare a essere inserito come pedice.
Analogamente:
[highlight giallo: importante|]
il browser potrebbe continuare la digitazione dentro lo <span class="highlight">.
Per l’utente però spesso l’intenzione è:
“Ho finito quella formattazione, ora voglio continuare normalmente.”
Da qui nasce la scorciatoia Escape.
Il key handler contiene:
if (event.key === "Escape") {
Riferimento:
offline-quiz-runtime.js:511–545
Prima, se esiste ancora una selezione estesa:
if (!range.collapsed) selection.collapseToEnd();
Quindi il testo rimane formattato, ma il caret viene spostato alla fine.
Poi controlla i comandi inline:
for (const command of [
"bold",
"italic",
"underline",
"strikeThrough",
"superscript",
"subscript",
]) {
if (document.queryCommandState(command)) {
document.execCommand(command, false, null);
}
}
Concettualmente:
BOLD ON → BOLD OFF
SUPERSCRIPT → NORMAL
SUBSCRIPT → NORMAL
ESC diventa quindi una piccola operazione di state reset.
Per l’highlight non esiste un semplice comando equivalente.
Il caret potrebbe trovarsi fisicamente dentro:
<span class="highlight" data-color="yellow">...</span>
Per continuare fuori dal colore bisogna cambiare la struttura del DOM.
PlumePilot:
La parte centrale è:
const suffix = caret.cloneRange();
suffix.setEnd(highlight, highlight.childNodes.length);
const rest = suffix.extractContents();
poi:
const position = document.createRange();
position.setStart(
parent,
Array.prototype.indexOf.call(parent.childNodes, highlight) + 1,
);
position.collapse(true);
Quindi ESC non significa semplicemente:
set color = null
Significa:
modifica albero
+
ricostruzione selection
+
nuova posizione caret
PlumePilot modella la checklist come una normale lista non ordinata:
<ul class="checklist">
<li>Ripassare definizione</li>
<li class="checked">Rivedere esempio</li>
</ul>
Il CSS trasforma visivamente gli elementi:
.note-editor .checklist {
list-style: none;
}
.note-editor .checklist > li::before {
content: "☐";
}
.note-editor .checklist > li.checked::before {
content: "☑";
}
Questa scelta riusa la semantica strutturale di <ul>/<li> e aggiunge soltanto stato applicativo.
Possiamo descriverla così:
struttura HTML standard
+
classe semantica PlumePilot
+
stato per singolo item
Uno dei bug reali incontrati durante lo sviluppo era questo:
1. creo una checklist
2. esco dalla checklist
3. seleziono "lista puntata"
4. il browser continua / ricrea una riga con semantica checklist
La lista numerata non mostrava lo stesso comportamento.
Questo è un ottimo esempio della difficoltà di contenteditable:
il browser possiede un proprio modello implicito della lista corrente.
Non basta assumere che, dopo un comando, il DOM corrisponda esattamente al modello mentale dell’applicazione.
La soluzione attuale introduce:
function splitChecklist(editor) {
Riferimento:
offline-quiz-runtime.js:270–288
La funzione individua:
↓
<li corrente>
↓
<ul class="checklist">
poi separa l’item corrente dalla checklist.
splitChecklist()Partiamo da:
CHECKLIST
├── A
├── B ← current item
└── C
Il codice crea:
const normal = document.createElement("ul");
const remainder = list.cloneNode(false);
Quindi sposta i sibling successivi in remainder:
original checklist
├── A
└── B
remainder checklist
└── C
Poi rimuove lo stato checked dall’item corrente:
item.classList.remove("checked");
lo sposta nella lista normale:
normal.appendChild(item);
ottenendo:
CHECKLIST
└── A
NORMAL UL
└── B
CHECKLIST
└── C
Questa trasformazione preserva sia ciò che viene prima sia ciò che viene dopo.
Non converte indiscriminatamente tutta la checklist.
Il pulsante lista puntata fa:
if (
command === "insertUnorderedList" &&
currentNode(target)?.closest("ul.checklist")
) {
splitChecklist(target);
return;
}
runCommand(target, command);
if (command === "insertUnorderedList") {
splitChecklist(target);
}
Questo doppio controllo è interessante.
PlumePilot verifica:
prima del comando
+
dopo il comando
perché il browser stesso può aver trasformato il DOM.
È un altro esempio del pattern già visto nel libro:
check
↓
operation
↓
re-check / normalize
Lo stesso principio che usavamo dopo un await ricompare qui dopo un’operazione di editing nativa.
Quando l’utente preme Invio dentro una lista, gran parte del comportamento è ancora gestita dal motore contenteditable del browser.
Il classico doppio Invio su un item vuoto può causare l’uscita dalla lista.
PlumePilot non reimplementa l’intero algoritmo nativo di editing delle liste.
Interviene invece sui punti in cui la semantica custom checklist può sopravvivere o propagarsi in modo indesiderato.
Questo è un compromesso intenzionale:
browser
→ gestione generale lista/caret/Enter
PlumePilot
→ semantica checklist
→ normalizzazione
→ checked state
Ricostruire completamente il comportamento di una lista sarebbe un progetto editoriale molto più grande.
<input>La checklist non usa un checkbox HTML reale.
Il quadrato è generato con CSS.
Per rilevare il click PlumePilot controlla:
const item = event.target.closest(".checklist > li");
if (
item &&
editor.contains(item) &&
event.clientX < item.getBoundingClientRect().left
) {
item.classList.toggle("checked");
}
Riferimento:
offline-quiz-runtime.js:560–570
La zona alla sinistra del contenuto del <li> viene quindi interpretata come click sul marker.
È una forma molto semplice di hit testing.
Un callout viene modellato come:
<div class="callout" data-kind="important">
...
</div>
I tipi ammessi sono:
important
attention
review
Quindi la struttura porta un significato esplicito.
Il CSS decide l’aspetto.
Questa separazione è utile:
semantica → data-kind
presentazione → CSS
Durante lo sviluppo è emerso un problema interessante.
Se non veniva selezionato testo, l’applicazione poteva interpretare arbitrariamente il blocco precedente come target del callout.
Ma una selezione vuota può significare semplicemente:
“voglio iniziare un nuovo callout da qui.”
La versione attuale esplicita questa semantica.
La funzione applyCallout() distingue tre casi.
const existing = node?.closest(".callout");
Se il callout corrente ha lo stesso tipo:
if (existing.dataset.kind === kind) {
existing.classList.remove("callout");
delete existing.dataset.kind;
}
quindi:
Callout Important
↓ click Important
Normal block
Lo stesso controllo diventa un toggle.
Se invece scegliamo un tipo differente:
else existing.dataset.kind = kind;
quindi:
Important
↓
Attention
senza creare un nuovo wrapper.
Se:
selection.isCollapsed
PlumePilot non trasforma arbitrariamente il blocco precedente.
Crea un nuovo callout:
const callout = document.createElement("div");
callout.className = "callout";
callout.dataset.kind = kind;
callout.appendChild(document.createElement("br"));
poi lo inserisce dopo il blocco corrente oppure in fondo all’editor.
Infine crea un nuovo Range e porta lì il caret.
Questa parte corregge direttamente il problema di UX emerso durante lo sviluppo.
Con una selezione reale, PlumePilot chiede al browser di trasformare il blocco:
document.execCommand("formatBlock", false, "div");
poi applica:
block.className = "callout";
block.dataset.kind = kind;
La selezione utente rappresenta quindi una chiara intenzione:
questo blocco
↓
diventa un callout
Nel key handler troviamo un comportamento speciale per Enter:
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.ctrlKey &&
!event.metaKey
) {
Poi il codice cerca il callout corrente:
const block = currentNode(editor)?.closest(".callout");
ma non esce sempre.
Controlla se dal caret alla fine del callout rimane testo:
const remaining = selection.getRangeAt(0).cloneRange();
remaining.setEnd(block, block.childNodes.length);
if (!remaining.toString().trim()) {
Quindi la regola è:
Enter nel mezzo del callout
→ normale editing interno
Enter alla fine del callout
→ esci dal callout
Questo rende l’intenzione prevedibile.
insertNormalBlock()Per uscire dal callout viene creato:
const paragraph = document.createElement("div");
paragraph.appendChild(document.createElement("br"));
block.after(paragraph);
poi il caret viene posizionato dentro il nuovo blocco:
const range = document.createRange();
range.selectNodeContents(paragraph);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
Ancora una volta:
cambiare il DOM senza riposizionare la Selection produrrebbe un editor rotto.
DOM e caret devono essere aggiornati come un’unica operazione logica.
Un editor deve gestire almeno:
mouse
keyboard
paste
browser editing engine
programmatic commands
PlumePilot implementa scorciatoie:
Ctrl/Cmd + B → bold
Ctrl/Cmd + I → italic
Ctrl/Cmd + U → underline
Ctrl/Cmd + G → strike-through
Ctrl/Cmd + E → superscript
Ctrl/Cmd + D → subscript
ESC → exit formatting
Il codice normalizza:
const key = event.key.toLowerCase();
poi mappa:
const command = {
b: "bold",
i: "italic",
u: "underline",
g: "strikeThrough",
e: "superscript",
d: "subscript",
}[key];
Riferimento:
offline-quiz-runtime.js:546–555
Il runtime scarta esplicitamente:
if (event.shiftKey) return;
È una scelta di riduzione dei conflitti.
Una scorciatoia editoriale apparentemente comoda può interferire con:
Un piccolo editor dovrebbe avere un set di shortcut limitato e prevedibile.
Il paste handler fa:
editor.addEventListener("paste", (event) => {
event.preventDefault();
document.execCommand(
"insertText",
false,
event.clipboardData.getData("text/plain"),
);
});
Perché impedire l’HTML del clipboard?
Se incollassimo direttamente rich HTML potremmo ricevere:
<span style="font-family: ...">
<div class="qualcosa">
<a href="...">
<img ...>
oppure markup molto più complesso prodotto da Word, Google Docs o un’altra pagina web.
PlumePilot decide:
paste esterno
↓
text/plain
↓
struttura controllata dal nostro editor
È sia una semplificazione architetturale sia una misura difensiva.
Potremmo pensare:
“L’HTML lo abbiamo creato noi, quindi possiamo salvarlo così com’è.”
Ma il contenuto può attraversare più percorsi:
browser editing engine
custom DOM operations
file annotato precedente
paste
markup legacy
future versions
Inoltre il file HTML annotato viene ricaricato in un contesto eseguibile.
Per questo PlumePilot usa una funzione esplicita:
cleanHtml(source)
Riferimento:
offline-quiz-runtime.js:27–60
La funzione definisce:
const allowed = new Set([
"B",
"STRONG",
"I",
"EM",
"U",
"S",
"UL",
"OL",
"LI",
"SUP",
"SUB",
"BR",
"DIV",
"P",
"SPAN",
]);
Quindi la domanda non è:
“Questo tag è pericoloso?”
ma:
“Questo tag fa parte del nostro formato editoriale?”
È la differenza tra:
blacklist
→ prova a conoscere tutto ciò che non vogliamo
versus:
allowlist
→ accetta soltanto ciò che sappiamo interpretare
Per un piccolo formato controllato, la seconda strategia è molto più semplice da ragionare.
PlumePilot non copia genericamente gli attributi del nodo.
Per uno SPAN riconosce soltanto:
.study-mask
.highlight + data-color ammesso
per un DIV:
.callout + data-kind ammesso
per un UL:
.checklist
per un LI:
.checked
Quindi un nodo del tipo:
<span onclick="..." style="..." data-random="...">
non viene copiato con quegli attributi.
Il sanitizer ricostruisce un nuovo DOM conosciuto.
Il codice commenta:
// Unknown markup is discarded along with its content.
Se un nodo non è ammesso, viene restituito un DocumentFragment vuoto.
Questa scelta è molto conservativa.
Un sanitizer differente potrebbe decidere di:
rimuovere il tag
ma mantenere i figli testuali
PlumePilot preferisce invece non reinterpretare markup che non riconosce.
È un trade-off:
massima conservazione del contenuto
vs
massima prevedibilità del formato
Per un editor di appunti con vocabolario HTML ristretto, la seconda scelta è ragionevole.
All’inizio del runtime troviamo:
const notes = new Map(...);
Ogni domanda possiede una chiave stabile:
noteKey:
`test:${...}:question:${questionIndex + 1}`
Il modello persistito contiene campi come:
explanationHtml
observationsHtml
status
Quindi possiamo distinguere:
LIVE EDITOR STATE
DOM contenteditable
Selection
Range
activeField
↓ input
PERSISTABLE NOTE STATE
Map<noteKey, note>
Questa separazione è fondamentale.
input come commit verso il modelloOgni editor registra:
editor.addEventListener("input", () => {
details.dataset.hasNotes = String(
saveNote(question, field + "Html", cleanHtml(editor.innerHTML)),
);
});
Quindi:
DOM mutation dell'editor
↓
input event
↓
editor.innerHTML
↓
cleanHtml()
↓
saveNote()
↓
notes Map
La Map diventa la rappresentazione persistibile del contenuto.
Il DOM dell’editor può essere distrutto e ricreato durante un render().
Il quiz può essere rerenderizzato quando:
Il container viene sostituito:
container.replaceChildren();
poi ogni editor viene ricreato.
Il contenuto salvato viene reinserito con:
if (saved[field + "Html"]) {
editor.innerHTML = cleanHtml(saved[field + "Html"]);
} else {
editor.textContent = saved[field] || "";
}
Quindi:
DOM corrente
≠
source of truth permanente degli appunti
La notes Map è più vicina al modello dati; il DOM è una vista editabile di quel modello.
È simile a:
View Model / DOM
↓
change event
↓
application state
↓
re-render
anche se qui non stiamo usando React/Vue o un framework reattivo.
Il quiz offline non usa un database esterno.
Quando l’utente sceglie:
Scarica HTML con appunti
PlumePilot clona il documento:
const clone = document.documentElement.cloneNode(true);
poi serializza la Map:
Object.fromEntries(notes)
la codifica e la inserisce in:
<script id="notes-data" type="application/octet-stream">...</script>
Infine salva l’intero HTML.
Riferimento:
offline-quiz-runtime.js:689–705
Quindi il file contiene contemporaneamente:
UI
runtime JavaScript
dati dei quiz
immagini offline
appunti
stati di revisione
È una piccola self-contained application snapshot.
Il runtime usa TextEncoder, TextDecoder, btoa() e atob() per trasformare il JSON in una rappresentazione testuale sicura da inserire nel nodo dati.
Concettualmente:
Map
↓ Object.fromEntries
Object
↓ JSON.stringify
JSON UTF-8
↓ Base64
payload testuale
Al caricamento avviene il percorso inverso.
Questo non è cifratura.
È soltanto encoding.
L’obiettivo è poter incorporare il payload nel file senza mescolare direttamente il suo contenuto con markup o script eseguibile.
Ogni domanda può anche avere:
🟢 Verificata
🟡 Da rivedere
🔴 Da verificare
Questo stato non viene rappresentato come colore dentro l’editor.
Vive in:
note.status
Il runtime può quindi filtrare le domande senza interpretare il loro HTML.
Questa è una buona separazione semantica:
contenuto dell'appunto
→ HTML controllato
workflow di studio
→ status enum
Se avessimo codificato lo stato soltanto attraverso colori o classi visuali nel testo, la logica di filtro sarebbe molto più fragile.
Anche questo piccolo editor può essere visto come una macchina a stati.
Non necessariamente una FSM esplicita, ma un insieme di stati combinati:
activeField
selection collapsed / expanded
current formatting
current block type
current list type
callout kind
highlight ancestry
focus state
Gli eventi includono:
FOCUS
KEYDOWN
INPUT
MOUSEUP
MOUSEDOWN TOOLBAR
CLICK TOOLBAR
PASTE
CLICK CHECKLIST
Le guardie includono:
selection appartiene all'editor?
selection è collapsed?
range attraversa più blocchi?
siamo dentro una checklist?
siamo dentro un callout?
caret è alla fine del callout?
E le azioni:
execCommand
extractContents
insertNode
split list
create block
move caret
saveNote
sanitize
Quindi possiamo riscrivere mentalmente:
EDITOR_IDLE
↓ focus
EDITOR_ACTIVE
├── select text
│ ↓
│ RANGE_SELECTED
│ ↓ toolbar
│ FORMAT_SELECTION
│
├── caret in callout + Enter at end
│ ↓
│ EXIT_CALLOUT
│
├── caret in highlight + Escape
│ ↓
│ SPLIT_HIGHLIGHT
│
└── input
↓
SANITIZE_AND_SAVE
Il concetto di state machine, quindi, non era specifico dell’autoplay. È un modo generale di ragionare sui sistemi interattivi.
Possiamo formulare alcune proprietà che dovrebbero sempre essere vere.
Un comando della toolbar deve modificare soltanto l’editor attivo.
selectedRange ∈ activeField
Il markup salvato deve appartenere al vocabolario consentito.
saved HTML ⊆ allowed editor schema
Una lista puntata normale non deve ereditare semanticamente .checklist solo perché l’utente proveniva da una checklist.
Una selezione vuota su “Callout” non deve trasformare arbitrariamente un blocco precedente.
Dopo ESC, la digitazione successiva non deve ereditare involontariamente una formattazione che l’utente ha deciso di terminare.
Dopo ogni trasformazione strutturale, il caret deve trovarsi in una posizione valida e prevedibile.
Visti in questo modo, i bug diventano più semplici da classificare.
Non sono soltanto:
"lista che si comporta male"
ma:
violazione dell'invariante di separazione tra list type
Un utente si aspetta che:
scrivo
→ formatto
→ inserisco lista
→ modifico colore
→ Ctrl+Z
ripercorra operazioni coerenti.
Con execCommand(), il browser mantiene parte della propria undo history.
Ma quando manipoliamo direttamente il DOM con:
extractContents()
insertNode()
replaceWith()
appendChild()
l’integrazione con quella history non è necessariamente equivalente in tutti i browser.
Questo è uno dei principali problemi degli editor ibridi.
Possiamo immaginare due motori:
Browser editing history
+
Custom DOM mutations
che non sempre condividono la stessa idea di “transazione”.
Un editor più grande potrebbe introdurre un proprio transaction model:
before state
↓
operation
↓
normalized document state
↓
transaction record
↓
after state
per esempio:
{
type: "SET_HIGHLIGHT",
range: ...,
color: "yellow"
}
oppure un modello documentale indipendente dal DOM.
Il DOM diventerebbe una proiezione:
Document Model
↓
DOM render
invece di essere contemporaneamente:
modello + vista + editing engine
È la direzione seguita dai rich-text editor professionali.
Ma avrebbe un costo progettuale enormemente superiore rispetto alle esigenze di PlumePilot.
beforeinput come possibile evoluzioneLe piattaforme Web moderne espongono anche l’evento:
beforeinput
che viene emesso prima che molte modifiche dell’utente vengano applicate all’editing host.
In un editor più strutturato può essere utile per intercettare intenzioni del tipo:
insertText
insertParagraph
formatBold
historyUndo
...
prima della mutazione.
Un’ipotetica evoluzione potrebbe usare:
beforeinput
+ Selection / Range
+ DOM normalization
+ transaction layer
riducendo progressivamente la dipendenza da execCommand().
Ma questo non significa che la migrazione sia una semplice sostituzione uno-a-uno.
execCommand("bold") concentra dietro una singola chiamata molti dettagli di editing che dovremmo poi gestire noi.
Per il nostro caso il runtime gestisce:
due piccoli campi per domanda
insieme limitato di formati
file offline
nessun collaborative editing
nessun documento lungo multiutente
nessun plugin ecosystem
Quindi un editor minimale custom è difendibile.
Se però aggiungessimo:
tabelle editabili
link complessi
immagini drag-and-drop
nested callout
indentazione arbitraria
history robusta cross-browser
collaboration
commenti multiutente
schema documentale versionato
il costo crescerebbe rapidamente.
A quel punto avrebbe senso valutare un vero editor framework con document model e transaction system.
La lezione non è:
“non usare librerie.”
È:
scegli il livello di infrastruttura proporzionato alla complessità semantica dell’editor.
Due DOM possono apparire identici ma comportarsi diversamente al prossimo input.
Per esempio:
<span class="highlight">abc|</span>
visivamente può sembrare uguale a:
<span class="highlight">abc</span>|
ma il prossimo carattere:
x
nel primo caso può entrare nell’highlight.
Nel secondo no.
Quindi dobbiamo testare:
DOM
+
Selection
+
prossima azione dell'utente
selezione singolo nodo
selezione con bold già presente
selection collapsed
ESC dopo bold
ESC dopo superscript
ESC dopo subscript
plain → yellow
yellow → blue su tutto il wrapper
yellow → blue su sottoselezione
ESC nel mezzo
ESC alla fine
selezione cross-block → nessuna trasformazione
nuova checklist
check / uncheck item
checklist → bullet
checklist → numbered
uscita da lista → nuova bullet
split con item precedenti
split con item successivi
split con entrambi
selection → callout
caret collapsed → nuovo callout
same kind → remove
kind A → kind B
Enter nel mezzo
Enter alla fine
scrivi → cambia capitolo → torna
scrivi → filtra → ritorna
scarica annotato → riapri
markup legacy → cleanHtml
paste rich text → plain text
Chrome
Edge
Firefox
In particolare per i comandi legacy non possiamo assumere equivalenza perfetta tra browser.
Molte regole dell’editor si prestano a test di proprietà.
Per esempio:
Dopo cleanHtml():
nessun tag fuori dall'allowlist deve esistere.
Oppure:
Dopo wrapSelection():
il testo visibile totale deve essere invariato.
Oppure:
Dopo cambiare colore a una sottoselezione:
nessun highlight identico deve essere annidato inutilmente.
Oppure:
Dopo splitChecklist():
l'ordine testuale degli item deve essere invariato.
Queste proprietà sono spesso più potenti di un singolo test basato su coordinate DOM esatte.
Il sanitizer ci permette di dedurre uno schema implicito.
Possiamo rappresentarlo così:
NoteDocument
├── inline
│ ├── B / STRONG
│ ├── I / EM
│ ├── U
│ ├── S
│ ├── SUP
│ ├── SUB
│ └── SPAN
│ ├── highlight[data-color]
│ └── study-mask
│
├── block
│ ├── DIV
│ │ └── callout[data-kind]
│ └── P
│
└── list
├── UL
│ └── checklist?
├── OL
└── LI
└── checked?
Quindi, anche se non esiste un file JSON Schema, abbiamo già un document schema.
Questo è utile per eventuali migrazioni future.
Al momento la struttura è semplice e il sanitizer supporta anche valori legacy testuali:
if (saved[field + "Html"]) ...
else editor.textContent = saved[field] || "";
È una piccola forma di backward compatibility.
Se il formato crescesse molto, potrebbe essere utile introdurre:
{
version: 2,
explanationHtml: "...",
observationsHtml: "...",
status: "review"
}
esattamente come abbiamo già visto per gli snapshot della commissione.
Ancora una volta, lo stesso problema architetturale ricompare in domini differenti:
stato persistito
→ schema
→ evoluzione
→ migrazione
Se dovessimo far crescere molto l’editor, potremmo separare:
┌───────────────────────┐
│ Note Model │
│ blocks / marks / list │
└───────────┬───────────┘
│ render
▼
┌───────────────────────┐
│ DOM Adapter │
└───────────┬───────────┘
│
▼
contenteditable
▲
│ input intent
┌───────────┴───────────┐
│ Selection Adapter │
│ beforeinput / Range │
└───────────────────────┘
Le operazioni diventerebbero comandi espliciti:
TOGGLE_BOLD
SET_HIGHLIGHT(yellow)
TOGGLE_CHECKLIST
SET_CALLOUT(attention)
EXIT_FORMATTING
Il vantaggio sarebbe maggiore prevedibilità.
Lo svantaggio sarebbe costruire e mantenere un vero motore editoriale.
Per PlumePilot attuale probabilmente non ne vale la pena.
Possiamo fare un’analogia con database e stream.
Un Range è simile a un cursore che identifica una porzione della struttura corrente.
Ma attenzione: il DOM può cambiare.
Quindi il range non è un identificatore business stabile.
È più vicino a:
cursor / pointer dentro una struttura mutabile
che a:
primary key
Questa distinzione spiega perché PlumePilot verifica sempre che il range appartenga ancora all’editor corretto.
cleanHtml() come boundary validationNel Capitolo 2 abbiamo visto che bridge.js normalizza i messaggi che attraversano un boundary.
Qui accade la stessa cosa con HTML:
DOM arbitrario dell'editing engine
↓
cleanHtml()
↓
formato PlumePilot ammesso
cleanHtml() è quindi un anti-corruption layer tra:
browser editing DOM
e:
persisted note format
input come event sourcing? Non proprioPotrebbe essere tentante dire:
ogni input event = evento persistito
ma non è ciò che accade.
PlumePilot salva lo snapshot corrente dell’HTML sanitizzato.
Non conserva una sequenza come:
INSERT_TEXT
TOGGLE_BOLD
ADD_CALLOUT
...
Quindi il modello è:
state snapshot
non:
event sourcing
Questa distinzione è importante.
Lezione:
il browser editing engine mantiene struttura e contesto propri; dopo un comando bisogna verificare il DOM risultante, non assumere che corrisponda all’intenzione.
Pattern:
command
↓
inspect
↓
normalize
Lezione:
una selection collassata è informazione, non assenza di informazione.
Può significare:
insertion point
non:
usa arbitrariamente il blocco precedente
La UI deve interpretare correttamente lo stato epistemico della Selection.
Lezione:
il formato non appartiene soltanto al testo passato; può appartenere anche alla posizione corrente del caret.
Per questo ESC deve agire sullo stato futuro della digitazione.
Lezione:
focus e selection sono parte dello stato applicativo.
Un innocuo click UI può distruggere il contesto necessario a un’altra operazione.
Da qui:
rememberSelection()
+
preventDefault(mousedown)
+
restore range
Non basta pensare:
testo + stile
Dobbiamo pensare:
DOM tree
+
Selection / Range
+
editing engine
+
application semantics
Ogni comando deve chiedersi:
Il testo rimane lo stesso?
La struttura rimane valida?
Il caret rimane valido?
Il comando modifica soltanto l'editor corretto?
Il markup rimane nel nostro schema?
La checklist non è conosciuta dal browser.
Il browser conosce:
UL
LI
PlumePilot aggiunge:
checklist
checked
Quando questi due modelli interagiscono, serve un layer di normalizzazione.
Gli editor vengono distrutti e ricreati.
Gli appunti sopravvivono perché esiste un modello separato:
notes Map
Il DOM è quindi una rappresentazione modificabile, non il deposito definitivo dello stato.
Il contenuto HTML attraversa browser, file salvati e versioni differenti.
Persistire soltanto un sottoinsieme riconosciuto rende il sistema più prevedibile.
Usare execCommand() può essere pragmatico in uno scope ristretto.
Ma dobbiamo sapere:
API deprecated
+
comportamento browser-specific
+
necessità di regression test
Il debito tecnologico è molto meno pericoloso quando è consapevole e confinato.
Dato:
<p>La <strong>risposta corretta</strong> è B.</p>
immagina di selezionare:
corretta
Prova a descrivere:
startContainer
startOffset
endContainer
endOffset
Poi chiediti cosa cambierebbe selezionando:
risposta corretta è
attraversando il confine tra <strong> e testo normale.
splitChecklist()Parti da:
☑ A
☐ B ← caret
☐ C
☐ D
Disegna il DOM prima e dopo il passaggio a lista puntata.
Verifica che l’ordine:
A B C D
rimanga invariato.
Parti da:
<span class="highlight" data-color="yellow">
abc def ghi
</span>
Seleziona def e applica blu.
Scrivi il DOM desiderato senza nesting inutile.
Aggiungi mentalmente:
<a href="https://example.com" onclick="alert(1)">test</a>
agli appunti.
Che cosa dovrebbe produrre cleanHtml() attuale?
È la politica che sceglieresti per un editor più generico?
Perché?
execCommand()Scegli un solo comando:
bold
Prova a progettare una versione basata esclusivamente su:
Selection
Range
DOM
beforeinput
Poi considera questi casi:
selection già parzialmente bold
selection attraversa due nodi
caret collapsed
undo
redo
paste
nested marks
L’esercizio serve soprattutto a capire perché un editor completo è complesso.
contenteditableHTML Standard:
https://html.spec.whatwg.org/multipage/interaction.html#contenteditable
MDN:
https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable
MDN:
https://developer.mozilla.org/en-US/docs/Web/API/Selection
W3C Selection API:
https://www.w3.org/TR/selection-api/
MDN:
https://developer.mozilla.org/en-US/docs/Web/API/Range
beforeinputMDN:
https://developer.mozilla.org/en-US/docs/Web/API/Element/beforeinput_event
Input Events specification:
https://www.w3.org/TR/input-events-2/
execCommand()MDN:
https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
Da leggere notando esplicitamente lo stato Deprecated dell’API.
Il piccolo editor degli appunti di PlumePilot ci porta sorprendentemente vicino a problemi tipici dei grandi rich-text editor.
Siamo partiti da:
"vorrei mettere il grassetto negli appunti"
ma abbiamo incontrato:
contenteditable
Selection
Range
caret
focus
DOM tree rewriting
liste
normalizzazione
state machine
sanitizzazione
schema dati
persistenza
undo history
cross-browser behavior
La lezione più importante è probabilmente questa:
Modificare testo ricco significa mantenere coerenti contenuto, struttura e posizione editoriale nello stesso momento.
PlumePilot risolve il problema con un editor deliberatamente piccolo e ibrido:
browser editing engine
+
execCommand per operazioni semplici
+
Range/DOM per semantica custom
+
cleanHtml come boundary
+
Map come stato persistibile
Non è l’architettura che useremmo necessariamente per costruire Google Docs.
È però una soluzione proporzionata al problema reale e, proprio per questo, un eccellente caso di studio.
Nel prossimo capitolo torneremo alla UI dell’estensione nel suo complesso:
11 — UI senza framework: popup, menu fluttuante e sincronizzazione.
Qui vedremo come PlumePilot costruisce interfacce relativamente ricche con DOM, CSS e JavaScript vanilla, come condivide stato tra popup e floating menu e dove la futura revisione UI/UX ci darà l’occasione di separare meglio modello, rendering e layout.
← 09 — Generare PDF, HTML ed EPUB · Indice · 11 — UI senza framework →