Para envolver un rango de texto y conservar los elementos en línea existentes, envuelve cada fragmento de texto seleccionado en su propio span. Este recorrido en Safari para Mac usa un párrafo sintético para mantener intactos los elementos de énfasis, el orden del texto y las referencias a los elementos.
Por qué puede fallar un único span envolvente
Un rango del navegador puede empezar dentro de un elemento en línea y terminar fuera de él. Por ejemplo, una selección podría empezar a mitad de un texto enfatizado, cruzar un elemento anidado y terminar en el texto final del párrafo. Esa selección no tiene por qué corresponder a un único subárbol completo.
Range.surroundContents() no puede manejar todas estas selecciones. Lanza una excepción cuando el rango contiene parcialmente un nodo que no es de texto. El estándar DOM actual especifica un InvalidStateError para esa condición. Un rango que contiene texto y elementos seleccionados por completo puede usar este método, pero cruzar el límite de un elemento requiere otro plan. Estándar DOM, Range: método surroundContents()
Extraer el rango en un único span nuevo es otra transformación posible, pero sus reglas de conservación son distintas. extractContents() clona los elementos ancestros seleccionados parcialmente. Si la tarea requiere que los elementos en línea originales permanezcan completos y conserven su identidad, esas ramas clonadas no son adecuadas. Range: método extractContents()
Usa esta guía de decisión antes de cambiar el documento:
| Resultado requerido | Enfoque adecuado |
|---|---|
| Un solo envoltorio alrededor del texto y de los elementos seleccionados por completo | Considera surroundContents() |
| Los elementos en línea originales permanecen completos en una selección parcial | Envuelve por separado los fragmentos de texto seleccionados |
| Un solo envoltorio que cruza límites de elementos seleccionados parcialmente | Acepta la reestructuración y revisa el comportamiento de extracción y clonación |
| Énfasis visual sin añadir elementos | Reconsidera si la tarea necesita spans |
El ejemplo siguiente usa envoltorios separados. En este caso, conservar la estructura significa mantener los objetos de elemento existentes y sus relaciones de ancestro mientras se añaden spans debajo de ellos. Los nodos de texto se dividirán donde empiece o termine la selección.
Abre el ejemplo sintético en Safari para Mac
Abre una pestaña nueva de Safari e introduce about:blank. Luego ve a Safari → Configuración… → Avanzado y activa Mostrar funciones para desarrolladores web. Abre Desarrollo → Mostrar inspector web, o pulsa Option–Command–I. Estos son los pasos documentados por WebKit. Activar el inspector web
Selecciona la pestaña Consola. Ejecuta el programa JavaScript completo de abajo en su indicador. WebKit documenta que el indicador de la consola evalúa JavaScript en el contexto de ejecución seleccionado de la página inspeccionada. Para este ejemplo, usa el contexto de nivel superior de la página. Interfaz del inspector web
No se necesita ninguna biblioteca, importación, paso de compilación ni contenido existente del documento. El programa crea su propio párrafo, envuelve un rango predeterminado, restaura una selección hacia delante sobre el texto envuelto y añade un botón para quitarlo. La entrada compatible es deliberadamente limitada: un párrafo que contiene texto y descendientes ordinarios em, strong, span o code, con ambos límites del rango dentro de nodos de texto.
(() => {
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);
})();
Comprende qué texto se envuelve
El párrafo dice Alpha bright blue gamma delta.. Su elemento strong existente contiene bright y un elemento em anidado que contiene blue. El rango empieza en el desplazamiento 3 de bright y termina en el desplazamiento 6 de gamma delta.. Estos son valores de ejemplo definidos por el programa.
La salida esperada de la consola contiene tres fragmentos:
[\"ght \", \"blue\", \" gamma\"]
El primer span pasa a ser hijo del elemento strong original. El segundo pasa a ser hijo del elemento em original. El tercero pasa a ser hijo del párrafo. Al leer su texto en el orden del documento, se reproduce la cadena seleccionada, ght blue gamma. El prefijo no seleccionado bri y el sufijo delta. quedan fuera de los envoltorios.
El recorrido usa createTreeWalker() con NodeFilter.SHOW_TEXT para visitar los nodos de texto descendientes. intersectsNode() identifica los candidatos, y los desplazamientos de los límites recortan el primer y el último candidato. Se excluyen los fragmentos de longitud cero. Document: método createTreeWalker(), Range: método intersectsNode()
El programa registra todos los límites de los fragmentos antes de modificar nada. Esto importa porque los rangos del DOM son dinámicos: al dividir, insertar o quitar nodos, sus puntos de límite pueden actualizarse. Un Range clonado también sería dinámico; guardar las referencias a los nodos y los desplazamientos numéricos proporciona la lista de trabajo estable que se usa aquí. Estándar DOM: rangos
Para cada fragmento, el programa divide primero el límite final y luego el inicial. splitText() deja el prefijo en el nodo de texto original y devuelve el texto restante como nodo hermano. Esto aísla la subcadena seleccionada sin recrear sus elementos contenedores. Text: método splitText()
Los cuatro casos de planificación de fragmentos —texto que cruza elementos, una subcadena dentro de un nodo, un rango colapsado y límites que no seleccionan caracteres— se comprobaron de forma determinista en Node.js. Durante la preparación no se ejecutaron mutaciones del DOM en el navegador ni interacciones con Safari; el programa incluye aserciones para comprobar esos resultados al ejecutarlo.
Conserva la estructura correcta y los contratos del ciclo de vida
Este programa nunca quita, reemplaza ni clona los elementos strong y em existentes. Sus referencias siguen siendo utilizables, y el listener asociado a strong permanece vinculado a ese mismo objeto. El ejemplo comprueba tanto las relaciones entre padres como el envío de un evento sintético después de envolver el texto.
La identidad de los nodos de texto tiene un contrato distinto. Al dividir los límites se introducen nodos de texto nuevos, y cada fragmento de texto seleccionado pasa a un span nuevo. Cualquier aplicación que conserve referencias a nodos de texto debe tener en cuenta ese cambio. El método conserva el contenido textual y la jerarquía de los elementos originales; no conserva todos los límites originales entre nodos de texto.
Añadir spans también cambia las relaciones de padre inmediatas del texto. Antes de adoptar esta transformación en un documento existente, comprueba el código que espera que el primer hijo de un elemento sea un nodo de texto, los selectores CSS que dependen de hijos directos y los controladores de eventos que inspeccionan el destino del evento. Conservar un objeto de elemento por sí solo no garantiza que se sigan cumpliendo todas las suposiciones de la aplicación circundante.
El ejemplo reconstruye explícitamente una selección hacia delante después de envolver el texto. No conserva la dirección hacia atrás de la selección ni proporciona una transacción de deshacer del editor. Considera esos requisitos por separado al integrar la operación en una interfaz de edición. La propia función de envoltura devuelve sus spans sin garantizar un estado de selección concreto del navegador.
Al hacer clic en Quitar spans, se desenvuelven solo los spans creados durante esa ejecución y se borra la selección. Los nodos de texto divididos permanecen separados. Esa acción restaura la jerarquía de elementos y el texto originales, pero no revierte exactamente la identidad de los nodos de texto. Evita añadir normalización automática si otro código depende de esas referencias.
Adapta el ejemplo sin ampliar su alcance de forma inadvertida
Para una selección hecha arrastrando con el usuario, obtén su rango solo después de comprobar que window.getSelection() devolvió una selección y que rangeCount no es cero. getRangeAt(0) devuelve el primer rango; solicitar un índice fuera del recuento de rangos de la selección produce un error. Selection: método getRangeAt()
Pasa ese rango a la misma función auxiliar solo si ambos contenedores de límite son nodos de texto dentro del párrafo previsto. Una selección cuyo contenedor de límite sea un elemento se rechaza antes de modificar nada. Admitir esos límites requiere una conversión adicional, definida explícitamente, a posiciones de texto descendientes; quitar simplemente la condición de control no la implementa.
La función auxiliar también rechaza los descendientes no compatibles y los envoltorios marcados existentes antes de hacer cambios. Para anotaciones repetidas o superpuestas, define primero si los spans nuevos deben anidarse, fusionarse o reemplazar los anteriores. El ejemplo deja esa decisión explícita en lugar de acumular envoltorios sin una política.
Los desplazamientos se miden en unidades de código UTF-16. Para posiciones calculadas en texto que contiene caracteres de varias unidades, asegúrate de que los límites no dividan la secuencia de caracteres prevista. El ejemplo sintético usa texto latino sencillo, por lo que sus desplazamientos numéricos no son ambiguos. Text: método splitText()
Para esta tarea, el invariante útil es sencillo: al concatenar el texto de los spans nuevos debe obtenerse la cadena seleccionada original, el texto completo del párrafo debe permanecer sin cambios y los elementos en línea existentes deben conservar sus identidades y relaciones de ancestro. Comprueba los tres aspectos al adaptar el ejemplo; en conjunto, describen qué significa una conservación correcta.
Fuentes
- Estándar DOM — WHATWG.
- Activar el inspector web — WebKit.
- Interfaz del inspector web — WebKit.
- Range: método surroundContents() — MDN Web Docs.
- Range: método extractContents() — MDN Web Docs.
- Document: método createTreeWalker() — MDN Web Docs.
- Range: método intersectsNode() — MDN Web Docs.
- Text: método splitText() — MDN Web Docs.
- Selection: método getRangeAt() — MDN Web Docs.