Um einen Textbereich einzuschließen und vorhandene Inline-Elemente zu erhalten, schließen Sie jedes ausgewählte Textfragment in ein eigenes Span-Element ein. Diese Anleitung für Safari auf dem Mac verwendet einen synthetischen Absatz, damit Hervorhebungselemente, Textreihenfolge und Elementreferenzen erhalten bleiben.
Warum ein einzelnes umschließendes Span-Element scheitern kann
Ein Browser-Range kann innerhalb eines Inline-Elements beginnen und außerhalb davon enden. Beispielsweise kann eine Auswahl mitten in hervorgehobenem Text beginnen, ein verschachteltes Element durchqueren und im nachfolgenden Text des Absatzes enden. Diese Auswahl entspricht nicht unbedingt einem vollständigen Teilbaum.
Range.surroundContents() kann nicht jede solche Auswahl verarbeiten. Es löst einen Fehler aus, wenn der Range ein Nicht-Textknoten nur teilweise enthält. Der aktuelle DOM-Standard sieht für diesen Fall einen InvalidStateError vor. Ein Range, der Text und vollständig ausgewählte Elemente enthält, lässt sich mit dieser Methode verarbeiten; beim Überschreiten einer Elementgrenze ist jedoch ein anderer Ansatz nötig. DOM-Standard, Range: Methode surroundContents()
Den Range in ein einzelnes neues Span-Element zu extrahieren, ist eine weitere mögliche Umwandlung, sie folgt jedoch anderen Erhaltungsregeln. extractContents() klont teilweise ausgewählte übergeordnete Elemente. Wenn die ursprünglichen Inline-Elemente vollständig erhalten bleiben und ihre Identität bewahren sollen, sind diese geklonten Zweige ungeeignet. Range: Methode extractContents()
Nutzen Sie diese Entscheidungshilfe, bevor Sie das Dokument ändern:
| Gewünschtes Ergebnis | Geeigneter Ansatz |
|---|---|
| Ein einzelnes Wrapper-Element um Text und vollständig ausgewählte Elemente | surroundContents() in Betracht ziehen |
| Ursprüngliche Inline-Elemente bleiben bei einer Teil-Auswahl vollständig erhalten | Ausgewählte Textfragmente separat umschließen |
| Ein einzelnes Wrapper-Element über teilweise ausgewählte Elementgrenzen hinweg | Umstrukturierung akzeptieren und das Extrahierungs- und Klonverhalten prüfen |
| Visuelle Hervorhebung ohne zusätzliche Elemente | Prüfen, ob Spans überhaupt nötig sind |
Das folgende Beispiel verwendet separate Wrapper. Die Struktur zu erhalten bedeutet hier, die vorhandenen Elementobjekte und ihre Vorfahrenbeziehungen beizubehalten und darunter Spans hinzuzufügen. Textknoten werden an den Stellen geteilt, an denen die Auswahl beginnt oder endet.
Das synthetische Beispiel in Safari auf dem Mac öffnen
Öffnen Sie einen neuen Safari-Tab und geben Sie about:blank ein. Wählen Sie dann Safari → Einstellungen… → Erweitert und aktivieren Sie Funktionen für Webentwickler anzeigen. Öffnen Sie Entwickler → Web-Inspector einblenden oder drücken Sie Option–Befehl–I. Dies sind die von WebKit dokumentierten Schritte. Web Inspector aktivieren
Wählen Sie den Tab Konsole. Führen Sie das vollständige JavaScript-Programm unten in der Eingabeaufforderung aus. WebKit dokumentiert, dass die Konsoleneingabe JavaScript im ausgewählten Ausführungskontext der untersuchten Seite auswertet. Verwenden Sie für dieses Beispiel den Kontext der obersten Seite. Web Inspector-Oberfläche
Es sind weder eine Bibliothek noch ein Import, ein Build-Schritt oder vorhandene Dokumentinhalte erforderlich. Das Programm erstellt seinen eigenen Absatz, umschließt einen vorgegebenen Range, stellt eine vorwärts gerichtete Auswahl über dem umschlossenen Text wieder her und fügt eine Entfernen-Schaltfläche hinzu. Die unterstützte Eingabe ist bewusst eng gefasst: ein Absatz mit Text und gewöhnlichen Nachfahren vom Typ em, strong, span oder code, wobei sich beide Range-Grenzen innerhalb von Textknoten befinden.
(() => {
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);
})();
Verstehen, welcher Text umschlossen wird
Der Absatz lautet Alpha bright blue gamma delta.. Sein vorhandenes Element strong enthält bright und ein verschachteltes Element em, das blue enthält. Der Range beginnt bei Offset 3 in bright und endet bei Offset 6 in gamma delta.. Dies sind illustrative Eingabewerte, die im Programm definiert sind.
Die erwartete Konsolenausgabe enthält drei Fragmente:
[\"ght \", \"blue\", \" gamma\"]
Das erste Span-Element wird zum Kind des ursprünglichen Elements strong. Das zweite wird zum Kind des ursprünglichen Elements em. Das dritte wird zum Kind des Absatzes. Liest man ihren Text in Dokumentreihenfolge, ergibt sich die ausgewählte Zeichenfolge ght blue gamma. Das nicht ausgewählte Präfix bri und das Suffix delta. bleiben außerhalb der Wrapper.
Die Traversierung verwendet createTreeWalker() mit NodeFilter.SHOW_TEXT, um die Textknoten der Nachfahren zu durchlaufen. intersectsNode() ermittelt Kandidaten, und die Grenz-Offsets beschneiden den ersten und letzten Kandidaten. Fragmente der Länge null werden ausgeschlossen. Document: Methode createTreeWalker(), Range: Methode intersectsNode()
Das Programm erfasst alle Fragmentgrenzen, bevor es etwas verändert. Das ist wichtig, weil DOM-Ranges live sind: Das Teilen, Einfügen oder Entfernen von Knoten kann ihre Grenzpunkte aktualisieren. Auch ein geklonter Range wäre live; das Speichern der Knotenreferenzen und numerischen Offsets liefert die stabile Arbeitsliste, die hier verwendet wird. DOM-Standard: Ranges
Für jedes Fragment teilt das Programm zuerst an der Endgrenze und dann an der Startgrenze. splitText() belässt das Präfix im ursprünglichen Textknoten und gibt den restlichen Text als Geschwister zurück. So wird die ausgewählte Teilzeichenfolge isoliert, ohne ihre umschließenden Elemente neu zu erstellen. Text: Methode splitText()
Die vier Fälle der Fragmentplanung – Text über mehrere Elemente hinweg, eine Teilzeichenfolge innerhalb eines Knotens, ein zusammengefallener Range und Grenzen, die keine Zeichen auswählen – wurden deterministisch in Node.js geprüft. DOM-Änderungen im Browser und die Interaktion mit Safari wurden während der Vorbereitung nicht ausgeführt; das Programm enthält Assertions, mit denen sich diese Ergebnisse beim Ausführen prüfen lassen.
Die richtigen Struktur- und Lebenszyklusbedingungen erhalten
Die vorhandenen Elemente strong und em werden von diesem Programm nie entfernt, ersetzt oder geklont. Ihre Elementreferenzen bleiben verwendbar, und der an strong angehängte Listener bleibt an dasselbe Objekt gebunden. Das Beispiel prüft sowohl die Eltern-Kind-Beziehungen als auch das Auslösen eines synthetischen Ereignisses nach dem Umschließen.
Für die Identität von Textknoten gilt eine andere Bedingung. Durch das Teilen an den Grenzen entstehen neue Textknoten, und jedes ausgewählte Textfragment wird in ein neues Span-Element verschoben. Jede Anwendung, die Textknotenreferenzen hält, muss diese Änderung berücksichtigen. Die Methode erhält den Textinhalt und die ursprüngliche Elementhierarchie; sie erhält nicht jede ursprüngliche Textknotengrenze.
Das Hinzufügen von Spans ändert außerdem die unmittelbaren Eltern-Kind-Beziehungen für Text. Prüfen Sie vor der Übernahme dieser Umwandlung in ein vorhandenes Dokument den Code, der davon ausgeht, dass das erste Kind eines Elements ein Textknoten ist, CSS-Selektoren, die von direkten Kindern abhängen, und Ereignisbehandler, die das Ereignisziel untersuchen. Allein die Beibehaltung eines Elementobjekts garantiert nicht, dass sämtliche Annahmen der umgebenden Anwendung weiterhin gelten.
Das Beispiel stellt nach dem Umschließen ausdrücklich eine vorwärts gerichtete Auswahl wieder her. Es erhält weder die Richtung einer rückwärts gerichteten Auswahl noch bietet es eine Rückgängig-Transaktion für einen Editor. Behandeln Sie diese Anforderungen bei der Integration des Vorgangs in eine Bearbeitungsoberfläche separat. Die Wrapper-Funktion selbst gibt ihre Spans zurück, ohne einen bestimmten Browser-Auswahlstatus zu versprechen.
Ein Klick auf Spans entfernen hebt nur die während dieses Durchlaufs erstellten Spans auf und löscht die Auswahl. Die geteilten Textknoten bleiben getrennt. Diese Entfernen-Aktion stellt die ursprüngliche Elementhierarchie und den Text wieder her, aber nicht exakt die Identität der Textknoten. Fügen Sie keine automatische Normalisierung hinzu, wenn anderer Code von diesen Referenzen abhängt.
Das Beispiel anpassen, ohne seinen Umfang unbemerkt auszuweiten
Bei einer vom Benutzer gezogenen Auswahl rufen Sie ihren Range erst ab, nachdem Sie geprüft haben, dass window.getSelection() eine Auswahl zurückgegeben hat und rangeCount ungleich null ist. getRangeAt(0) gibt den ersten Range zurück; wird ein Index außerhalb der Anzahl der Ranges in der Auswahl angefordert, entsteht ein Fehler. Selection: Methode getRangeAt()
Übergeben Sie diesen Range nur dann an denselben Helfer, wenn sich beide Grenzcontainer innerhalb des vorgesehenen Absatzes um Textknoten handelt. Eine Auswahl, deren Grenzcontainer ein Element ist, wird vor jeder Änderung abgewiesen. Um solche Grenzen zu unterstützen, muss eine zusätzliche, ausdrücklich definierte Umwandlung in Positionen innerhalb der Nachfahrentexte erfolgen; einfach die Schutzbedingung zu entfernen, implementiert sie nicht.
Der Helfer weist außerdem nicht unterstützte Nachfahren und bereits markierte Wrapper zurück, bevor er Änderungen vornimmt. Legen Sie bei wiederholten oder überlappenden Annotationen zunächst fest, ob neue Spans verschachtelt, zusammengeführt oder frühere ersetzt werden sollen. Das Beispiel macht diese Entscheidung bewusst sichtbar, statt ohne Richtlinie weitere Wrapper anzusammeln.
Offsets werden in UTF-16-Codeeinheiten gemessen. Bei berechneten Positionen in Text mit Zeichen aus mehreren Codeeinheiten ist sicherzustellen, dass Grenzen nicht die vorgesehene Zeichenfolge teilen. Das synthetische Beispiel verwendet einfache lateinische Schrift, daher sind seine numerischen Offsets eindeutig. Text: Methode splitText()
Für diese Aufgabe ist die maßgebliche Invariante einfach: Der zusammengefügte Text der neuen Spans muss der ursprünglichen ausgewählten Zeichenfolge entsprechen, der vollständige Text des Absatzes muss unverändert bleiben und die vorhandenen Inline-Elemente müssen ihre Identität und ihre Vorfahrenbeziehungen bewahren. Prüfen Sie alle drei Punkte, wenn Sie das Beispiel anpassen; zusammen beschreiben sie, was erfolgreiche Erhaltung bedeutet.
Quellen
- DOM-Standard — WHATWG.
- Web Inspector aktivieren — WebKit.
- Web Inspector-Oberfläche — WebKit.
- Range: Methode surroundContents() — MDN Web Docs.
- Range: Methode extractContents() — MDN Web Docs.
- Document: Methode createTreeWalker() — MDN Web Docs.
- Range: Methode intersectsNode() — MDN Web Docs.
- Text: Methode splitText() — MDN Web Docs.
- Selection: Methode getRangeAt() — MDN Web Docs.