Оригинал: How to Create a Browser Extension, автор Lars Kölker

Спорим, вы прямо сейчас используете какие-то браузерные расширения. Некоторые из них очень полезны и популярны, например, блокировщики рекламы, менеджеры паролей или средства для просмотра PDF. Функциональность таких расширений (аддонов) не ограничена — вы можете сделать с их помощью что угодно, но сегодня мы будем разбираться, как делать непосредственно их. Другими словами, мы собираемся создать собственное расширение.

Что будем делать?

На сайте Reddit есть аккаунты, которые на добровольной основе занимаются транскрибацией (переводом в текст) изображений, аудио- и видео-контента. Это здорово улучшает доступность для людей с ограниченными возможностями.

Однако эти комментарии находятся в общей ленте и сразу не видны. Мы собираемся решить эту проблему.

Мы создадим расширение Transcribers of Reddit (TOR), которое будет искать и перемещать комментарии с расшифровкой на верх общего списка, а также добавлять aria-атрибуты для скрин-ридеров. Мы даже пойдем чуть дальше и добавим возможность изменить цвет фона и установить рамку для этих комментариев, чтобы улучшить визуальный контраст.

Основная идея этого проекта — показать, как создавать браузерное расширение, а не реализовать полнофункциональный аддон, так что вы всегда можете что-то доработать или переделать на свой вкус.

Мы начнем с расширения для Chromium-браузеров (Google Chrome, Microsoft Edge, Brave и т. д.) В следующих статьях, возможно, мы его портируем на Firefox и Safari, который с недавних пор тоже поддерживает веб-расширения и в MacOS и в iOS.

Репозиторий проекта

Рабочая директория

Прежде чем начинать, нужно создать произвести все необходимые подготовительные действия.

Создадим новую папку для проекта — можно назвать ее transcribers-of-reddit. А внутри нее папку src для исходного кода.

Манифест

Точка входа — это файл, который содержит всю важную информацию о расширении (название, описание и т. д.), а также определяет необходимые разрешения и выполняемые скрипты.

Нашей точкой входа будет файл manifest.json в папке src. Добавим в него для начала три свойства:

{
  "manifest_version": 3,
  "name": "Transcribers of Reddit",
  "version": "1.0"
}
  • Поле manifest_version — это версия используемой спецификации, она определяет, какие API доступны приложению. На данный момент самая свежая версия — mv3.
  • Поле name — название расширения, которое будет отображаться, например, в Chrome Web Store.
  • Поле version — это версия самого расширения. Она указывается в виде строки, которая должна содержать только цифры и точки. Можно указывать мажорные, минорные версии, а также патчи (например, 1.3.5).

Больше информации

В файл manifest.json можно добавить очень много различных параметров, описывающих наше приложение. Например, его описание (поле description), которое объясняет, что приложение умеет делать.

Добавим также иконки приложения в разных разрешениях и урл домашней страницы проекта.

{
  "description": "Reddit made accessible for disabled users.",
  "icons": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  },
  "homepage_url": "https://lars.koelker.dev/extensions/tor/"
}
  • Поле description не должно быть длиннее 132 символов.
  • Иконки, указанные в поле icons, используются во множестве мест. Предпочтительно загружать файлы png-формата. Иконки для нашего проекта вы можете загрузить прямо из его репозитория.
  • В поле homepage_url можно указать любую страницу, которая содержит информацию о приложении. Она будет отображаться в графе Open extention website на странице управления расширением.
Скриншот страницы управления расширением

Разрешения

Большой плюс расширений — это возможность взаимодействовать напрямую с браузером. Но для этого нужно явно указать, какие API мы собираемся использовать, чтобы получить разрешения. Это нужно сделать в секции permissions файла manifest.json:


{
  "manifest_version": 3,
  "name": "Transcribers of Reddit",
  "version": "1.0",
  "description": "Reddit made accessible for disabled users.",
  "icons": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  },
  "homepage_url": "https://lars.koelker.dev/extensions/tor/",

  "permissions": [
    "storage",
    "webNavigation"
  ]
}

Прежде всего, мы хотим, чтобы наше расширение сохраняло пользовательские настройки, поэтому мы должны разрешить браузеру сохранять их — разрешение storage. Например, если пользователь хочет видеть у комментариев красную рамку, мы сохраним этот выбор и в следующий раз применим тот же стиль.

Кроме того, мы должны отслеживать пользовательскую навигацию по текущей странице — разрешение webNavigation. Дело в том, что Reddit — это одностраничное приложение (SPA), которое не вызывает событий перезагрузки страниц. Нам нужно поймать взаимодействие и загрузку комментариев на страницу.

В зависимости от указанных разрешений браузер может выводить сообщение пользователю для их подтверждения. Вот список таких разрешений.

Управление переводами

Браузерным расширениями доступен встроенный API интернационализации (i18n). Он позволяет управлять переводами для множества языков (полный список). Чтобы воспользоваться этой возможностью, нужно создать сами переводы, а также указать дополнительную информацию в manifest.json.

Определяем, какой язык будет использоваться по умолчанию (английский):

"default_locale": "en"

Если вдруг в браузере будет включен язык, который не поддерживается нашим расширением, оно просто будет использовать базовый язык.

Сами переводы определяются в папке _locales. Для каждого создается отдельная папка, внутри которой находится файл messages.json:

src 
 └─ _locales
     └─ en
        └─ messages.json
     └─ fr
        └─ messages.json

Перевод каждой фразы оформляется по специальной схеме:

  1. Определяется ключ перевода (translation key), или идентификатор фразы, по которому мы будем к ней обращаться.
  2. Для каждого ключа указываются свойства: message, description и placeholders.
  • message — это сама переводимая фраза.
  • description — опциональное описание перевода, если необходимо. Предназначено, скорее, для самого переводчика, в расширении не используется.
  • placeholders — динамический контент внутри фразы, например, слова, которые могут изменяться.

Вот пример перевода одной фразы:

{
  "userGreeting": { // Translation key ("id")
    "message": "Good $daytime$, $user$!" // сама фраза
    "description": "User Greeting", // опциональное описание
    "placeholders": { // динамический контент внутри фразы
      "daytime": { // имя плейсхолдера, используется в самой фразе
        "content": "$1",
        "example": "morning" // необязательный пример значения
      },
      "user": { 
        "content": "$1",
        "example": "Lars"
      }
    }
  }
}

Схема использования плейсхолдеров может показаться довольно сложной.

Для начала мы должны определить, какие части сообщения (фразы) являются динамическими. Их может и не быть. Если такие фрагменты нашлись, нужно обернуть их символами $.

Каждый такой фрагмент нужно отдельно описать в поле placeholders. Это немного неинтуитивно, но Chrome хочет знать, какое значение следует вставить вместо плейсхолдера. Так как мы хотим использовать динамические (неопределенные) значения, нужно использовать специальную конструкцию $1, которая является ссылкой на вставленный контент.

Свойство example необязательно, оно может использоваться как подсказка для переводчиков, но в самом расширении не применяется.

Давайте добавим переводы в наш проект. Это оригинальный английский файл, а вы можете добавить столько языков, сколько захотите. Например, для русского нужно создать папку ru, а для немецкого de.

{
  "name": {
    "message": "Transcribers of Reddit"
  },
  "description": {
    "message": "Accessible image descriptions for subreddits."
  },
  "popupManageSettings": {
    "message": "Manage settings"
  },
  "optionsPageTitle": {
    "message": "Settings"
  },
  "sectionGeneral": {
    "message": "General settings"
  },
  "settingBorder": {
    "message": "Show comment border"
  },
  "settingBackground": {
    "message": "Show comment background"
  }
}

Возможно, вы хотите знать, почему мы не указали API интернационализации в списке разрешений.

Chrome довольно непоследователен в этом отношении — вам не нужно указывать все необходимые разрешения, например, chrome.i18n. Некоторые другие разрешения требуется указывать, но пользователь, который устанавливает ваше расширение о них ничего не знает. Некоторые разрешения вообще являются «гибридными», вы можете использовать некоторые их функции без объявления, но другие обязательно нужно указать в манифесте. Чтобы разобраться во всем этом, загляните лучше в документацию.

Интернационализация манифеста

Первое, что видит пользователь в Chrome Web Store, — это обзорная страница расширения. Мы должны убедиться, что на ней все переведено на актуальный язык, для этого придется внести несколько изменений в манифест — перевести название и описание:

{
  // Обновленные значения
  "name": "__MSG_name__",
  "description": "__MSG_description__"
}

Это специальный синтаксис, который позволяет ссылаться на фразы в messages.json по их идентификатору. Например, строка __MSG_name__ использует фразу с идентификатором name.

Интернационализация HTML-страниц

Чтобы перевести тексты на страницах самого расширения, потребуется добавить немного JavaScript:

chrome.i18n.getMessage('name');

Этот код также использует идентификатор фразы из файла messages.json.

Чтобы передать динамические плейсхолдеры, используйте второй параметр метода:

chrome.i18n.getMessage('userGreeting', {
  daytime: 'morning',
  user: 'Lars'
});

Конечно, перевести таким образом все тексты на странице не самая приятная задача, но мы можем схитрить. Вынесем тексты в data-атрибуты элементов и переведем их все сразу с помощью скрипта.

Создайте новую папку js внутри директории src и добавьте туда файл util.js, в котором будут находиться вспомогательные функции:

src 
 └─ js
     └─ util.js

Вот так выглядит сам код:

const i18n = document.querySelectorAll("[data-intl]");
i18n.forEach(msg => {
  msg.innerHTML = chrome.i18n.getMessage(msg.dataset.intl);
});

chrome.i18n.getAcceptLanguages(languages => {
  document.documentElement.lang = languages[0];
});

При выполнении этот скрипт найдет все элементы с атрибутом data-intl, найдет соответствующую указанному идентификатору переведенную фразу и вставит полученный текст в innerHTML элемента.

<!-- До выполнения JS -->
<html>
  <body>
    <button data-intl="popupManageSettings"></button>
  </body>
</html>
<!-- После выполнения JS -->
<html lang="en">
  <body>
    <button data-intl="popupManageSettings">Manage settings</button>
  </body>
</html>

Добавление выпадающего меню и страницы настроек

Прежде чем перейти уже наконец к настоящей разработке, мы должны создать две страницы:

  1. Страницу настроек, которая будет содержать настройки пользователя.
  2. Выпадающее меню, которое будет открываться при нажатии на иконку расширения в панели браузера. Его можно использовать для различных сценариев, например, отображать быстрые настройки или собранную статистику.
Скриншот страницы с настройками
Выпадающее меню содержит ссылку на страницу настроек

Вот файлы, которые нам понадобятся:

src 
 ├─ css
 |    └─ paintBucket.css
 ├─ popup
 |    ├─ popup.html
 |    ├─ popup.css
 |    └─ popup.js
 └─ options
      ├─ options.html
      ├─ options.css
      └─ options.js

Файлы .css содержат самый обычный CSS, не больше, не меньше. Вы все наверняка знаете, как это работает 🙂 При желании вы, разумеется, можете использовать различные препроцессоры, главное, не забудьте скомпилировать код.

Файл paintBucket.css:

:root {
  --primary: #ff4500;
  --primary-dark: #d83a00;
  --secondary: #f2f2f2;
  --secondary-dark: #cdcdcd;
  --text: #000;
  --body: #fff;
  --btn-background: rgba(214, 214, 214,.25);
  --btn-background-hover: #c3c3c3;
  --focus: rgba(24, 116, 195, .3);
  --warning: #9b050c;
}

@media (prefers-color-scheme: dark) {
  :root {
    --secondary: #7e818c;
    --secondary-dark: #91939d;
    --text: #fff;
    --body: #202124;
    --btn-background: #292a2d;
    --btn-background-hover: #47494e;
    --focus: rgba(255, 255, 255, .3);
  }
}
*, *::before, *::after {
  box-sizing: border-box;
}

*:focus {
  box-shadow: 0 0 0 3px rgba(0, 125, 250, 0.45);
  outline: none;
}

html {
  width: 100%;
}

html, body {
  margin: 0;
  padding: 0;
  font-size: 16px;
  color: var(--text);
  background-color: var(--body);
}

body {
  width: 100%;
  height: 100vh;
  max-height: 100%;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
}

Обратите внимание, выпадающее меню — это НЕ отдельная вкладка браузера. Его размер зависит от контента. Если вы хотите задать определенные фиксированные размеры, просто установите свойства width и height для элемента html.

Выпадающее меню

Напишем HTML-код выпадающего меню, а также подключим туда CSS- и JS-файлы.

Файл popup.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title data-intl="name"></title>

    <link rel="stylesheet" href="../css/paintBucket.css">
    <link rel="stylesheet" href="popup.css">

    <script src="../js/util.js" defer></script>
    <script src="popup.js" defer></script>
  </head>
  <body>
    <h1 id="title"></h1>
    <button data-intl="popupManageSettings"></button>
  </body>
</html>

Заголовок первого уровня будет содержать название расширения и его текущую версию, а кнопка будет открывать страницу настроек. Обратите внимание, для кнопки установлен атрибут data-intl, то есть ее текст будет вставлен автоматически нашим вспомогательным скриптом.

Текст заголовка мы установим отдельно в файле popup.js. Там же будет обработчик клика по кнопке:

const title = document.getElementById('title');
const settingsBtn = document.querySelector('button');
const manifest = chrome.runtime.getManifest();

title.textContent = `${manifest.name} (${manifest.version})`;

settingsBtn.addEventListener('click', () => {
  chrome.runtime.openOptionsPage();
});

Чтобы получить доступ к данным манифеста, это скрипт использует runtime API браузера Chrome. Его метод getManifest возвращает JSON-объект (этот метод не требует специальных разрешений).

Для начала устанавливаем текст заголовка. После этого определяем слушатель для события клика по кнопке. При клике мы хотим открывать страницу настроек, для этого есть специальный метод openOptionsPage(), также не требующий разрешений.

Стили меню в файле popup.css:

html {
  width: 360px;
  height: 160px;
  padding: 1.5rem;
}

body {
  max-height: 100%;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

h1 {
  font-size: 1rem;
  font-weight: normal;
  text-align: center;
}

button {
  -moz-appearance: none;
  -webkit-appearance: none;
  appearance: none;
  border: none;
  color: #fff;
  background-color: var(--primary);
  transition: background-color 200ms;
  padding: 0.75rem 1.5rem;
  cursor: pointer;
}
button:hover {
  background-color: var(--primary-dark);
}

Мы полностью закончили меню, но наше расширение пока что ничего о нем не знает. Нужно зарегистировать его в файле manifest.json:

"action": {
  "default_popup": "popup/popup.html",
  "default_icon": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  }
},

Страница настроек

Страница с пользовательскими настройками создается точно так же.

Начинаем с разметки — файл options.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title data-intl="name"></title>

  <link rel="stylesheet" href="../css/paintBucket.css">
  <link rel="stylesheet" href="options.css">

  <script src="../js/util.js" defer></script>
  <script src="options.js" defer></script>
</head>
<body>
  <header>
    <h1>
      <!-- Icon provided by feathericons.com -->
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" role="presentation">
        <circle cx="12" cy="12" r="3"></circle>
        <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
      </svg>
      <span data-intl="optionsPageTitle"></span>
    </h1>
  </header>

  <main>
    <section id="generalOptions">
      <h2 data-intl="sectionGeneral"></h2>

      <div id="generalOptionsWrapper"></div>
    </section>
  </main>

  <footer>
    <p>Transcribers of Reddit extension by <a href="https://lars.koelker.dev" target="_blank">lars.koelker.dev</a>.</p>
    <p>Reddit is a registered trademark of Reddit, Inc. This extension is not endorsed or affiliated with Reddit, Inc. in any way.</p>
  </footer>
</body>
</html>

Сейчас здесь нет никаких обещанных настроек, только подготовленный для них блок. Вставлять их мы будем с помощью JavaScript (в файле options.js).

Сначала определим дефолтные значения и получим ссылку на DOM-элемент контейнера:

const defaultSettings = Object.freeze({
  border: false,
  background: false,
});
const generalSection = document.getElementById('generalOptionsWrapper');

Теперь нужно загрузить сохраненные ранее настройки. Для этого нам понадобится storage API (который мы уже зарегистрировали ранее в манифесте).

Можно сохранять данные локально (chrome.storage.local) или синхронизировать их между разными устройствами пользователя (chrome.storage.sync). Для простоты будем использовать локальное хранилище.

Для получения данных предназначен метод get, который принимает два аргумента: ключ, с которым связано нужное значение, и коллбэк для обработки полученных данных.

У нас ключ — это строка 'settings', но можно также передавать массив строк, если требуется загрузить несколько различных настроек.

Полученные из хранилища данные будут переданы функции-коллбэку:

chrome.storage.local.get('settings', ({ settings }) => {
  const options = settings ?? defaultSettings; // если не было сохраненных настроек, используем дефолтные
  if (!settings) {
    chrome.storage.local.set({
     settings: defaultSettings,
    });
 }

  // создаем HTML-опции для каждой настройки
  const generalOptions = Object.keys(options).filter(x => !x.startsWith('advanced'));
  generalOptions.forEach(option => createOption(option, options, generalSection));
});

Рендер опций вынесен в отдельную функцию createOption():

function createOption(setting, settingsObject, wrapper) {
  const settingWrapper = document.createElement("div");
  settingWrapper.classList.add("setting-item");
  settingWrapper.innerHTML = `
  <div class="label-wrapper">
    <label for="${setting}" id="${setting}Desc">
      ${chrome.i18n.getMessage(`setting${setting}`)}
    </label>
  </div>

  <input type="checkbox" ${settingsObject[setting] ? 'checked' : ''} id="${setting}" />
  <label for="${setting}"
    tabindex="0"
    role="switch"
    aria-checked="${settingsObject[setting]}"
    aria-describedby="${setting}-desc"
    class="is-switch"
  ></label>
  `;

  const toggleSwitch = settingWrapper.querySelector("label.is-switch");
  const input = settingWrapper.querySelector("input");

  input.onchange = () => {
    toggleSwitch.setAttribute('aria-checked', input.checked);
    updateSetting(setting, input.checked);
  };

  toggleSwitch.onkeydown = e => {
    if(e.key === " " || e.key === "Enter") {
      e.preventDefault();
      toggleSwitch.click();
    }
  }

  wrapper.appendChild(settingWrapper);
}

Здесь мы создаем отдельный блок для каждой настройки и помещаем внутрь него чекбокс, который может быть включен или выключен. Добавляем слушатель события change для этого чекбокса и при изменении вызываем метод updateSetting, который должен обновить сохраненное значение в хранилище. Для этого предназначен метод хранилища set, который принимает два аргумента: обновленное значение всего хранилища и коллбэк (опциональный параметр, который мы не будем использовать).

Так как мы храним настройки в виде объекта, следует использовать spread-оператор, чтобы обновлять только нужный параметр, сохраняя актуальные значения прочих.

function updateSetting(key, value) {
  chrome.storage.local.get('settings', ({ settings }) => {
    chrome.storage.local.set({
      settings: {
        ...settings,
        [key]: value
      }
    })
  });
}

Стили страницы в файле options.css:

header {
  background-color: var(--primary);
  color: #fff;
  padding: 1rem 1.75rem;
}
header > h1 {
  margin: 0 auto;
  max-width: 40rem;
  font-weight: normal;
  display: flex;
  align-items: center;
}
header > h1 > svg {
  width: 2rem;
  height: 2rem;
  margin-right: 0.5rem;
}

main {
  width: 100%;
  padding: 0 1.75rem;
  margin: 1.5rem auto;
  display: flex;
  flex-direction: column;
  flex: 1;
}
main > section {
  width: 100%;
  max-width: 40rem;
  margin: 1rem auto;
}
main > section > h2 {
  max-width: 50rem;
  font-weight: normal;
  margin: 0.25rem 0;
  color: var(--black);
}
main > section > h2.dropdown {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  width: 100%;
  font-size: 1.5rem;
  border: none;
  cursor: pointer;
  padding: 0.25rem 0;
  background-color: var(--white);
  transition: background-color 200ms;
  display: flex;
  align-items: center;
  justify-content: space-between;
  user-select: none;
}
main > section > h2.dropdown > svg {
  width: 1.6rem;
  height: 1.6rem;
  transition: transform 200ms;
}
main > section > h2.dropdown:hover {
  background-color: var(--gray);
}
main > section > h2.dropdown + div {
  display: none !important;
}
main > section > h2.dropdown[aria-expanded=true] > svg {
  transform: rotate(-90deg);
}
main > section > h2.dropdown[aria-expanded=true] + div {
  display: flex !important;
}
main > section > #generalOptionsWrapper, main > section #advancedOptionsWrapper {
  display: flex;
  flex-direction: column;
  margin: 1rem 0;
}
main > section > #generalOptionsWrapper > .setting-item, main > section #advancedOptionsWrapper > .setting-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.5rem 0;
}
main > section > #generalOptionsWrapper > .setting-item:hover > .label-wrapper > button, main > section > #generalOptionsWrapper > .setting-item:focus-within > .label-wrapper > button, main > section #advancedOptionsWrapper > .setting-item:hover > .label-wrapper > button, main > section #advancedOptionsWrapper > .setting-item:focus-within > .label-wrapper > button {
  opacity: 1;
}
main > section > #generalOptionsWrapper > .setting-item > .label-wrapper, main > section #advancedOptionsWrapper > .setting-item > .label-wrapper {
  display: flex;
  align-items: center;
}
main > section > #generalOptionsWrapper > .setting-item > .label-wrapper > button, main > section #advancedOptionsWrapper > .setting-item > .label-wrapper > button {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background: none;
  border: none;
  cursor: pointer;
  margin-left: 0.25rem;
  opacity: 0;
  transition: opacity 200ms;
}
main > section > #generalOptionsWrapper > .setting-item > .label-wrapper > button > svg, main > section #advancedOptionsWrapper > .setting-item > .label-wrapper > button > svg {
  width: 1.5rem;
  height: 1.5rem;
  stroke: var(--placeholder);
}
main > section > #generalOptionsWrapper > .setting-item > input, main > section #advancedOptionsWrapper > .setting-item > input {
  display: none;
}
main > section > #generalOptionsWrapper > .setting-item > input:checked + .is-switch, main > section #advancedOptionsWrapper > .setting-item > input:checked + .is-switch {
  background-color: var(--primary);
  border-color: var(--primary);
}
main > section > #generalOptionsWrapper > .setting-item > input:checked + .is-switch:hover, main > section #advancedOptionsWrapper > .setting-item > input:checked + .is-switch:hover {
  background-color: var(--primary-dark);
  border-color: var(--primary-dark);
}
main > section > #generalOptionsWrapper > .setting-item > input:checked + .is-switch::after, main > section #advancedOptionsWrapper > .setting-item > input:checked + .is-switch::after {
  transform: translateY(-50%) translateX(28px);
}
main > section > #generalOptionsWrapper > .setting-item > .is-switch, main > section #advancedOptionsWrapper > .setting-item > .is-switch {
  width: 4rem;
  height: 34px;
  background: var(--secondary);
  cursor: pointer;
  border-radius: 30px;
  overflow: hidden;
  position: relative;
  transition: background-color 200ms, border-color 200ms;
  border: 1px solid var(--secondary);
}
main > section > #generalOptionsWrapper > .setting-item > .is-switch:hover, main > section > #generalOptionsWrapper > .setting-item > .is-switch:focus, main > section #advancedOptionsWrapper > .setting-item > .is-switch:hover, main > section #advancedOptionsWrapper > .setting-item > .is-switch:focus {
  background-color: var(--secondary-dark);
  border-color: var(--secondary-dark);
}
main > section > #generalOptionsWrapper > .setting-item > .is-switch::after, main > section #advancedOptionsWrapper > .setting-item > .is-switch::after {
  background: #fff;
  border-radius: 50%;
  box-shadow: 2px 4px 6px rgba(0, 0, 0, 0.2);
  content: "";
  height: 30px;
  width: 30px;
  position: absolute;
  left: 2px;
  top: 50%;
  transform: translateY(-50%);
  transition: transform 200ms;
  will-change: transform;
}

footer {
  display: flex;
  flex-direction: column;
  padding: 1rem 1.75rem;
  text-align: center;
}
footer > p {
  margin: 0.25rem 0;
  font-size: 75%;
}
footer > p > a {
  text-decoration: none;
  color: var(--text);
}

dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 100%;
  max-width: 40rem;
  border: none;
  margin: 0;
  box-shadow: 5px 5px 8px 0 rgba(0, 0, 0, 0.15);
  border-radius: 0.5rem;
  padding: 2rem;
}
dialog::backdrop {
  background: rgba(0, 0, 0, 0.25);
  backdrop-filter: blur(10px);
}
dialog > #dialogClose {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  padding: 0.25rem;
  background: none;
  border: none;
  cursor: pointer;
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  color: lightgray;
  transition: color 200ms;
}
dialog > #dialogClose:hover {
  color: var(--placeholder);
}
dialog > #dialogClose > svg {
  width: 1.75rem;
  height: 1.75rem;
}
dialog > #optionDescription {
  margin-bottom: 2rem;
  line-height: 1.5;
}
dialog > #dialogActions {
  display: flex;
  justify-content: flex-end;
  margin: 0 -0.5rem;
}
dialog > #dialogActions > #dialogOk {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background: var(--blue);
  padding: 0.75rem 1.5rem;
  min-width: 4rem;
  max-width: fit-content;
  border: none;
  color: var(--white);
  cursor: pointer;
}

И конечно же, мы должны зарегистрировать новую страницу в манифесте:

"options_ui": {
  "open_in_tab": true,
  "page": "options/options.html"
},

При необходимости вы можете оформить страницу опций в виде модального диалога. Для этого нужно установить свойство open_in_tab: false.

Установка расширения для разработки

Мы сделали всю подготовительную работу, и теперь неплохо было бы проверить, работает ли наше расширение.

Перейдите на страницу chrome://extensions и включите режим разработчика (в правом верхнем углу).

Появятся три кнопки, нам нужна первая — Загрузить распакованное расширение. Кликните на нее и в появившемся окне выберите папку src, в которой хранятся все файлы нашего проекта.

После этого наше расширение появится в списке в статусе Установлено. Также его иконка появится в верхней панели браузера рядом с адресной строкой (его можно найти, кликнув на иконку паззла 🧩) . Если вы нажмете на нее, то появится выпадающее меню с кнопкой:

На вашем девайсе оно может выглядеть немного по-другому, если в предпочтениях не включена темная тема оформления.

Страница настроек:

Попробуйте изменить значение переключателей и перезагрузить страницу — вы увидите, что настройки сохраняются в локальном хранилище браузера.

Основной скрипт

Итак, мы сделали выпадающее меню и настроили взаимодействие со страницей настроек, но само расширение все еще не делает ничего полезного. Давайте исправим это и добавим основной скрипт.

Создайте новый файл comment.js внутри директории js.

Теперь его нужно добавить в манифест в секцию content_scripts:

"content_scripts": [
  {
    "matches": [ "*://www.reddit.com/*" ],
    "js": [ "js/comment.js" ]
  }
],

Это массив, каждый элемент которого состоит из двух частей:

  • matches — содержит массив урлов, на которых браузер должен запускать наше расширение. Мы указываем всего один урл домен верхнего уровня — www.reddit.com, а изменяющиеся части обозначаются звездочками.
  • js — содержит массив скриптов, которые нужно запустить на подходящих урлах.

Помните, что скрипты расширения не могут взаимодействовать на скриптами сайта, не могут использовать их переменные или функции.

// script_on_website.js
const username = 'Lars';

// content_script.js
console.log(username); // Error: username is not defined

Давайте приступим уже к программированию.

Прежде всего добавим несколько констант в файл comment.js, в которых сохраним селекторы DOM-элементов и регулярные выражения для дальнейшей работы.

Объект CommentUtils содержит набор тестовых функций для определения, содержит ли пост «ToR-комментарии» и существует ли вообще блок комментариев на странице.

const messageTypes = Object.freeze({
  COMMENT_PAGE: 'comment_page',
  SUBREDDIT_PAGE: 'subreddit_page',
  MAIN_PAGE: 'main_page',
  OTHER_PAGE: 'other_page',
});

const Selectors = Object.freeze({
  commentWrapper: 'div[style*="--commentswrapper-gradient-color"] > div, div[style*="max-height: unset"] > div',
  torComment: 'div[data-tor-comment]',
  postContent: 'div[data-test-id="post-content"]'
});

const UrlRegex = Object.freeze({
  commentPage: /\/r\/.*\/comments\/.*/,
  subredditPage: /\/r\/.*\//
});

const CommentUtils = Object.freeze({
  isTorComment: (comment) => comment.querySelector('[data-test-id="comment"]') ? comment.querySelector('[data-test-id="comment"]').textContent.includes('m a human volunteer content transcriber for Reddit') : false,
  torCommentsExist: () => !!document.querySelector(Selectors.torComment),
  commentWrapperExists: () => !!document.querySelector('[data-reddit-comment-wrapper="true"]')
});

Затем мы проверяем, находится ли пользователь на странице «поста» с комментариями. Для этого проверяем текущий урл и обновляем значение переменной directPage. Эта проверка сработает в том случае, если пользователь перешел на страницу с внешних ресурсов или собственноручно вбил адрес в адресную строку браузера.

let directPage = false;
if (UrlRegex.commentPage.test(window.location.href)) {
  directPage = true;
  moveComments();
}

Но в большинстве случаев переходы осуществляются внутри SPA. Чтобы отловить их, мы можем добавить слушатель сообщений, используя runtime API.

chrome.runtime.onMessage.addListener(msg => {
  if (msg.type === messageTypes.COMMENT_PAGE) {
    waitForComment(moveComments);
  }
});

Теперь нам нужно написать реализацию функций moveComment и waitForComment.

moveComment — находит в общем списке «ToR-комментарии» с транскрипцией медиа-ресурсов и перемещает их в начало списка. Также при наличии соответствующих настроек она изменяет цвет фона и рамки этих комментариев (это проверяется с помощью уже знакомого нам метода get).

function moveComments() {
  if (CommentUtils.commentWrapperExists()) {
    return;
  }

  const wrapper = document.querySelector(Selectors.commentWrapper);
  let comments = wrapper.querySelectorAll(`${Selectors.commentWrapper} > div`);
  const postContent = document.querySelector(Selectors.postContent);

  wrapper.dataset.redditCommentWrapper = 'true';
  wrapper.style.flexDirection = 'column';
  wrapper.style.display = 'flex';

  if (directPage) {
    comments = document.querySelectorAll("[data-reddit-comment-wrapper='true'] > div");
  }

  chrome.storage.local.get('settings', ({ settings }) => { 
    comments.forEach(comment => {
      if (CommentUtils.isTorComment(comment)) {
        comment.dataset.torComment = 'true';
        if (settings.background) {
          comment.style.backgroundColor = 'var(--newCommunityTheme-buttonAlpha05)';
        }
        if (settings.border) {
          comment.style.outline = '2px solid red';
        }
        comment.style.order = "-1";
        applyWaiAria(postContent, comment);
      }
    });
  })
}

Функция applyWaiAria добавляет комментариям aria-атрибуты для улучшения доступности для скринридеров. Мы также используем вспомогательную функцию uuidv4 для генерации уникальных идентификаторов.

function applyWaiAria(postContent, comment) {
  const postMedia = postContent.querySelector('img[class*="ImageBox-image"], video');
  const commentId = uuidv4();

  if (!postMedia) {
    return;
  }

  comment.setAttribute('id', commentId);
  postMedia.setAttribute('aria-describedby', commentId);
}

function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

Функция waitForComment ожидает, пока комментарии загрузятся, и после этого вызывает коллбэк, который был ей передан. Для отслеживания используется API браузера MutationObserver.

function waitForComment(callback) {
  const config = { childList: true, subtree: true };
  const observer = new MutationObserver(mutations => {
    for (const mutation of mutations) {
      if (document.querySelector(Selectors.commentWrapper)) {
        callback();
        observer.disconnect();
        clearTimeout(timeout);
        break;
      }
    }
  });

  observer.observe(document.documentElement, config);
  const timeout = startObservingTimeout(observer, 10);
}

function startObservingTimeout(observer, seconds) {
  return setTimeout(() => {
    observer.disconnect();
  }, 1000 * seconds);
}

Добавляем сервис-воркер

Помните, что мы добавили слушатель сообщений в скрипте нашего расширения? Но пока никаких сообщений нам не приходит, кто-то должен их отправлять. Для этого мы должны зарегистрировать сервис-воркер.

Создайте новый файл sw.js внутри директории src (важно, чтобы он находился в корне проекта).

Сразу же зарегистрируем его в манифесте:

"background": {
  "service_worker": "sw.js"
}

Начнем, как обычно, с констант для сообщений и типов страниц:

const messageTypes = Object.freeze({
  COMMENT_PAGE: 'comment_page',
  SUBREDDIT_PAGE: 'subreddit_page',
  MAIN_PAGE: 'main_page',
  OTHER_PAGE: 'other_page',
});

const UrlRegex = Object.freeze({
  commentPage: /\/r\/.*\/comments\/.*/,
  subredditPage: /\/r\/.*\//
});

const Utils = Object.freeze({
  getPageType: (url) => {
    if (new URL(url).pathname === '/') {
      return messageTypes.MAIN_PAGE;
    } else if (UrlRegex.commentPage.test(url)) {
      return messageTypes.COMMENT_PAGE;
    } else if (UrlRegex.subredditPage.test(url)) {
      return messageTypes.SUBREDDIT_PAGE;
    }

    return messageTypes.OTHER_PAGE;
  }
});

Мы будем следить за историей навигации браузера (onHistoryStateUpdated). Нужное нам событие будет вызываться каждый раз, когда SPA обращается к History API при обновлении контента страницы без перезагрузки.

При наступлении события мы запрашиваем активную вкладку браузера и получаем ее tabId, а затем отправляем сообщение основному скрипту расширения.

chrome.webNavigation.onHistoryStateUpdated.addListener(async ({ url }) => {
  const [{ id: tabId }] = await chrome.tabs.query({ active: true, currentWindow: true });

  chrome.tabs.sendMessage(tabId, {
    type: Utils.getPageType(url),
    url
  });
});

Готово!

Вот и все!

Перейдите в менеджер расширений (chrome://extensions) и перезапустите наше расширение, чтобы все изменения применились.

Теперь откройте на Reddit какой-нибудь пост, у которого есть комментарии с транскрибацией (например, вот этот). И вы сразу же увидите нужный комментарий, потому что он отображается в самом верху списка, да еще и с рамкой и красным фоном (если вы установили соответствующие настройки).

Расширение Transcribers of Reddit находит комментарии с транскрибацией изображения, поднимает их на верх списка и выделяет красной рамкой и фоном

Заключение

Создавать браузерные расширения совсем несложно, как вы только что убедились. В них нет ничего особенного — те же самые HTML, CSS и JavaScript, которые вы пишете каждый день, плюс совсем немного магии в manifest.json.

Если у вас возникнут какие-то проблемы, скорее всего вы сможете найти ответ в отличной документации Chrome API.

Репозиторий с кодом статьи

0 комментариев

Оставить комментарий

*Доступные HTML-теги: a, abbr, blockquote, code, pre, del, i, em, strong, b, strike
*Не будет опубликован