Мои хобби-проекты - небольшие расширения для Хрома и сайты-микросервисы. Одно из расширений, PDF to PNG, внезапно забралось на первое место в поиске Chrome Web Store по запросу “pdf to png” (ну, почти всегда), и им пользуются аж под 500 человек - это потрясающе!
Раз PDF в картинки получилось, немедленно загорелось сделать и наоборот - картинки -> PDF, только ещё круче: чтоб работало через правый клик по картинке на любой странице, чтоб была лёгкая(!) возможность настроек - и чтоб всё прям локально в браузере и картинки при этом никуда не загружались!
Все замечательные настройки поместил в сайд-панель, вот только обычный пользователь может же никогда не догадаться её открыть, а таки хотелось бы дать о ней знать, но при этом не дёргать тех, кому достаточно меню правой кнопки мыши - что ж, будем показывать её иногда (ну или пока пользователь явно не кликнет куда-то в панель).
Эта хотелка упёрлась в правило Хрома: открыть панель можно только в ответ на клик, а в обработчике клика по пункту меню это значит - до первого await. А чтобы показывать иногда, на каждом клике расширение должно помнить, сколько раз уже её показало, - а прочитать это из хранилища и есть await.
Решение - хранить счётчик прямо в id пункта контекстного меню! :)
И вот - Save Image as PDF. Правый клик - и картинка уже PDF.
Await страницы и await воркера по-разному “сжигают” жест
Правый клик случается на чужой странице, где от расширения ничего не открыто: ни панели, ни своих страниц. Соответственно, клики по пункту меню слушает сервис-воркер.
Наивный подход - прочитать в обработчике клика, сколько раз панель уже показывали, и по этому решить, открывать ли её:
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
const { shown = 0 } = await chrome.storage.local.get('shown');
if (shown < 3) {
chrome.sidePanel.open({ tabId: tab.id });
}
});
Панель, конечно же, не откроется ни разу, а в консоли воркера на каждый клик будет ошибка sidePanel.open() may only be called in response to a user gesture.
Некоторые API Хрома, например chrome.sidePanel.open() и chrome.permissions.request(), работают, только если их вызвали в ответ на действие человека: клик, нажатие клавиши. Клик по пункту меню таким жестом считается, вот только живёт этот жест на странице и в воркере очень по-разному.
На обычной странице работает transient activation: после клика жест “живёт” 5 секунд. Так прямо и записано в коде Chromium, kActivationLifespan = base::Seconds(5), с пояснением “long enough to allow network round trips even in a very slow connection”. Проверить легко - вставьте в консоль DevTools на любой странице и кликните по ней:
document.onclick = () => [4000, 6000].forEach((ms) =>
setTimeout(() => console.log(ms, navigator.userActivation.isActive), ms));
// 4000 true
// 6000 false
А в воркере никакого таймера нет: жест держится, только пока Хром передаёт событие клика обработчику. async-обработчик на первом же await возвращает управление Хрому - и жеста больше нет. Даже await Promise.resolve(), который не ждёт вообще ничего, его сжигает. Сам await chrome.sidePanel.open() при этом можно - главное, чтобы перед ним ничего не ждали. В команде Chrome об этой разнице знают: в WebExtensions CG предлагают дать расширениям жест по времени, как на страницах, но пока это только предложение.
Почти любое полезное действие требует await: прочитать настройки, скачать картинку, собрать PDF. К тому же воркер мог только что проснуться от этого самого клика: в MV3 его выгружают примерно после 30 секунд простоя - так себе детерминизм. После такого холодного старта в памяти пусто, так что и глобальная переменная не спасёт. Как же тогда узнать в момент клика, сколько раз панель уже показывали?
Колбэк вместо await
На chromium-extensions это можно обходить старым добрым колбэком, ибо вот именно для одного колбэка Хром оставляет “жест”, но только для одного, и только для колбэка (никаких await!). Колбэк внутри колбэка жеста уже не получит.
chrome.contextMenus.onClicked.addListener((info, tab) => {
chrome.storage.local.get('shown', ({ shown = 0 }) => {
if (shown < 3) chrome.sidePanel.open({ tabId: tab.id });
// так сработает если колбэк именно один - без вложенности
});
});
Однако получилось придумать другой трюк и остаться в мире await!
Счётчик в id пункта меню
Я придумал такое правило, чтобы насильно наверняка познакомить пользователя с сайд-панелью, но не мозолить глаза постоянно: открывать панель на первых трёх сохранениях, потом на каждом десятом, пока пользователь не ткнёт хоть что-то в панели или не откроет её сам (ну то есть - панель точно была замечена). Для этого уже в момент клика нужно знать, какое это по счёту сохранение и какой режим выбран в настройках.
Есть одно своё значение, которое приходит в обработчик клика по меню синхронно, - это, собственно, info.menuItemId, id пункта, по которому кликнули. Это может быть неочевидным, но вообще-то ничего не мешает пересоздавать пункты меню хоть двадцать раз в день. Вот туда состояние и легло:
// id = 'save|<step>|<mode>'
// step 0..12 пользователь ещё не пользовался панелью; считаем сохранения через меню
// known пользователь открыл панель сам или что-то в ней сделал; больше не меняется
// mode download | panel | collection
export const NUDGE_SAVES = 3;
export const LAST_STEP = 12;
export const KNOWN = 'known';
const ID_PATTERN = /^save\|(known|0|[1-9]\d?)\|(download|panel|collection)$/;
export function parseMenuId(id: unknown): MenuState | null {
…
const match = ID_PATTERN.exec(id);
…
return { step, mode: match[2] as MenuMode };
}
export function shouldOpenPanel(state: MenuState): boolean {
if (state.mode === 'panel') return true;
if (state.step === KNOWN) return false;
return state.step < NUDGE_SAVES || state.step === LAST_STEP;
}
export function afterMenuSave(state: MenuState): MenuState {
const { step } = state;
if (step === KNOWN) return state;
return { ...state, step: step === LAST_STEP ? NUDGE_SAVES : step + 1 };
}
Пункт меню у расширения один, с одним и тем же заголовком “Save image as PDF”, меняется только id: save|0|download, save|12|collection, save|known|panel и т.п. Обработчик разбирает id и, если надо, открывает панель первым же вызовом, ничего не дожидаясь:
export function handleMenuClick(info, tab, deps): Promise<void> {
const state = parseMenuId(info.menuItemId); // синхронно
…
const openedPanel = shouldOpenPanel(state) && tabId !== undefined;
if (openedPanel) {
deps.openPanel(tabId).catch(warn('sidePanel.open')); // chrome.sidePanel.open({ tabId })
}
…
}
После клика, уже вне жеста, воркер вычисляет следующее состояние и перерегистрирует пункт: contextMenus.removeAll() и create() с новым id. contextMenus.update() тут не поможет: менять id он не умеет. Саму регистрацию меню Хром хранит сам, она переживает и выгрузку воркера, и перезапуск браузера. А вот при обновлении расширения Хром меню стирает, поэтому копия id лежит ещё и в storage.local, из неё пункт и восстанавливается.
Конечно, в id много не положишь - счётчик да режим. Зато до открытия панели не читается вообще ничего; это чуть быстрее чтения chrome.storage, хотя в UX разницы не заметить.
Если у вас тоже расширение с динамическим меню
Если вам тоже хочется, чтобы правый клик вёл себя по-разному, вот что у меня в итоге сработало:
- вызов, которому нужен жест, ставьте первой строкой обработчика, до любого
await; - всё, что нужно знать в момент клика, кладите в id пункта меню, а после клика перерегистрируйте пункт с новым id;
- ну, вообще-то и вариант с колбэком тоже сработает
Само расширение - Save Image as PDF в Chrome Web Store, у него есть и сайт. А если нужно наоборот, PDF в картинки, - есть PDF to PNG, с которого всё и началось (сайт). Подробнее о проекте - на странице проекта.
А у вас?
А вы как передаёте состояние в onClicked? Если нашли способ проще id и не завязанный на единственный колбэк - расскажите.