Per avvolgere un intervallo di testo preservando gli elementi inline esistenti, racchiudi ogni frammento di testo selezionato in uno span distinto. Questa guida passo passo per Safari su Mac usa un paragrafo sintetico per mantenere intatti gli elementi di enfasi, l’ordine del testo e i riferimenti agli elementi.
Perché un unico span circostante può non funzionare
Un intervallo del browser può iniziare all’interno di un elemento inline e terminare al suo esterno. Per esempio, una selezione potrebbe iniziare a metà di un testo enfatizzato, attraversare un elemento annidato e terminare nel testo finale del paragrafo. Tale selezione non corrisponde necessariamente a un unico sottoalbero completo.
Range.surroundContents() non è in grado di gestire tutte queste selezioni. Genera un errore quando l’intervallo contiene parzialmente un nodo non testuale. L’attuale DOM Standard specifica un InvalidStateError per questa condizione. Questo metodo può essere usato con un intervallo che contiene testo ed elementi selezionati per intero, ma per attraversare il confine di un elemento serve un piano diverso. DOM Standard, Range: metodo surroundContents()
Estrarre l’intervallo in un unico nuovo span è un’altra possibile trasformazione, ma comporta regole di preservazione diverse. extractContents() clona gli elementi antenati selezionati solo parzialmente. Se il tuo compito richiede che gli elementi inline originali rimangano integri e mantengano la propria identità, quei rami clonati non sono adatti. Range: metodo extractContents()
Prima di modificare il documento, usa questa guida decisionale:
| Risultato richiesto | Approccio adatto |
|---|---|
| Un unico wrapper intorno al testo e agli elementi selezionati per intero | Valuta surroundContents() |
| Gli elementi inline originali rimangono integri attraverso una selezione parziale | Avvolgi separatamente i frammenti di testo selezionati |
| Un unico wrapper attraverso i confini di elementi selezionati parzialmente | Accetta la ristrutturazione e verifica il comportamento di estrazione e clonazione |
| Enfasi visiva senza aggiungere elementi | Valuta se il compito richiede davvero degli span |
L’esempio seguente sceglie wrapper separati. In questo caso, preservare la struttura significa mantenere gli oggetti elemento esistenti e le relazioni tra antenati, aggiungendo span al loro interno. I nodi di testo verranno suddivisi nei punti iniziale e finale della selezione.
Apri l’esempio sintetico in Safari su Mac
Apri una nuova scheda di Safari e inserisci about:blank. Poi scegli Safari → Impostazioni… → Avanzate e attiva Mostra funzionalità per gli sviluppatori web. Apri Sviluppo → Mostra Web Inspector, oppure premi Opzione–Comando–I. Questi sono i passaggi documentati da WebKit. Attivazione di Web Inspector
Seleziona la scheda Console. Esegui nel relativo prompt il programma JavaScript completo riportato di seguito. WebKit documenta che il prompt della console esegue JavaScript nel contesto di esecuzione selezionato della pagina ispezionata. Per questo esempio, usa il contesto della pagina di livello superiore. Interfaccia di Web Inspector
Non sono necessari librerie, importazioni, passaggi di compilazione o contenuti preesistenti nel documento. Il programma crea il proprio paragrafo, avvolge un intervallo prestabilito, ripristina una selezione in avanti sul testo avvolto e aggiunge un pulsante di rimozione. Gli input supportati sono volutamente limitati: un paragrafo contenente testo e discendenti ordinari em, strong, span o code, con entrambi i limiti dell’intervallo all’interno di nodi di testo.
(() => {
function wrapTextRange(root, range) {
const doc = root.ownerDocument;
const startNode = range.startContainer;
const endNode = range.endContainer;
if (root.tagName !== "P" ||
startNode.nodeType !== 3 || endNode.nodeType !== 3 ||
!root.contains(startNode) || !root.contains(endNode)) {
throw new TypeError("Use one paragraph and text-node boundaries.");
}
const allowed = new Set(["EM", "STRONG", "SPAN", "CODE"]);
for (const element of root.querySelectorAll("*")) {
if (!allowed.has(element.tagName) ||
element.hasAttribute("data-range-wrap")) {
throw new TypeError("Unsupported element or existing wrappers.");
}
}
if (range.collapsed) return [];
// Snapshot every fragment before changing the live DOM range.
const jobs = [];
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (!range.intersectsNode(node)) continue;
const start = node === startNode ? range.startOffset : 0;
const end = node === endNode ? range.endOffset : node.length;
if (end > start) jobs.push({ node, start, end });
}
const spans = [];
for (const { node, start, end } of jobs) {
// Split the end first so the start offset remains valid.
if (end < node.length) node.splitText(end);
const selected = start > 0 ? node.splitText(start) : node;
const span = doc.createElement("span");
span.setAttribute("data-range-wrap", "");
span.style.backgroundColor = "#fff0a6";
selected.parentNode.insertBefore(span, selected);
span.appendChild(selected);
spans.push(span);
}
return spans;
}
const panel = document.createElement("section");
const root = document.createElement("p");
const strong = document.createElement("strong");
const em = document.createElement("em");
const tail = document.createTextNode(" gamma delta.");
strong.append(document.createTextNode("bright "));
em.textContent = "blue";
strong.append(em);
root.append(document.createTextNode("Alpha "), strong, tail);
panel.append(root);
document.body.append(panel);
let clicks = 0;
strong.addEventListener("click", () => { clicks += 1; });
const originalText = root.textContent;
const range = document.createRange();
range.setStart(strong.firstChild, 3);
range.setEnd(tail, 6);
const selectedText = range.toString();
const spans = wrapTextRange(root, range);
const selection = window.getSelection();
const restored = document.createRange();
restored.setStart(spans[0].firstChild, 0);
const lastText = spans[spans.length - 1].firstChild;
restored.setEnd(lastText, lastText.length);
selection.removeAllRanges();
selection.addRange(restored);
strong.dispatchEvent(new Event("click"));
console.assert(root.textContent === originalText, "Text changed");
console.assert(strong.parentNode === root, "Strong element moved");
console.assert(em.parentNode === strong, "Emphasis element moved");
console.assert(clicks === 1, "Existing listener failed");
console.assert(spans.map(s => s.textContent).join("") === selectedText);
console.log(spans.map(s => s.textContent));
const remove = document.createElement("button");
remove.textContent = "Remove spans";
remove.addEventListener("click", () => {
selection.removeAllRanges();
for (const span of spans) {
span.replaceWith(...Array.from(span.childNodes));
}
remove.disabled = true;
});
panel.append(remove);
})();
Comprendi quale testo viene avvolto
Il paragrafo contiene Alpha bright blue gamma delta.. Il suo elemento strong esistente contiene bright e un elemento em annidato che contiene blue. L’intervallo inizia all’offset 3 in bright e termina all’offset 6 in gamma delta.. Si tratta di input illustrativi definiti dal programma.
L’output atteso della console contiene tre frammenti:
["ght ", "blue", " gamma"]
Il primo span diventa figlio dell’elemento strong originale. Il secondo diventa figlio dell’elemento em originale. Il terzo diventa figlio del paragrafo. Leggendo il loro testo nell’ordine del documento si ottiene la stringa selezionata, ght blue gamma. Il prefisso non selezionato bri e il suffisso delta. rimangono fuori dai wrapper.
La traversata usa createTreeWalker() con NodeFilter.SHOW_TEXT per visitare i nodi di testo discendenti. intersectsNode() individua i candidati e gli offset dei limiti ritagliano il primo e l’ultimo candidato. I frammenti di lunghezza zero vengono esclusi. Document: metodo createTreeWalker(), Range: metodo intersectsNode()
Il programma registra tutti i limiti dei frammenti prima di effettuare qualsiasi modifica. Questo è importante perché gli intervalli DOM sono dinamici: la divisione, l’inserimento o la rimozione di nodi può aggiornare i loro punti limite. Anche un Range clonato sarebbe dinamico; salvare i riferimenti ai nodi e gli offset numerici fornisce l’elenco di lavoro stabile usato qui. DOM Standard: intervalli
Per ciascun frammento, il programma divide prima il limite finale e poi quello iniziale. splitText() lascia il prefisso nel nodo di testo originale e restituisce il testo rimanente come nodo fratello. In questo modo isola la sottostringa selezionata senza ricreare gli elementi che la contengono. Text: metodo splitText()
I quattro casi di pianificazione dei frammenti — testo che attraversa elementi, una sottostringa all’interno di un singolo nodo, un intervallo compresso e limiti che non selezionano caratteri — sono stati verificati in modo deterministico in Node.js. Durante la preparazione non sono state eseguite modifiche al DOM del browser né interazioni con Safari; il programma include asserzioni per verificare questi risultati quando viene eseguito.
Preserva la struttura corretta e i contratti del ciclo di vita
Gli elementi strong e em esistenti non vengono mai rimossi, sostituiti o clonati da questo programma. I relativi riferimenti agli elementi restano utilizzabili e il listener collegato a strong rimane collegato allo stesso oggetto. L’esempio verifica sia le relazioni tra gli elementi genitore sia l’invio di un evento sintetico dopo aver avvolto il testo.
I nodi di testo seguono un contratto diverso in termini di identità. La divisione dei limiti introduce nuovi nodi di testo e ogni frammento di testo selezionato viene spostato in un nuovo span. Qualsiasi applicazione che conservi riferimenti ai nodi di testo deve tenere conto di questa modifica. Il metodo preserva il contenuto testuale e la gerarchia degli elementi originali, ma non conserva tutti i confini dei nodi di testo originali.
Aggiungere span modifica anche le relazioni immediate tra gli elementi genitore e il testo. Prima di adottare questa trasformazione in un documento esistente, controlla il codice che si aspetta che il primo figlio di un elemento sia un nodo di testo, i selettori CSS che dipendono dai figli diretti e i gestori di eventi che analizzano il destinatario dell’evento. Conservare un oggetto elemento non significa che tutte le altre assunzioni dell’applicazione rimangano valide.
L’esempio ricostruisce esplicitamente una selezione in avanti dopo aver avvolto il testo. Non conserva la direzione all’indietro della selezione e non fornisce una transazione di annullamento per l’editor. Considera questi aspetti come requisiti distinti quando integri l’operazione in un’interfaccia di modifica. La funzione wrapper restituisce gli span senza garantire uno stato specifico della selezione nel browser.
Facendo clic su Rimuovi span vengono annullati i wrapper solo sugli span creati durante quella specifica esecuzione e la selezione viene cancellata. I nodi di testo divisi restano separati. L’azione di rimozione ripristina la gerarchia originale degli elementi e il testo, ma non ripristina esattamente l’identità dei nodi di testo. Evita di aggiungere una normalizzazione automatica se altro codice dipende da questi riferimenti.
Adatta l’esempio senza ampliarne silenziosamente l’ambito
Per una selezione trascinata dall’utente, ottieni il relativo intervallo solo dopo aver verificato che window.getSelection() abbia restituito una selezione e che rangeCount sia diverso da zero. getRangeAt(0) restituisce il primo intervallo; richiedere un indice fuori dal numero di intervalli della selezione genera un errore. Selection: metodo getRangeAt()
Passa tale intervallo allo stesso helper solo se entrambi i contenitori dei limiti sono nodi di testo all’interno del paragrafo desiderato. Una selezione il cui contenitore del limite è un elemento viene rifiutata prima di qualsiasi modifica. Per supportare limiti di questo tipo serve una conversione aggiuntiva, definita esplicitamente, in posizioni di testo discendenti; limitarsi a rimuovere la verifica non è sufficiente.
L’helper rifiuta anche discendenti non supportati e wrapper già contrassegnati prima di apportare modifiche. In caso di annotazioni ripetute o sovrapposte, definisci prima se i nuovi span devono annidarsi, fondersi o sostituire quelli precedenti. L’esempio rende esplicita questa scelta, invece di accumulare wrapper senza una regola.
Gli offset sono misurati in unità di codice UTF-16. Quando calcoli posizioni in testo contenente caratteri composti da più unità, assicurati che i limiti non dividano la sequenza di caratteri desiderata. L’esempio sintetico usa semplice testo latino, quindi i suoi offset numerici non sono ambigui. Text: metodo splitText()
Per questo compito, l’invariante utile è semplice: la concatenazione del testo dei nuovi span deve essere identica alla stringa selezionata originale, il testo completo del paragrafo deve rimanere invariato e gli elementi inline esistenti devono conservare la propria identità e le relazioni con gli antenati. Quando adatti l’esempio, verifica tutti e tre gli aspetti: insieme descrivono che cosa significa preservare correttamente la struttura.
Fonti
- DOM Standard — WHATWG.
- Attivazione di Web Inspector — WebKit.
- Interfaccia di Web Inspector — WebKit.
- Range: metodo surroundContents() — MDN Web Docs.
- Range: metodo extractContents() — MDN Web Docs.
- Document: metodo createTreeWalker() — MDN Web Docs.
- Range: metodo intersectsNode() — MDN Web Docs.
- Text: metodo splitText() — MDN Web Docs.
- Selection: metodo getRangeAt() — MDN Web Docs.