Pour encapsuler une plage de texte tout en préservant les éléments en ligne existants, placez chaque fragment de texte sélectionné dans son propre span. Ce guide pas à pas sur Safari pour Mac utilise un paragraphe synthétique afin de préserver les éléments de mise en emphase, l’ordre du texte et les références aux éléments.
Pourquoi un span englobant unique peut échouer
Une plage du navigateur peut commencer à l’intérieur d’un élément en ligne et se terminer à l’extérieur de celui-ci. Par exemple, une sélection peut commencer au milieu d’un texte mis en emphase, traverser un élément imbriqué et se terminer dans le texte qui suit le paragraphe. Cette sélection ne correspond pas nécessairement à un sous-arbre complet.
Range.surroundContents() ne peut pas gérer toutes ces sélections. Elle génère une erreur lorsque la plage ne contient qu’une partie d’un nœud qui n’est pas textuel. La norme DOM actuelle spécifie une InvalidStateError dans ce cas. Une plage contenant du texte et des éléments entièrement sélectionnés peut utiliser cette méthode, mais le franchissement d’une limite d’élément nécessite une autre approche. Norme DOM, Range : méthode surroundContents()
Extraire la plage dans un nouveau span est une autre transformation possible, mais ses règles de préservation sont différentes. extractContents() clone les éléments ancêtres partiellement sélectionnés. Si la tâche exige que les éléments en ligne d’origine restent entiers et conservent leur identité, ces branches clonées ne conviennent pas. Range : méthode extractContents()
Utilisez ce guide de décision avant de modifier le document :
| Résultat requis | Approche adaptée |
|---|---|
| Un seul élément enveloppe le texte et les éléments entièrement sélectionnés | Envisagez surroundContents() |
| Les éléments en ligne d’origine restent entiers malgré une sélection partielle | Encapsulez séparément les fragments de texte sélectionnés |
| Un seul élément enveloppe la sélection en traversant des limites d’éléments partiellement sélectionnés | Acceptez la restructuration et examinez le comportement d’extraction et de clonage |
| Mise en emphase visuelle sans ajout d’éléments | Demandez-vous si la tâche nécessite vraiment des spans |
L’exemple ci-dessous choisit des éléments enveloppes distincts. Ici, préserver la structure signifie conserver les objets éléments existants et leurs relations avec leurs ancêtres, tout en ajoutant des spans à l’intérieur de ceux-ci. Les nœuds de texte seront scindés aux limites de la sélection.
Ouvrir l’exemple synthétique dans Safari sur Mac
Ouvrez un nouvel onglet Safari et saisissez about:blank. Choisissez ensuite Safari → Réglages… → Avancées, puis activez Afficher les fonctionnalités pour les développeurs web. Ouvrez Développement → Afficher l’inspecteur Web, ou appuyez sur Option–Commande–I. Ces étapes sont documentées par WebKit. Activer l’inspecteur Web
Sélectionnez l’onglet Console. Exécutez le programme JavaScript complet ci-dessous dans son invite. WebKit indique que l’invite de la console évalue le JavaScript dans le contexte d’exécution sélectionné de la page inspectée. Pour cet exemple, utilisez le contexte de la page de premier niveau. Interface de l’inspecteur Web
Aucune bibliothèque, importation, étape de compilation ni aucun contenu de document existant ne sont requis. Le programme crée son propre paragraphe, encapsule une plage prédéterminée, rétablit une sélection vers l’avant sur le texte encapsulé et ajoute un bouton de suppression. Les entrées prises en charge sont volontairement limitées : un paragraphe contenant du texte et des éléments descendants ordinaires em, strong, span ou code, avec les deux limites de la plage à l’intérieur de nœuds de texte.
(() => {
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);
})();
Comprendre quel texte sera encapsulé
Le paragraphe contient le texte Alpha bright blue gamma delta. Son élément strong existant contient bright ainsi qu’un élément em imbriqué contenant blue. La plage commence à l’offset 3 dans bright et se termine à l’offset 6 dans gamma delta. Ce sont des entrées illustratives définies par le programme.
La sortie attendue de la console contient trois fragments :
[\"ght \", \"blue\", \" gamma\"]
Le premier span devient un enfant de l’élément strong d’origine. Le deuxième devient un enfant de l’élément em d’origine. Le troisième devient un enfant du paragraphe. La lecture de leur texte dans l’ordre du document reproduit la chaîne sélectionnée, ght blue gamma. Le préfixe non sélectionné bri et le suffixe delta. restent à l’extérieur des éléments enveloppes.
Le parcours utilise createTreeWalker() avec NodeFilter.SHOW_TEXT pour parcourir les nœuds de texte descendants. intersectsNode() repère les candidats, et les offsets des limites tronquent les premier et dernier candidats. Les fragments de longueur nulle sont exclus. Document : méthode createTreeWalker(), Range : méthode intersectsNode()
Le programme enregistre toutes les limites des fragments avant d’effectuer des mutations. C’est important, car les plages DOM sont dynamiques : le fractionnement, l’insertion ou la suppression de nœuds peut mettre à jour leurs points de délimitation. Un objet Range cloné resterait lui aussi dynamique ; l’enregistrement des références aux nœuds et des offsets numériques fournit la liste de travail stable utilisée ici. Norme DOM : plages
Pour chaque fragment, le programme scinde d’abord la limite de fin, puis celle de début. splitText() laisse le préfixe dans le nœud de texte d’origine et renvoie le texte restant sous forme de nœud frère. Cela isole la sous-chaîne sélectionnée sans recréer les éléments qui l’encadrent. Text : méthode splitText()
Les quatre cas de planification des fragments — texte traversant plusieurs éléments, sous-chaîne dans un même nœud, plage réduite à un point et limites ne sélectionnant aucun caractère — ont été vérifiés de manière déterministe dans Node.js. Les mutations du DOM dans un navigateur et les interactions avec Safari n’ont pas été exécutées pendant la préparation ; le programme contient des assertions pour vérifier ces résultats lors de son exécution.
Préserver la structure et les contrats de cycle de vie appropriés
Les éléments strong et em existants ne sont jamais supprimés, remplacés ni clonés par ce programme. Leurs références restent utilisables, et l’écouteur attaché à strong reste attaché à ce même objet. L’exemple vérifie les relations parent-enfant ainsi que la distribution d’un événement synthétique après l’encapsulation.
Les nœuds de texte ont un contrat différent. Le fractionnement aux limites crée de nouveaux nœuds de texte, et chaque fragment sélectionné est déplacé dans un nouveau span. Toute application qui conserve des références aux nœuds de texte doit tenir compte de ce changement. La méthode préserve le contenu textuel et la hiérarchie des éléments d’origine ; elle ne conserve pas toutes les limites d’origine entre les nœuds de texte.
L’ajout de spans modifie aussi les relations immédiates entre les parents et les nœuds de texte. Avant d’adopter cette transformation dans un document existant, vérifiez le code qui suppose que le premier enfant d’un élément est un nœud de texte, les sélecteurs CSS qui dépendent des enfants directs et les gestionnaires d’événements qui inspectent la cible de l’événement. Le simple fait de conserver un objet élément ne garantit pas que toutes les hypothèses de l’application environnante restent valides.
L’exemple reconstruit explicitement une sélection vers l’avant après l’encapsulation. Il ne préserve ni le sens d’une sélection vers l’arrière ni une transaction d’annulation d’éditeur. Traitez-les comme des exigences distinctes lors de l’intégration de l’opération dans une interface d’édition. La fonction d’encapsulation elle-même renvoie ses spans sans garantir un état de sélection particulier dans le navigateur.
Cliquer sur Supprimer les spans désencapsule uniquement les spans créés lors de cette exécution et efface la sélection. Les nœuds de texte scindés restent séparés. Cette suppression rétablit la hiérarchie des éléments et le texte d’origine, mais ne restaure pas exactement l’identité des nœuds de texte. Évitez d’ajouter une normalisation automatique si d’autres parties du code dépendent de ces références.
Adapter l’exemple sans élargir silencieusement son champ d’application
Pour une sélection effectuée à la souris, récupérez sa plage uniquement après avoir vérifié que window.getSelection() a renvoyé une sélection et que rangeCount est différent de zéro. getRangeAt(0) renvoie la première plage ; demander un index supérieur ou égal au nombre de plages de la sélection génère une erreur. Selection : méthode getRangeAt()
Transmettez cette plage au même utilitaire uniquement lorsque ses deux conteneurs de limite sont des nœuds de texte situés dans le paragraphe visé. Une sélection dont le conteneur de limite est un élément est rejetée avant toute mutation. La prise en charge de ces limites nécessite une conversion supplémentaire, explicitement définie, vers des positions dans le texte descendant ; il ne suffit pas de supprimer la vérification pour implémenter cette prise en charge.
L’utilitaire rejette également les éléments descendants non pris en charge et les éléments enveloppes déjà marqués avant d’effectuer des changements. Pour des annotations répétées ou qui se chevauchent, définissez d’abord si les nouveaux spans doivent s’imbriquer, fusionner ou remplacer les précédents. L’exemple met volontairement cette décision en évidence au lieu d’accumuler des éléments enveloppes sans règle.
Les offsets sont mesurés en unités de code UTF-16. Pour les positions calculées dans un texte contenant des caractères codés sur plusieurs unités, veillez à ce que les limites ne coupent pas la séquence de caractères visée. L’exemple synthétique utilise du texte latin simple, de sorte que ses offsets numériques ne prêtent pas à confusion. Text : méthode splitText()
Pour cette tâche, l’invariant utile est simple : la concaténation du texte des nouveaux spans doit être égale à la chaîne sélectionnée d’origine, le texte complet du paragraphe doit rester inchangé et les éléments en ligne existants doivent conserver leur identité et leurs relations avec leurs ancêtres. Vérifiez ces trois conditions lors de l’adaptation de l’exemple ; ensemble, elles définissent ce que signifie une préservation réussie.
Sources
- Norme DOM — WHATWG.
- Activer l’inspecteur Web — WebKit.
- Interface de l’inspecteur Web — WebKit.
- Range : méthode surroundContents() — MDN Web Docs.
- Range : méthode extractContents() — MDN Web Docs.
- Document : méthode createTreeWalker() — MDN Web Docs.
- Range : méthode intersectsNode() — MDN Web Docs.
- Text : méthode splitText() — MDN Web Docs.
- Selection : méthode getRangeAt() — MDN Web Docs.