Om du vill omsluta ett textintervall och samtidigt bevara befintliga inline-element omsluter du varje markerat textfragment i ett eget span-element. Den här genomgången för Safari på Mac använder ett syntetiskt stycke för att bevara emfaselement, textordning och elementreferenser.
Varför ett omslutande span-element kan misslyckas
Ett webbläsarintervall kan börja inuti ett inline-element och sluta utanför det. En markering kan till exempel börja mitt i emfasmarkerad text, passera ett nästlat element och sluta i styckets avslutande text. Markeringen motsvarar då inte nödvändigtvis ett enda komplett delträd.
Range.surroundContents() kan inte hantera alla sådana markeringar. Metoden ger ett fel när intervallet delvis innehåller en nod som inte är en textnod. Den aktuella DOM-standarden anger InvalidStateError för det fallet. Ett intervall som innehåller text och helt markerade element kan använda den här metoden, men om markeringen korsar en elementgräns behövs en annan lösning. DOM Standard, Range: surroundContents() method
Att extrahera intervallet till ett enda nytt span-element är en annan möjlig omvandling, men den har andra regler för bevarande. extractContents() klonar förfäderelement som bara är delvis markerade. Om uppgiften kräver att de ursprungliga inline-elementen förblir intakta och behåller sina identiteter är de klonade grenarna olämpliga. Range: extractContents() method
Använd den här beslutshjälpen innan du ändrar dokumentet:
| Önskat resultat | Lämpligt tillvägagångssätt |
|---|---|
| Ett omslutande element runt text och helt markerade element | Överväg surroundContents() |
| Ursprungliga inline-element förblir intakta vid en delvis markering | Omslut markerade textfragment var för sig |
| Ett omslutande element över delvis markerade elementgränser | Acceptera omstrukturering och granska hur extrahering och kloning fungerar |
| Visuell emfas utan att lägga till element | Överväg om uppgiften verkligen behöver span-element |
Exemplet nedan använder separata omslutningar. Att bevara strukturen innebär här att behålla befintliga elementobjekt och deras förfäder–barn-relationer medan span-element läggs till under dem. Textnoder delas där markeringen börjar eller slutar.
Öppna det syntetiska exemplet i Safari på Mac
Öppna en ny Safari-flik och skriv about:blank. Välj sedan Safari → Settings… → Advanced och aktivera Show features for web developers. Öppna Develop → Show Web Inspector eller tryck på Option–Command–I. Det här är stegen som WebKit dokumenterar. Enabling Web Inspector
Välj fliken Console. Kör hela JavaScript-programmet nedan i inmatningsfältet. WebKit dokumenterar att konsolens inmatningsfält utvärderar JavaScript i den valda exekveringskontexten för den inspekterade sidan. Använd sidans toppnivåkontext i det här exemplet. Web Inspector Interface
Inget bibliotek, ingen import, inget byggsteg och inget befintligt dokumentinnehåll krävs. Programmet skapar ett eget stycke, omsluter ett förutbestämt intervall, återställer en framåtriktad markering över den omslutna texten och lägger till en knapp för borttagning. Indata som stöds är avsiktligt begränsade: ett stycke med text och vanliga underordnade em, strong, span eller code, där båda intervallgränserna ligger inuti textnoder.
(() => {
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);
})();
Förstå vilken text som omsluts
Stycket lyder Alpha bright blue gamma delta. Dess befintliga strong-element innehåller bright och ett nästlat em-element som innehåller blue. Intervallet börjar vid offset 3 i bright och slutar vid offset 6 i gamma delta. Det här är illustrativa indata som definieras av programmet.
Den förväntade konsolutmatningen innehåller tre fragment:
[\"ght \", \"blue\", \" gamma\"]
Det första span-elementet blir ett barn till det ursprungliga strong-elementet. Det andra blir ett barn till det ursprungliga em-elementet. Det tredje blir ett barn till stycket. Om texten läses i dokumentordning återskapas den markerade strängen ght blue gamma. Det omarkerade prefixet bri och suffixet delta. förblir utanför omslutningarna.
Genomgången använder createTreeWalker() med NodeFilter.SHOW_TEXT för att besöka underordnade textnoder. intersectsNode() identifierar kandidaterna, och gränsoffsets trimmar den första och sista kandidaten. Fragment med längden noll utesluts. Document: createTreeWalker() method, Range: intersectsNode() method
Programmet registrerar alla fragmentgränser innan något ändras. Det är viktigt eftersom DOM-intervall är levande: när noder delas, infogas eller tas bort kan deras gränspunkter uppdateras. Även ett klonat Range skulle vara levande; genom att spara nodreferenserna och de numeriska offsetvärdena skapas den stabila arbetslista som används här. DOM Standard: Ranges
För varje fragment delar programmet först vid slutgränsen och sedan vid startgränsen. splitText() låter prefixet ligga kvar i den ursprungliga textnoden och returnerar återstående text som ett syskon. På så sätt isoleras den markerade delsträngen utan att dess omslutande element återskapas. Text: splitText() method
De fyra fallen för fragmentplanering – text som korsar element, en delsträng i en nod, ett kollapsat intervall och gränser som inte väljer några tecken – kontrollerades deterministiskt i Node.js. DOM-ändringar i webbläsaren och interaktion med Safari kördes inte under förberedelserna; programmet innehåller assertioner för att kontrollera resultaten när det körs.
Bevara rätt struktur och livscykelkontrakt
De befintliga elementen strong och em tas aldrig bort, ersätts eller klonas av programmet. Deras elementreferenser förblir användbara, och lyssnaren som är kopplad till strong sitter kvar på samma objekt. Exemplet kontrollerar både föräldrarelationerna och en syntetisk händelsedispatch efter omslutningen.
Textnoders identitet följer ett annat kontrakt. När gränser delas tillkommer nya textnoder, och varje markerat textfragment flyttas in i ett nytt span-element. Program som håller referenser till textnoder måste ta hänsyn till den ändringen. Metoden bevarar textinnehållet och den ursprungliga elementhierarkin, men inte varje ursprunglig textnodsgräns.
Att lägga till span-element ändrar också textens direkta föräldrarelationer. Innan du använder omvandlingen i ett befintligt dokument bör du kontrollera kod som förutsätter att ett elements första barn är en textnod, CSS-selektorer som är beroende av direkta barn och händelsehanterare som granskar händelsens mål. Att behålla ett elementobjekt innebär inte i sig att alla antaganden i den omgivande applikationen fortfarande stämmer.
Exemplet bygger uttryckligen upp en framåtriktad markering på nytt efter omslutningen. Det bevarar inte bakåtriktad markeringsriktning och tillhandahåller ingen ångringstransaktion för redigeraren. Behandla det som separata krav när du integrerar åtgärden i ett redigeringsgränssnitt. Själva omslutningsfunktionen returnerar sina span-element utan att lova ett visst urvalstillstånd i webbläsaren.
Om du klickar på Remove spans tas endast de span-element bort som skapades under den körningen, och markeringen rensas. De delade textnoderna förblir separata. Borttagningen återställer den ursprungliga elementhierarkin och texten, men den återställer inte textnodernas identiteter exakt. Undvik automatisk normalisering om annan kod är beroende av de referenserna.
Anpassa exemplet utan att tyst utöka dess omfattning
För en markering som användaren drar med musen hämtar du dess intervall först efter att du kontrollerat att window.getSelection() returnerade en markering och att rangeCount inte är noll. getRangeAt(0) returnerar det första intervallet; om du begär ett index utanför markeringens intervallantal uppstår ett fel. Selection: getRangeAt() method
Skicka intervallet till samma hjälpfunktion endast när båda gränsbehållarna är textnoder inuti det avsedda stycket. En markering där gränsbehållaren är ett element avvisas före ändringarna. För att stödja sådana gränser krävs en ytterligare, uttryckligen definierad omvandling till textpositioner bland underordnade noder; att bara ta bort kontrollen implementerar den inte.
Hjälpfunktionen avvisar också icke-stödda underordnade element och befintliga markerade omslutningar innan den gör några ändringar. För upprepade eller överlappande annoteringar bör du först bestämma om nya span-element ska nästlas, slås samman eller ersätta tidigare sådana. Exemplet synliggör avsiktligt det beslutet i stället för att lägga till omslutningar utan någon policy.
Offsetvärden mäts i UTF-16-kodenheter. Om du beräknar positioner i text som innehåller tecken med flera kodenheter ska du se till att gränserna inte delar upp den avsedda teckensekvensen. Det syntetiska exemplet använder enkel latinsk text, så de numeriska offsetvärdena är entydiga. Text: splitText() method
För den här uppgiften är invarianten enkel: om du sammanfogar texten i de nya span-elementen ska den motsvara den ursprungligen markerade strängen, hela styckets text ska förbli oförändrad och de befintliga inline-elementen ska behålla sina identiteter och förfäder–barn-relationer. Kontrollera alla tre när du anpassar exemplet; tillsammans beskriver de vad ett lyckat bevarande innebär.
Källor
- DOM Standard — WHATWG.
- Enabling Web Inspector — WebKit.
- Web Inspector Interface — WebKit.
- Range: surroundContents() method — MDN Web Docs.
- Range: extractContents() method — MDN Web Docs.
- Document: createTreeWalker() method — MDN Web Docs.
- Range: intersectsNode() method — MDN Web Docs.
- Text: splitText() method — MDN Web Docs.
- Selection: getRangeAt() method — MDN Web Docs.