← JavaScript/Selection и Range#152 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

Selection и Range

Представь: ты делаешь текстовый редактор или инструмент аннотирования документов. Пользователь выделяет фрагмент текста — нужно прочитать его, запомнить позицию, добавить подсветку. Или нужна кнопка «копировать код» в документации — одно нажатие, и код в буфере. Для всего этого — Selection и Range API.

Что решает этот механизм

Range описывает произвольный фрагмент документа с началом и концом. Selection — это то, что пользователь выделил мышью. Clipboard API позволяет работать с буфером обмена. Вместе они дают полный контроль над текстовым выделением в браузере.

На основе предыдущих уроков

  • строки — работа с индексами и подстроками — тот же принцип, что в Range
  • события — selectionchange уведомляет об изменении выделения
  • Range — фрагмент документа

    Range описывает фрагмент документа: у него есть начало (start) и конец (end). Range можно создать, изменить и использовать для операций с текстом.

    // Создать Range
    const range = document.createRange()
    
    // Установить границы: (узел, смещение)
    range.setStart(textNode, 5)   // начало: 5-й символ текстового узла
    range.setEnd(textNode, 15)    // конец: 15-й символ
    
    // Полезные свойства
    console.log(range.collapsed)    // true если start === end (нет выделения)
    console.log(range.toString())   // текст внутри Range
    
    // Выбрать весь содержимое элемента
    range.selectNodeContents(element)
    
    // Получить размеры и позицию Range в viewport
    const rect = range.getBoundingClientRect()
    console.log(rect.top, rect.left, rect.width, rect.height)

    window.getSelection() — текущее выделение

    const selection = window.getSelection()
    
    // Текст выделенного фрагмента
    console.log(selection.toString())       // 'выделенный текст'
    
    // Количество диапазонов (обычно 0 или 1)
    console.log(selection.rangeCount)       // 0 или 1
    
    // Получить первый Range из выделения
    if (selection.rangeCount > 0) {
      const range = selection.getRangeAt(0)
      console.log(range.toString())
    }
    
    // Снять выделение программно
    selection.removeAllRanges()
    
    // Добавить Range к выделению
    selection.addRange(range)

    Программное выделение текста

    function selectText(element) {
      const selection = window.getSelection()
      const range = document.createRange()
    
      range.selectNodeContents(element)  // выделить весь текст элемента
      selection.removeAllRanges()        // сбросить старое выделение
      selection.addRange(range)          // применить новое
    }
    
    // Выделить часть текста
    function selectRange(element, start, end) {
      const selection = window.getSelection()
      const range = document.createRange()
    
      range.setStart(element.firstChild, start)
      range.setEnd(element.firstChild, end)
    
      selection.removeAllRanges()
      selection.addRange(range)
    }

    Clipboard API — работа с буфером обмена

    // Копировать текст в буфер (возвращает Promise)
    async function copyText(text) {
      try {
        await navigator.clipboard.writeText(text)
        console.log('Скопировано!')
      } catch (err) {
        console.error('Ошибка копирования:', err)
      }
    }
    
    // Прочитать из буфера (требует разрешения пользователя)
    async function pasteText() {
      const text = await navigator.clipboard.readText()
      return text
    }
    
    // Старый способ (без async): работает в большинстве браузеров
    document.execCommand('copy')  // копирует текущее выделение

    Реальный пример: кнопка «копировать код»

    function addCopyButton(codeBlock) {
      const btn = document.createElement('button')
      btn.textContent = 'Копировать'
    
      btn.addEventListener('click', async () => {
        const code = codeBlock.textContent
        await navigator.clipboard.writeText(code)
    
        btn.textContent = 'Скопировано!'
        setTimeout(() => { btn.textContent = 'Копировать' }, 2000)
      })
    
      codeBlock.parentElement.appendChild(btn)
    }

    Симуляция Range через строки

    Поскольку в sandbox нет реального DOM, мы симулируем Range через строковую логику — она полностью отражает принцип работы Range:

    // Range в реальном DOM: (node, charOffset) → (node, charOffset)
    // В строковой симуляции: (string, startIndex) → (string, endIndex)
    
    function createStringRange(text, start, end) {
      return {
        text,
        start,
        end,
        get collapsed() { return this.start === this.end },
        toString()      { return this.text.slice(this.start, this.end) },
        get length()    { return this.end - this.start },
      }
    }

    Типичные ошибки

    1. Читать selection.toString() до проверки rangeCount

    // ПЛОХО — может вернуть '' если ничего не выделено, непонятно почему
    const text = window.getSelection().toString()
    if (text) { ... }  // работает, но не объясняет почему пусто
    
    // ХОРОШО — явная проверка
    const selection = window.getSelection()
    if (selection.rangeCount === 0) return  // нет выделения
    const text = selection.toString()
    if (!text) return  // collapsed (курсор без выделения)

    2. Не вызывать selection.removeAllRanges() перед addRange

    // ПЛОХО — предыдущее выделение может остаться (в Firefox допускается несколько Range)
    function selectText(element) {
      const selection = window.getSelection()
      const range = document.createRange()
      range.selectNodeContents(element)
      selection.addRange(range)  // добавляет к существующему!
    }
    
    // ХОРОШО — сначала сбросить, потом установить
    function selectText(element) {
      const selection = window.getSelection()
      selection.removeAllRanges()
      const range = document.createRange()
      range.selectNodeContents(element)
      selection.addRange(range)
    }

    3. Использовать document.execCommand('copy') — устаревший API

    // ПЛОХО — execCommand устарел и будет удалён
    document.execCommand('copy')  // Deprecated!
    
    // ХОРОШО — используй Clipboard API
    async function copyText(text) {
      await navigator.clipboard.writeText(text)
      // требует HTTPS и разрешения пользователя
    }

    В реальных проектах

  • Кнопка «копировать код»: GitHub, StackOverflow, документация — navigator.clipboard.writeText(codeBlock.textContent)
  • Текстовые редакторы (ProseMirror, Tiptap): весь движок построен вокруг Range — выделение, вставка, форматирование
  • Поиск по странице: браузерный Ctrl+F использует Range для подсветки совпадений, ты можешь реализовать то же через window.find() или Range API
  • Примеры

    Симуляция Range и Selection через строки: выделение, копирование, форматирование фрагментов текста

    // Симуляция Range/Selection API через строки
    // В браузере Range работает с текстовыми узлами DOM
    
    // --- StringRange: имитация Range ---
    function createRange(text, start, end) {
      if (start > end) [start, end] = [end, start]
      return {
        text,
        start: Math.max(0, start),
        end:   Math.min(text.length, end),
        get collapsed() { return this.start === this.end },
        toString()      { return this.text.slice(this.start, this.end) },
        get length()    { return this.end - this.start },
        cloneRange()    { return createRange(this.text, this.start, this.end) },
      }
    }
    
    // --- StringSelection: имитация window.getSelection() ---
    function createSelection() {
      let ranges = []
      return {
        get rangeCount() { return ranges.length },
        getRangeAt(i)   { return ranges[i] },
        addRange(r)     { ranges = [r] },
        removeAllRanges() { ranges = [] },
        toString()      { return ranges.map(r => r.toString()).join('') },
      }
    }
    
    // --- Демо 1: Работа с Range ---
    console.log('=== Range: выделение фрагментов ===')
    
    const articleText = 'JavaScript — мультипарадигменный язык программирования'
    
    const range1 = createRange(articleText, 0, 10)
    const range2 = createRange(articleText, 15, 34)
    const rangeAll = createRange(articleText, 0, articleText.length)
    
    console.log('range1:', range1.toString())    // 'JavaScript'
    console.log('range2:', range2.toString())    // 'мультипарадигменный'
    console.log('rangeAll:', rangeAll.toString()) // вся строка
    console.log('collapsed:', createRange(articleText, 5, 5).collapsed) // true
    console.log('length:', range2.length)        // 19
    
    // --- Демо 2: Selection ---
    console.log('\n=== Selection: текущее выделение ===')
    
    const selection = createSelection()
    console.log('rangeCount до:', selection.rangeCount)  // 0
    console.log('toString до:', selection.toString())    // ''
    
    selection.addRange(range1)
    console.log('rangeCount после:', selection.rangeCount) // 1
    console.log('Выделено:', selection.toString())          // 'JavaScript'
    
    selection.removeAllRanges()
    selection.addRange(range2)
    console.log('Новое выделение:', selection.toString())   // 'мультипарадигменный'
    
    // --- Демо 3: Имитация Clipboard API ---
    console.log('\n=== Clipboard API симуляция ===')
    
    let mockClipboard = ''
    
    const clipboardAPI = {
      async writeText(text) {
        mockClipboard = text
        return Promise.resolve()
      },
      async readText() {
        return Promise.resolve(mockClipboard)
      },
    }
    
    async function copySelectedText(selection) {
      const text = selection.toString()
      if (!text) { console.log('Ничего не выделено'); return }
      await clipboardAPI.writeText(text)
      console.log(`Скопировано в буфер: "${text}"`)
    }
    
    selection.removeAllRanges()
    selection.addRange(createRange(articleText, 0, 10))
    copySelectedText(selection).then(() => clipboardAPI.readText()).then(t => console.log('Из буфера:', t))
    
    // --- Демо 4: Поиск и выделение ---
    console.log('\n=== Поиск текста и создание Range ===')
    
    function findInText(text, query) {
      const results = []
      let idx = text.toLowerCase().indexOf(query.toLowerCase())
      while (idx !== -1) {
        results.push(createRange(text, idx, idx + query.length))
        idx = text.toLowerCase().indexOf(query.toLowerCase(), idx + 1)
      }
      return results
    }
    
    const content = 'Найти все вхождения слова "найти" в тексте. Найти и выделить.'
    const found = findInText(content, 'найти')
    console.log(`Найдено вхождений: ${found.length}`)
    found.forEach((r, i) => {
      console.log(`  [${i + 1}] позиция ${r.start}-${r.end}: "${r.toString()}"`)
    })

    Selection и Range

    Представь: ты делаешь текстовый редактор или инструмент аннотирования документов. Пользователь выделяет фрагмент текста — нужно прочитать его, запомнить позицию, добавить подсветку. Или нужна кнопка «копировать код» в документации — одно нажатие, и код в буфере. Для всего этого — Selection и Range API.

    Что решает этот механизм

    Range описывает произвольный фрагмент документа с началом и концом. Selection — это то, что пользователь выделил мышью. Clipboard API позволяет работать с буфером обмена. Вместе они дают полный контроль над текстовым выделением в браузере.

    На основе предыдущих уроков

  • строки — работа с индексами и подстроками — тот же принцип, что в Range
  • события — selectionchange уведомляет об изменении выделения
  • Range — фрагмент документа

    Range описывает фрагмент документа: у него есть начало (start) и конец (end). Range можно создать, изменить и использовать для операций с текстом.

    // Создать Range
    const range = document.createRange()
    
    // Установить границы: (узел, смещение)
    range.setStart(textNode, 5)   // начало: 5-й символ текстового узла
    range.setEnd(textNode, 15)    // конец: 15-й символ
    
    // Полезные свойства
    console.log(range.collapsed)    // true если start === end (нет выделения)
    console.log(range.toString())   // текст внутри Range
    
    // Выбрать весь содержимое элемента
    range.selectNodeContents(element)
    
    // Получить размеры и позицию Range в viewport
    const rect = range.getBoundingClientRect()
    console.log(rect.top, rect.left, rect.width, rect.height)

    window.getSelection() — текущее выделение

    const selection = window.getSelection()
    
    // Текст выделенного фрагмента
    console.log(selection.toString())       // 'выделенный текст'
    
    // Количество диапазонов (обычно 0 или 1)
    console.log(selection.rangeCount)       // 0 или 1
    
    // Получить первый Range из выделения
    if (selection.rangeCount > 0) {
      const range = selection.getRangeAt(0)
      console.log(range.toString())
    }
    
    // Снять выделение программно
    selection.removeAllRanges()
    
    // Добавить Range к выделению
    selection.addRange(range)

    Программное выделение текста

    function selectText(element) {
      const selection = window.getSelection()
      const range = document.createRange()
    
      range.selectNodeContents(element)  // выделить весь текст элемента
      selection.removeAllRanges()        // сбросить старое выделение
      selection.addRange(range)          // применить новое
    }
    
    // Выделить часть текста
    function selectRange(element, start, end) {
      const selection = window.getSelection()
      const range = document.createRange()
    
      range.setStart(element.firstChild, start)
      range.setEnd(element.firstChild, end)
    
      selection.removeAllRanges()
      selection.addRange(range)
    }

    Clipboard API — работа с буфером обмена

    // Копировать текст в буфер (возвращает Promise)
    async function copyText(text) {
      try {
        await navigator.clipboard.writeText(text)
        console.log('Скопировано!')
      } catch (err) {
        console.error('Ошибка копирования:', err)
      }
    }
    
    // Прочитать из буфера (требует разрешения пользователя)
    async function pasteText() {
      const text = await navigator.clipboard.readText()
      return text
    }
    
    // Старый способ (без async): работает в большинстве браузеров
    document.execCommand('copy')  // копирует текущее выделение

    Реальный пример: кнопка «копировать код»

    function addCopyButton(codeBlock) {
      const btn = document.createElement('button')
      btn.textContent = 'Копировать'
    
      btn.addEventListener('click', async () => {
        const code = codeBlock.textContent
        await navigator.clipboard.writeText(code)
    
        btn.textContent = 'Скопировано!'
        setTimeout(() => { btn.textContent = 'Копировать' }, 2000)
      })
    
      codeBlock.parentElement.appendChild(btn)
    }

    Симуляция Range через строки

    Поскольку в sandbox нет реального DOM, мы симулируем Range через строковую логику — она полностью отражает принцип работы Range:

    // Range в реальном DOM: (node, charOffset) → (node, charOffset)
    // В строковой симуляции: (string, startIndex) → (string, endIndex)
    
    function createStringRange(text, start, end) {
      return {
        text,
        start,
        end,
        get collapsed() { return this.start === this.end },
        toString()      { return this.text.slice(this.start, this.end) },
        get length()    { return this.end - this.start },
      }
    }

    Типичные ошибки

    1. Читать selection.toString() до проверки rangeCount

    // ПЛОХО — может вернуть '' если ничего не выделено, непонятно почему
    const text = window.getSelection().toString()
    if (text) { ... }  // работает, но не объясняет почему пусто
    
    // ХОРОШО — явная проверка
    const selection = window.getSelection()
    if (selection.rangeCount === 0) return  // нет выделения
    const text = selection.toString()
    if (!text) return  // collapsed (курсор без выделения)

    2. Не вызывать selection.removeAllRanges() перед addRange

    // ПЛОХО — предыдущее выделение может остаться (в Firefox допускается несколько Range)
    function selectText(element) {
      const selection = window.getSelection()
      const range = document.createRange()
      range.selectNodeContents(element)
      selection.addRange(range)  // добавляет к существующему!
    }
    
    // ХОРОШО — сначала сбросить, потом установить
    function selectText(element) {
      const selection = window.getSelection()
      selection.removeAllRanges()
      const range = document.createRange()
      range.selectNodeContents(element)
      selection.addRange(range)
    }

    3. Использовать document.execCommand('copy') — устаревший API

    // ПЛОХО — execCommand устарел и будет удалён
    document.execCommand('copy')  // Deprecated!
    
    // ХОРОШО — используй Clipboard API
    async function copyText(text) {
      await navigator.clipboard.writeText(text)
      // требует HTTPS и разрешения пользователя
    }

    В реальных проектах

  • Кнопка «копировать код»: GitHub, StackOverflow, документация — navigator.clipboard.writeText(codeBlock.textContent)
  • Текстовые редакторы (ProseMirror, Tiptap): весь движок построен вокруг Range — выделение, вставка, форматирование
  • Поиск по странице: браузерный Ctrl+F использует Range для подсветки совпадений, ты можешь реализовать то же через window.find() или Range API
  • Примеры

    Симуляция Range и Selection через строки: выделение, копирование, форматирование фрагментов текста

    // Симуляция Range/Selection API через строки
    // В браузере Range работает с текстовыми узлами DOM
    
    // --- StringRange: имитация Range ---
    function createRange(text, start, end) {
      if (start > end) [start, end] = [end, start]
      return {
        text,
        start: Math.max(0, start),
        end:   Math.min(text.length, end),
        get collapsed() { return this.start === this.end },
        toString()      { return this.text.slice(this.start, this.end) },
        get length()    { return this.end - this.start },
        cloneRange()    { return createRange(this.text, this.start, this.end) },
      }
    }
    
    // --- StringSelection: имитация window.getSelection() ---
    function createSelection() {
      let ranges = []
      return {
        get rangeCount() { return ranges.length },
        getRangeAt(i)   { return ranges[i] },
        addRange(r)     { ranges = [r] },
        removeAllRanges() { ranges = [] },
        toString()      { return ranges.map(r => r.toString()).join('') },
      }
    }
    
    // --- Демо 1: Работа с Range ---
    console.log('=== Range: выделение фрагментов ===')
    
    const articleText = 'JavaScript — мультипарадигменный язык программирования'
    
    const range1 = createRange(articleText, 0, 10)
    const range2 = createRange(articleText, 15, 34)
    const rangeAll = createRange(articleText, 0, articleText.length)
    
    console.log('range1:', range1.toString())    // 'JavaScript'
    console.log('range2:', range2.toString())    // 'мультипарадигменный'
    console.log('rangeAll:', rangeAll.toString()) // вся строка
    console.log('collapsed:', createRange(articleText, 5, 5).collapsed) // true
    console.log('length:', range2.length)        // 19
    
    // --- Демо 2: Selection ---
    console.log('\n=== Selection: текущее выделение ===')
    
    const selection = createSelection()
    console.log('rangeCount до:', selection.rangeCount)  // 0
    console.log('toString до:', selection.toString())    // ''
    
    selection.addRange(range1)
    console.log('rangeCount после:', selection.rangeCount) // 1
    console.log('Выделено:', selection.toString())          // 'JavaScript'
    
    selection.removeAllRanges()
    selection.addRange(range2)
    console.log('Новое выделение:', selection.toString())   // 'мультипарадигменный'
    
    // --- Демо 3: Имитация Clipboard API ---
    console.log('\n=== Clipboard API симуляция ===')
    
    let mockClipboard = ''
    
    const clipboardAPI = {
      async writeText(text) {
        mockClipboard = text
        return Promise.resolve()
      },
      async readText() {
        return Promise.resolve(mockClipboard)
      },
    }
    
    async function copySelectedText(selection) {
      const text = selection.toString()
      if (!text) { console.log('Ничего не выделено'); return }
      await clipboardAPI.writeText(text)
      console.log(`Скопировано в буфер: "${text}"`)
    }
    
    selection.removeAllRanges()
    selection.addRange(createRange(articleText, 0, 10))
    copySelectedText(selection).then(() => clipboardAPI.readText()).then(t => console.log('Из буфера:', t))
    
    // --- Демо 4: Поиск и выделение ---
    console.log('\n=== Поиск текста и создание Range ===')
    
    function findInText(text, query) {
      const results = []
      let idx = text.toLowerCase().indexOf(query.toLowerCase())
      while (idx !== -1) {
        results.push(createRange(text, idx, idx + query.length))
        idx = text.toLowerCase().indexOf(query.toLowerCase(), idx + 1)
      }
      return results
    }
    
    const content = 'Найти все вхождения слова "найти" в тексте. Найти и выделить.'
    const found = findInText(content, 'найти')
    console.log(`Найдено вхождений: ${found.length}`)
    found.forEach((r, i) => {
      console.log(`  [${i + 1}] позиция ${r.start}-${r.end}: "${r.toString()}"`)
    })

    Задание

    Реализуй функцию highlightMatches(text, query) которая находит все вхождения query в text и возвращает массив объектов { before, match, after } — фрагменты текста вокруг каждого совпадения. Регистр игнорировать. Также реализуй surround(text, start, end, tag) которая оборачивает фрагмент текста от start до end в HTML-тег, возвращая строку.

    Подсказка

    highlightMatches: before = text.slice(0, idx), match = text.slice(idx, idx + query.length), after = text.slice(idx + query.length), следующий поиск от idx + 1. surround: before = text.slice(0, start), content = text.slice(start, end), after = text.slice(end).

    Загружаем среду выполнения...
    Загружаем AI-помощника...