Aby owinąć zakres tekstu, zachowując istniejące elementy inline, owiń każdy zaznaczony fragment tekstu we własny element span. Ten przewodnik dla Safari na Macu korzysta ze sztucznego akapitu, aby zachować elementy wyróżnienia, kolejność tekstu i referencje do elementów.

Dlaczego jeden otaczający element span może nie zadziałać

Zakres w przeglądarce może zaczynać się wewnątrz jednego elementu inline i kończyć poza nim. Na przykład zaznaczenie może zaczynać się w połowie wyróżnionego tekstu, obejmować zagnieżdżony element i kończyć się w końcowym tekście akapitu. Takie zaznaczenie niekoniecznie odpowiada jednemu kompletnemu poddrzewu.

Range.surroundContents() nie obsługuje każdego takiego zaznaczenia. Zgłasza wyjątek, gdy zakres częściowo obejmuje węzeł inny niż tekstowy. Aktualny standard DOM określa dla tego przypadku błąd InvalidStateError. Tej metody można użyć dla zakresu zawierającego tekst i w pełni zaznaczone elementy, ale przekroczenie granicy elementu wymaga innego podejścia. Standard DOM, Range: metoda surroundContents()

Wyodrębnienie zakresu do jednego nowego elementu span to inna możliwa transformacja, ale obowiązują w niej odmienne zasady zachowywania struktury. extractContents() klonuje częściowo zaznaczone elementy nadrzędne. Jeśli zadanie wymaga zachowania oryginalnych elementów inline w całości i utrzymania ich tożsamości, takie sklonowane gałęzie się nie nadają. Range: metoda extractContents()

Przed zmianą dokumentu skorzystaj z tej wskazówki decyzyjnej:

Wymagany rezultat Odpowiednie podejście
Jeden element obejmujący tekst i w pełni zaznaczone elementy Rozważ surroundContents()
Oryginalne elementy inline pozostają w całości przy częściowym zaznaczeniu Owiń osobno zaznaczone fragmenty tekstu
Jeden element obejmujący częściowo zaznaczone granice elementów Zaakceptuj zmianę struktury i przeanalizuj działanie wyodrębniania/klonowania
Wizualne wyróżnienie bez dodawania elementów Zastanów się, czy zadanie w ogóle wymaga elementów span

Poniższy przykład korzysta z osobnych elementów span. Zachowanie struktury oznacza tu pozostawienie istniejących obiektów elementów i ich relacji nadrzędności, przy jednoczesnym dodaniu elementów span wewnątrz nich. Węzły tekstowe zostaną podzielone w miejscach początku lub końca zaznaczenia.

Otwórz sztuczny przykład w Safari na Macu

Otwórz nową kartę Safari i wpisz about:blank. Następnie wybierz Safari → Ustawienia… → Zaawansowane i włącz opcję Pokaż funkcje dla deweloperów stron internetowych. Otwórz Deweloper → Pokaż inspektor WWW albo naciśnij Option–Command–I. Są to kroki opisane w dokumentacji WebKit. Włączanie inspektora WWW

Wybierz kartę Konsola. Uruchom cały poniższy program JavaScript w jej polu wprowadzania. Dokumentacja WebKit podaje, że pole konsoli wykonuje JavaScript w wybranym kontekście wykonywania inspekcjonowanej strony. W tym przykładzie użyj kontekstu strony głównej. Interfejs inspektora WWW

Nie jest potrzebna żadna biblioteka, instrukcja importu, etap kompilacji ani istniejąca zawartość dokumentu. Program tworzy własny akapit, owija z góry określony zakres, przywraca zaznaczenie w kierunku do przodu obejmujące owinięty tekst i dodaje przycisk usuwania. Obsługiwane dane wejściowe są celowo ograniczone: jeden akapit zawierający tekst oraz zwykłe elementy em, strong, span lub code jako elementy potomne, przy czym obie granice zakresu muszą znajdować się wewnątrz węzłów tekstowych.

(() => {
  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);
})();

Sprawdź, który tekst zostanie owinięty

Akapit zawiera tekst Alpha bright blue gamma delta.. Istniejący element strong zawiera bright oraz zagnieżdżony element em z tekstem blue. Zakres zaczyna się na przesunięciu 3 w tekście bright , a kończy na przesunięciu 6 w tekście gamma delta.. Są to przykładowe dane wejściowe zdefiniowane przez program.

Oczekiwany wynik w konsoli zawiera trzy fragmenty:

[\"ght \", \"blue\", \" gamma\"]

Pierwszy element span staje się elementem potomnym oryginalnego elementu strong. Drugi staje się elementem potomnym oryginalnego elementu em. Trzeci staje się elementem potomnym akapitu. Odczytanie ich tekstu w kolejności dokumentu daje zaznaczony ciąg ght blue gamma. Niezaznaczony prefiks bri i sufiks delta. pozostają poza elementami span.

W celu przejścia po potomnych węzłach tekstowych używana jest metoda createTreeWalker() z NodeFilter.SHOW_TEXT. intersectsNode() identyfikuje kandydatów, a przesunięcia granic przycinają pierwszy i ostatni kandydat. Fragmenty o zerowej długości są pomijane. Document: metoda createTreeWalker(), Range: metoda intersectsNode()

Program zapisuje wszystkie granice fragmentów przed wprowadzeniem jakichkolwiek zmian. To ważne, ponieważ zakresy DOM są żywe: dzielenie, wstawianie lub usuwanie węzłów może zmienić ich punkty graniczne. Sklonowany Range również byłby żywy; zapisanie referencji do węzłów i wartości liczbowych przesunięć zapewnia stabilną listę operacji używaną w tym przykładzie. Standard DOM: zakresy

Dla każdego fragmentu program najpierw dzieli węzeł na końcowej granicy, a następnie na początkowej. splitText() pozostawia prefiks w oryginalnym węźle tekstowym i zwraca pozostały tekst jako węzeł sąsiedni. Dzięki temu zaznaczony podciąg zostaje wyodrębniony bez ponownego tworzenia elementów, które go obejmują. Text: metoda splitText()

Cztery przypadki planowania fragmentów — tekst przecinający granicę elementu, podciąg w jednym węźle, zwinięty zakres oraz granice, które nie zaznaczają żadnych znaków — sprawdzono deterministycznie w Node.js. Podczas przygotowywania nie wykonywano mutacji DOM w przeglądarce ani interakcji z Safari; program zawiera asercje do sprawdzenia tych wyników po uruchomieniu.

Zachowaj właściwą strukturę i wymagania dotyczące cyklu życia

Ten program nigdy nie usuwa, nie zastępuje ani nie klonuje istniejących elementów strong i em. Ich referencje pozostają użyteczne, a nasłuchiwacz przypisany do strong pozostaje przypisany do tego samego obiektu. Przykład sprawdza zarówno relacje między elementami nadrzędnymi, jak i wysłanie zdarzenia syntetycznego po owinięciu tekstu.

Tożsamość węzłów tekstowych podlega innym zasadom. Podział granic tworzy nowe węzły tekstowe, a każdy zaznaczony fragment tekstu zostaje przeniesiony do nowego elementu span. Aplikacja przechowująca referencje do węzłów tekstowych musi uwzględnić tę zmianę. Metoda zachowuje treść tekstową i oryginalną hierarchię elementów, ale nie zachowuje wszystkich pierwotnych granic węzłów tekstowych.

Dodanie elementów span zmienia też bezpośrednie relacje nadrzędności tekstu. Przed zastosowaniem tej transformacji w istniejącym dokumencie sprawdź kod, który oczekuje, że pierwszy element potomny danego elementu będzie węzłem tekstowym, selektory CSS zależne od bezpośrednich elementów potomnych oraz procedury obsługi zdarzeń sprawdzające cel zdarzenia. Samo zachowanie obiektu elementu nie gwarantuje, że wszystkie założenia reszty aplikacji nadal będą spełnione.

Przykład jawnie odtwarza zaznaczenie skierowane do przodu po owinięciu tekstu. Nie zachowuje kierunku zaznaczenia wstecznego ani nie zapewnia transakcji cofania w edytorze. Podczas integrowania tej operacji z interfejsem edycji należy traktować te kwestie jako osobne wymagania. Sama funkcja opakowująca zwraca utworzone elementy span, nie gwarantując określonego stanu zaznaczenia w przeglądarce.

Kliknięcie Usuń elementy span rozpakowuje tylko elementy span utworzone podczas tego uruchomienia i czyści zaznaczenie. Podzielone węzły tekstowe pozostają osobnymi węzłami. Ta operacja usuwania przywraca pierwotną hierarchię elementów i tekst, ale nie odwraca dokładnie zmian w tożsamości węzłów tekstowych. Nie dodawaj automatycznej normalizacji, jeśli inny kod zależy od tych referencji.

Dostosuj przykład bez niejawnego rozszerzania jego zakresu

Aby obsłużyć zaznaczenie przeciągnięte przez użytkownika, pobierz jego zakres dopiero po sprawdzeniu, czy window.getSelection() zwróciło zaznaczenie i czy rangeCount jest różne od zera. getRangeAt(0) zwraca pierwszy zakres; zażądanie indeksu spoza zakresu liczby zakresów zaznaczenia powoduje błąd. Selection: metoda getRangeAt()

Przekaż ten zakres do tej samej funkcji pomocniczej tylko wtedy, gdy oba kontenery graniczne są węzłami tekstowymi wewnątrz docelowego akapitu. Zaznaczenie, którego kontener graniczny jest elementem, zostaje odrzucone przed wprowadzeniem zmian. Obsługa takich granic wymaga dodatkowej, jawnie zdefiniowanej konwersji na pozycje w węzłach tekstowych potomnych; samo usunięcie zabezpieczenia jej nie zapewnia.

Funkcja pomocnicza odrzuca też nieobsługiwane elementy potomne i istniejące oznaczone elementy opakowujące, zanim wprowadzi zmiany. W przypadku powtarzających się lub nakładających się adnotacji najpierw określ, czy nowe elementy span mają być zagnieżdżane, scalane czy zastępować wcześniejsze. Przykład celowo wymusza podjęcie tej decyzji, zamiast gromadzić elementy opakowujące bez żadnych zasad.

Przesunięcia są mierzone w jednostkach kodowych UTF-16. W przypadku obliczonych pozycji w tekście zawierającym znaki wielojednostkowe upewnij się, że granice nie dzielą zamierzonego ciągu znaków. W sztucznym przykładzie użyto prostego tekstu łacińskiego, więc przesunięcia liczbowe są jednoznaczne. Text: metoda splitText()

Przy tym zadaniu przydatny niezmiennik jest prosty: konkatenacja tekstu nowych elementów span musi być równa pierwotnemu zaznaczonemu ciągowi, pełny tekst akapitu musi pozostać bez zmian, a istniejące elementy inline muszą zachować swoją tożsamość i relacje nadrzędności. Podczas dostosowywania przykładu sprawdź wszystkie trzy warunki; razem określają, co oznacza pomyślne zachowanie struktury.

Źródła