From f0766186577f0ab5feb060d67ed8b534775ea92c Mon Sep 17 00:00:00 2001 From: Ankit Ahuja <61857+ankit@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:03:09 -0400 Subject: [PATCH 1/4] fix: only try the side panel from the shortcut when it's the dock Co-Authored-By: Claude Opus 5.5 --- src/apps/background/global-commands.test.ts | 22 +++++++- src/apps/background/global-commands.ts | 6 +-- src/apps/background/listeners.test.ts | 4 ++ src/apps/background/side-panel-check.test.ts | 55 +++++++++++++++++++- src/apps/background/side-panel-check.ts | 23 +++++++- 5 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/apps/background/global-commands.test.ts b/src/apps/background/global-commands.test.ts index 728123018..d6d4dae07 100644 --- a/src/apps/background/global-commands.test.ts +++ b/src/apps/background/global-commands.test.ts @@ -3,7 +3,7 @@ jest.mock('./restore-tabs', () => ({ restoreTab: jest.fn(async () => undefined), })); jest.mock('./side-panel-check', () => ({ - canUseEditorSidePanel: () => !!chrome.sidePanel, + opensEditorInSidePanel: () => !!chrome.sidePanel && mockDockedInSidePanel, onEditorSidePanelOpened: jest.fn(), })); jest.mock('./styles', () => ({ @@ -16,6 +16,8 @@ import { restoreTab } from './restore-tabs'; import { applyStylesToAllTabs, toggleForPage } from './styles'; import { handleCommand } from './global-commands'; +let mockDockedInSidePanel = true; + const PAGE = { id: 7, url: 'https://example.com/' } as chrome.tabs.Tab; /** @@ -68,6 +70,7 @@ const flush = () => new Promise(resolve => setTimeout(resolve)); describe('handleCommand', () => { beforeEach(() => { jest.clearAllMocks(); + mockDockedInSidePanel = true; }); it('asks the page to carry out a shortcut that acts on it', async () => { @@ -106,7 +109,7 @@ describe('handleCommand', () => { expect(api.tabs.sendMessage).not.toBeCalled(); }); - it('leaves the editor to the page where the side panel isn’t the dock', async () => { + it('leaves the editor to the page where the side panel can’t open', async () => { const api = makeChrome({ panelSetUp: false }); handleCommand('stylebot', PAGE); @@ -119,6 +122,21 @@ describe('handleCommand', () => { ); }); + it('leaves the editor to the page where the side panel isn’t the dock', async () => { + mockDockedInSidePanel = false; + const api = makeChrome({ panelSetUp: true }); + + handleCommand('stylebot', PAGE); + await flush(); + + expect(api.sidePanel?.open).not.toBeCalled(); + expect(api.tabs.sendMessage).toBeCalledWith( + 7, + { name: 'RunCommand', command: 'stylebot' }, + expect.any(Function) + ); + }); + it('leaves the editor to the page in a browser without a side panel', async () => { const api = makeChrome({ sidePanel: false }); diff --git a/src/apps/background/global-commands.ts b/src/apps/background/global-commands.ts index 1472cb08d..f5f9871b1 100644 --- a/src/apps/background/global-commands.ts +++ b/src/apps/background/global-commands.ts @@ -10,8 +10,8 @@ import * as editorWindow from './editor-window'; import { restoreTab } from './restore-tabs'; import { applyStylesToAllTabs, toggleForPage } from './styles'; import { - canUseEditorSidePanel, onEditorSidePanelOpened, + opensEditorInSidePanel, } from './side-panel-check'; const isCommandName = (command: string): command is StylebotCommandName => @@ -59,7 +59,7 @@ const runInPage = (tabId: number, command: StylebotCommandName): void => { /** * Toggles the editor where the side panel is the dock. The panel is set up * on every tab ahead of time then, so it opens here, before anything is - * awaited and the shortcut's gesture lapses; elsewhere the open fails and + * awaited and the shortcut's gesture lapses. Where the open fails anyway, * the page toggles its own editor. */ const toggleEditorWithSidePanel = (tabId: number): void => { @@ -109,7 +109,7 @@ export const handleCommand = ( if (command === 'stylebot') { if (target.fromEditorWindow) { editorWindow.close(target.tabId); - } else if (canUseEditorSidePanel()) { + } else if (opensEditorInSidePanel()) { toggleEditorWithSidePanel(target.tabId); } else { runInPage(target.tabId, command); diff --git a/src/apps/background/listeners.test.ts b/src/apps/background/listeners.test.ts index 6cb23bd21..4469f957d 100644 --- a/src/apps/background/listeners.test.ts +++ b/src/apps/background/listeners.test.ts @@ -18,6 +18,10 @@ jest.mock('./restore-tabs', () => ({ })); jest.mock('./badge', () => ({ updateBadge: jest.fn() })); jest.mock('./options', () => ({ get: jest.fn(), pruneRetired: jest.fn() })); +jest.mock('./side-panel-check', () => ({ + initSidePanelCheck: jest.fn(), + onEditorSidePanelOpened: jest.fn(), +})); jest.mock('./sync-scheduler', () => ({ AWAY_SECONDS: 60, isSyncAlarm: jest.fn(), diff --git a/src/apps/background/side-panel-check.test.ts b/src/apps/background/side-panel-check.test.ts index 5d7e9289d..818f00f3c 100644 --- a/src/apps/background/side-panel-check.test.ts +++ b/src/apps/background/side-panel-check.test.ts @@ -4,7 +4,11 @@ import { } from '@stylebot/utils'; import { get as getOption, set as setOption } from './options'; -import { onEditorSidePanelOpened } from './side-panel-check'; +import { + initSidePanelCheck, + onEditorSidePanelOpened, + opensEditorInSidePanel, +} from './side-panel-check'; jest.mock('@stylebot/utils', () => ({ ...jest.requireActual('@stylebot/utils'), @@ -97,3 +101,52 @@ describe('onEditorSidePanelOpened', () => { openStylebot(); }); }); + +describe('opensEditorInSidePanel', () => { + let onChanged: ( + changes: Record, + area: string + ) => void; + + beforeEach(() => { + global.chrome = { + sidePanel: { open: jest.fn() }, + storage: { + onChanged: { + addListener: jest.fn(listener => { + onChanged = listener; + }), + }, + }, + } as unknown as typeof chrome; + }); + + it('tries the side panel until the dock is read', () => { + jest.isolateModules(() => { + const check = jest.requireActual('./side-panel-check'); + expect(check.opensEditorInSidePanel()).toBe(true); + }); + }); + + it('follows the dock as it changes', async () => { + initSidePanelCheck(); + await jest.runAllTimersAsync(); + expect(opensEditorInSidePanel()).toBe(true); + + onChanged( + { + options: { newValue: { layout: { ...layout, dockLocation: 'right' } } }, + }, + 'local' + ); + expect(opensEditorInSidePanel()).toBe(false); + }); + + it('stays in the page once the browser is found unable to host it', async () => { + initSidePanelCheck(); + await jest.runAllTimersAsync(); + + onChanged({ sidePanelIgnoresTabs: { newValue: true } }, 'local'); + expect(opensEditorInSidePanel()).toBe(false); + }); +}); diff --git a/src/apps/background/side-panel-check.ts b/src/apps/background/side-panel-check.ts index 3da786bb1..a3b11be85 100644 --- a/src/apps/background/side-panel-check.ts +++ b/src/apps/background/side-panel-check.ts @@ -13,6 +13,7 @@ const LOAD_TIMEOUT_MS = 2000; const POLL_MS = 250; let ignoresTabs = false; +let isDock: boolean | undefined; /** * Whether the editor can open in the browser's side panel: it has one, and @@ -23,17 +24,35 @@ export const canUseEditorSidePanel = (): boolean => supportsEditorSidePanel() && !ignoresTabs; /** - * Keeps canUseEditorSidePanel's answer in step with storage. + * Whether the editor opens in the side panel. Says yes until the dock is + * read after the worker wakes; callers fall back to the page. + */ +export const opensEditorInSidePanel = (): boolean => + isDock !== false && canUseEditorSidePanel(); + +/** + * Keeps canUseEditorSidePanel's and opensEditorInSidePanel's answers in step + * with storage. */ export const initSidePanelCheck = (): void => { isSidePanelIgnoringTabs().then(value => { ignoresTabs = value; }); + getOption('layout').then(layout => { + isDock = (layout as StylebotLayout).dockLocation === 'sidepanel'; + }); chrome.storage.onChanged.addListener((changes, area) => { - if (area === 'local' && changes[SIDE_PANEL_IGNORED_KEY]) { + if (area !== 'local') { + return; + } + if (changes[SIDE_PANEL_IGNORED_KEY]) { ignoresTabs = !!changes[SIDE_PANEL_IGNORED_KEY].newValue; } + if (changes['options']) { + isDock = + changes['options'].newValue?.layout?.dockLocation === 'sidepanel'; + } }); }; From 7214c467bb3989c0592251d27886195efb644d3e Mon Sep 17 00:00:00 2001 From: Ankit Ahuja <61857+ankit@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:35:39 -0400 Subject: [PATCH 2/4] fix: dock the editor in the page in Arc, whose side panel never shows Co-Authored-By: Claude Opus 5.5 --- src/apps/editor/window/index.ts | 22 +++++++++++- .../options/components/basics/ThePosition.vue | 21 +++++++++-- src/apps/popup/App.vue | 3 ++ src/apps/popup/utils.ts | 6 ++-- src/core/utils/editor-side-panel.test.ts | 36 +++++++++++++++++++ src/core/utils/editor-side-panel.ts | 33 +++++++++++++++-- src/core/utils/index.ts | 2 ++ 7 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 src/core/utils/editor-side-panel.test.ts diff --git a/src/apps/editor/window/index.ts b/src/apps/editor/window/index.ts index bf5acd83f..a2e4509ea 100644 --- a/src/apps/editor/window/index.ts +++ b/src/apps/editor/window/index.ts @@ -2,7 +2,12 @@ import { t } from '@stylebot/i18n'; import { setPageBridge, RemotePageBridge } from '@stylebot/page-bridge'; import { createStore, mountEditor } from '@stylebot/editor'; -import { getPageSupport, markSidePanelIgnoringTabs } from '@stylebot/utils'; +import type { EditorSidePanelOpened } from '@stylebot/types'; +import { + getPageSupport, + markSidePanelIgnoringTabs, + markSidePanelUnusableInArc, +} from '@stylebot/utils'; import { openEditorWindow } from '../utils/chrome'; import { initWindowListeners, initTabInfo } from './listeners'; @@ -143,6 +148,21 @@ const init = async (): Promise => { return; } + // Arc loads the side panel but never shows it. Told so, the background + // docks the editor in the page and opens it there. + if (host === 'sidepanel') { + markSidePanelUnusableInArc().then(isArc => { + if (isArc) { + const message: EditorSidePanelOpened = { + name: 'EditorSidePanelOpened', + tabId, + }; + chrome.runtime.sendMessage(message); + window.close(); + } + }); + } + const canStyle = await canStyleTab(tab); watchTab(canStyle); diff --git a/src/apps/options/components/basics/ThePosition.vue b/src/apps/options/components/basics/ThePosition.vue index b0991a754..c20aad9f4 100644 --- a/src/apps/options/components/basics/ThePosition.vue +++ b/src/apps/options/components/basics/ThePosition.vue @@ -39,7 +39,11 @@ import { UndockIcon, } from '@stylebot/icons'; import type { StylebotDockLocation, StylebotLayout } from '@stylebot/types'; -import { supportsEditorSidePanel } from '@stylebot/utils'; +import { + isSidePanelIgnoringTabs, + markSidePanelUnusableInArc, + supportsEditorSidePanel, +} from '@stylebot/utils'; type DockOption = { value: StylebotDockLocation; @@ -60,9 +64,13 @@ export default Vue.extend({ UndockIcon, }, + data(): { sidePanelUnusable: boolean } { + return { sidePanelUnusable: false }; + }, + computed: { hasSidePanel(): boolean { - return supportsEditorSidePanel(); + return supportsEditorSidePanel() && !this.sidePanelUnusable; }, layout(): StylebotLayout { @@ -113,6 +121,15 @@ export default Vue.extend({ ]; }, }, + + created() { + const mark = (unusable: boolean) => { + this.sidePanelUnusable = this.sidePanelUnusable || unusable; + }; + + isSidePanelIgnoringTabs().then(mark, () => undefined); + markSidePanelUnusableInArc().then(mark, () => undefined); + }, }); diff --git a/src/apps/popup/App.vue b/src/apps/popup/App.vue index 497cf605f..6a539f09d 100644 --- a/src/apps/popup/App.vue +++ b/src/apps/popup/App.vue @@ -134,6 +134,7 @@ import type { StylebotLayout, StylebotDockLocation, } from '@stylebot/types'; +import { markSidePanelUnusableInArc } from '@stylebot/utils'; import type { PageSupport } from '@stylebot/utils'; export default Vue.extend({ @@ -261,6 +262,8 @@ export default Vue.extend({ }, created() { + markSidePanelUnusableInArc(); + getCurrentTab(async tab => { this.tab = tab; this.pageSupport = await getRestoredPageSupport(tab); diff --git a/src/apps/popup/utils.ts b/src/apps/popup/utils.ts index 1743b59f6..1abfa7851 100644 --- a/src/apps/popup/utils.ts +++ b/src/apps/popup/utils.ts @@ -21,6 +21,7 @@ import { openDonatePage, supportsEditorSidePanel, openEditorSidePanel, + hasArcPalette, getBrowserCommands, getPageSupport, getRestorePendingKey, @@ -156,10 +157,11 @@ export const toggleStylebot = (tab: chrome.tabs.Tab): void => { /** * Opens the editor in the tab's side panel straight from the popup's click, - * as the page couldn't once its gesture is gone; falls back to the page. + * as the page couldn't once its gesture is gone; falls back to the page, as + * in Arc, whose side panel never shows. */ export const openStylebotSidePanel = (tab: chrome.tabs.Tab): void => { - if (!tab.id || !supportsEditorSidePanel()) { + if (!tab.id || !supportsEditorSidePanel() || hasArcPalette()) { toggleStylebot(tab); return; } diff --git a/src/core/utils/editor-side-panel.test.ts b/src/core/utils/editor-side-panel.test.ts new file mode 100644 index 000000000..9d7cbe96c --- /dev/null +++ b/src/core/utils/editor-side-panel.test.ts @@ -0,0 +1,36 @@ +import { + SIDE_PANEL_IGNORED_KEY, + markSidePanelUnusableInArc, +} from './editor-side-panel'; + +beforeEach(() => { + jest.useFakeTimers(); + document.documentElement.removeAttribute('style'); + global.chrome = { + storage: { local: { set: jest.fn(() => Promise.resolve()) } }, + } as unknown as typeof chrome; +}); + +afterEach(() => jest.useRealTimers()); + +describe('markSidePanelUnusableInArc', () => { + it('marks the side panel unusable once Arc adds its palette', async () => { + const marked = markSidePanelUnusableInArc(); + await jest.advanceTimersByTimeAsync(500); + document.documentElement.style.setProperty('--arc-palette-title', '#fff'); + await jest.advanceTimersByTimeAsync(500); + + await expect(marked).resolves.toBe(true); + expect(chrome.storage.local.set).toBeCalledWith({ + [SIDE_PANEL_IGNORED_KEY]: true, + }); + }); + + it('leaves the side panel alone in other browsers', async () => { + const marked = markSidePanelUnusableInArc(); + await jest.advanceTimersByTimeAsync(3000); + + await expect(marked).resolves.toBe(false); + expect(chrome.storage.local.set).not.toBeCalled(); + }); +}); diff --git a/src/core/utils/editor-side-panel.ts b/src/core/utils/editor-side-panel.ts index efc619959..b2e734ccb 100644 --- a/src/core/utils/editor-side-panel.ts +++ b/src/core/utils/editor-side-panel.ts @@ -1,8 +1,8 @@ export const SIDE_PANEL_IGNORED_KEY = 'sidePanelIgnoresTabs'; /** - * Whether this browser was found to ignore per-tab side panels (Vivaldi), - * which the editor needs. Readable from content scripts too. + * Whether this browser was found unable to host the editor in its side + * panel (Vivaldi, Arc). Readable from content scripts too. */ export const isSidePanelIgnoringTabs = async (): Promise => !!(await chrome.storage.local.get(SIDE_PANEL_IGNORED_KEY))[ @@ -16,6 +16,35 @@ export const isSidePanelIgnoringTabs = async (): Promise => export const markSidePanelIgnoringTabs = (): Promise => chrome.storage.local.set({ [SIDE_PANEL_IGNORED_KEY]: true }); +const ARC_PALETTE_VARIABLE = '--arc-palette-title'; +const ARC_WAIT_MS = 2000; +const ARC_POLL_MS = 100; + +/** + * Whether this page shows in Arc, by the palette variables Arc adds to every + * page; its user agent matches Chrome's. + */ +export const hasArcPalette = (): boolean => + !!getComputedStyle(document.documentElement).getPropertyValue( + ARC_PALETTE_VARIABLE + ); + +/** + * Remembers that the side panel can't host the editor when this page shows + * in Arc, which has the side panel API but never shows the panel. Arc adds + * its palette a moment after the page loads, so this waits for it a while. + */ +export const markSidePanelUnusableInArc = async (): Promise => { + for (let waited = 0; waited <= ARC_WAIT_MS; waited += ARC_POLL_MS) { + if (hasArcPalette()) { + await markSidePanelIgnoringTabs(); + return true; + } + await new Promise(resolve => setTimeout(resolve, ARC_POLL_MS)); + } + return false; +}; + /** * Whether this browser can host the editor in its side panel. Content * scripts can't see chrome.sidePanel, so they can't use this. diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 9480cc889..969ed4cb2 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -44,6 +44,8 @@ export { SIDE_PANEL_IGNORED_KEY, isSidePanelIgnoringTabs, markSidePanelIgnoringTabs, + hasArcPalette, + markSidePanelUnusableInArc, configureEditorSidePanel, openEditorSidePanel, closeEditorSidePanel, From 63f99ca56abab888332d40d5adf02948da45b72c Mon Sep 17 00:00:00 2001 From: Ankit Ahuja <61857+ankit@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:49:32 -0400 Subject: [PATCH 3/4] fix: catch Stylebot's shortcuts in the page in Arc, which never passes them on Co-Authored-By: Claude Opus 5.5 --- src/apps/background/listeners.ts | 3 + src/apps/editor/content-script.ts | 2 + src/apps/editor/page-shortcuts.test.ts | 122 +++++++++++++++++++++++ src/apps/editor/page-shortcuts.ts | 111 +++++++++++++++++++++ src/core/types/BackgroundPageMessage.ts | 9 ++ src/core/utils/editor-side-panel.test.ts | 17 +++- src/core/utils/editor-side-panel.ts | 25 +++-- src/core/utils/index.ts | 1 + 8 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 src/apps/editor/page-shortcuts.test.ts create mode 100644 src/apps/editor/page-shortcuts.ts diff --git a/src/apps/background/listeners.ts b/src/apps/background/listeners.ts index d1191e4d5..5caced08f 100644 --- a/src/apps/background/listeners.ts +++ b/src/apps/background/listeners.ts @@ -365,6 +365,9 @@ export const initListeners = (): void => { case 'EditorSidePanelOpened': onEditorSidePanelOpened(message.tabId); break; + case 'RunPageCommand': + handleCommand(message.command, sender.tab); + break; case 'RestoreTab': restoreTab(message.tabId).then(() => sendResponse({ ok: true })); break; diff --git a/src/apps/editor/content-script.ts b/src/apps/editor/content-script.ts index 5f4a2b1f5..d0402ae8f 100644 --- a/src/apps/editor/content-script.ts +++ b/src/apps/editor/content-script.ts @@ -10,6 +10,7 @@ import type { TabMessage } from '@stylebot/types'; import { REMOTE_PAGE_BRIDGE_PORT } from '@stylebot/page-bridge'; +import { initPageShortcuts } from './page-shortcuts'; import type { EditorApp } from './load-editor'; import { isEditorLoading, loadEditor } from './load-editor'; import { loadInspector } from './load-inspector'; @@ -201,4 +202,5 @@ const removeDeadEditor = (): void => { if (isStylableDocument(document.contentType)) { removeDeadEditor(); listen(); + initPageShortcuts(); } diff --git a/src/apps/editor/page-shortcuts.test.ts b/src/apps/editor/page-shortcuts.test.ts new file mode 100644 index 000000000..80861e088 --- /dev/null +++ b/src/apps/editor/page-shortcuts.test.ts @@ -0,0 +1,122 @@ +import { SIDE_PANEL_IGNORED_KEY } from '@stylebot/utils'; + +import { initPageShortcuts, toCombo } from './page-shortcuts'; + +const press = (init: KeyboardEventInit) => { + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ...init, + }); + document.body.dispatchEvent(event); + return event; +}; + +describe('toCombo', () => { + it('names the physical key, whatever Option types', () => { + expect( + toCombo( + new KeyboardEvent('keydown', { + code: 'KeyM', + key: 'Â', + altKey: true, + shiftKey: true, + }) + ) + ).toBe('alt+shift+m'); + }); + + it('orders modifiers as the browser shortcut combos do', () => { + expect( + toCombo( + new KeyboardEvent('keydown', { + code: 'Digit1', + ctrlKey: true, + metaKey: true, + shiftKey: true, + }) + ) + ).toBe('ctrl+shift+command+1'); + }); + + it('ignores a modifier pressed on its own', () => { + expect(toCombo(new KeyboardEvent('keydown', { code: 'ShiftLeft' }))).toBe( + '' + ); + }); +}); + +describe('initPageShortcuts', () => { + let sendMessage: jest.Mock; + + const init = async (arc: boolean) => { + if (arc) { + document.documentElement.style.setProperty('--arc-palette-title', '#fff'); + } + initPageShortcuts(); + await jest.runAllTimersAsync(); + }; + + beforeEach(() => { + jest.useFakeTimers(); + document.documentElement.removeAttribute('style'); + jest + .spyOn(navigator, 'userAgent', 'get') + .mockReturnValue('Mozilla/5.0 Chrome/141.0.0.0 Safari/537.36'); + sendMessage = jest.fn(async (message: { name: string }) => + message.name === 'GetCommands' + ? { stylebot: 'alt+shift+m', style: 'alt+shift+s' } + : undefined + ); + global.chrome = { + runtime: { sendMessage }, + storage: { + local: { + get: jest.fn(async () => ({})), + set: jest.fn(async () => undefined), + }, + }, + } as unknown as typeof chrome; + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + // First, since the listeners the Arc tests add stay on the window. + it('leaves shortcuts to the browser elsewhere', async () => { + await init(false); + + press({ code: 'KeyS', altKey: true, shiftKey: true }); + + expect(sendMessage).not.toBeCalled(); + expect(chrome.storage.local.set).not.toBeCalled(); + }); + + it('has the background carry out a shortcut pressed in Arc', async () => { + await init(true); + + const event = press({ code: 'KeyS', altKey: true, shiftKey: true }); + + expect(event.defaultPrevented).toBe(true); + expect(sendMessage).toBeCalledWith({ + name: 'RunPageCommand', + command: 'style', + }); + expect(chrome.storage.local.set).toBeCalledWith({ + [SIDE_PANEL_IGNORED_KEY]: true, + }); + }); + + it('lets other keys through in Arc', async () => { + await init(true); + + const event = press({ code: 'KeyS', altKey: true }); + + expect(event.defaultPrevented).toBe(false); + expect(sendMessage).not.toBeCalledWith( + expect.objectContaining({ name: 'RunPageCommand' }) + ); + }); +}); diff --git a/src/apps/editor/page-shortcuts.ts b/src/apps/editor/page-shortcuts.ts new file mode 100644 index 000000000..8023d936e --- /dev/null +++ b/src/apps/editor/page-shortcuts.ts @@ -0,0 +1,111 @@ +import type { + GetCommands, + RunPageCommand, + StylebotCommands, +} from '@stylebot/types'; +import { COMMAND_NAMES, markSidePanelUnusableInArc } from '@stylebot/utils'; + +import { whenDomReady } from './utils/dom-ready'; + +const NAMED_CODES: Record = { + Comma: ',', + Period: '.', + PageUp: 'pageup', + PageDown: 'pagedown', + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', + Space: 'space', + Home: 'home', + End: 'end', + Insert: 'insert', + Delete: 'delete', +}; + +/** + * The key a keydown is for, as a Stylebot combo names it. Read from the + * physical key, since Option on macOS turns the typed character into another. + */ +const keyOf = (code: string): string | undefined => { + const letter = /^Key([A-Z])$/.exec(code); + if (letter) { + return letter[1].toLowerCase(); + } + + const digit = /^Digit([0-9])$/.exec(code); + if (digit) { + return digit[1]; + } + + if (/^F([1-9]|1[0-2])$/.test(code)) { + return code.toLowerCase(); + } + + return NAMED_CODES[code]; +}; + +/** + * Turns a keydown into a Stylebot combo ("alt+shift+m"), in the modifier + * order fromBrowserShortcut writes. + */ +export const toCombo = (event: KeyboardEvent): string => { + const key = keyOf(event.code); + if (!key) { + return ''; + } + + return [ + event.ctrlKey && 'ctrl', + event.altKey && 'alt', + event.shiftKey && 'shift', + event.metaKey && 'command', + key, + ] + .filter(Boolean) + .join('+'); +}; + +/** + * Whether the browser registers Stylebot's shortcuts but never passes them + * on. Only Arc does so far, which also can't show the side panel. + */ +const dropsBrowserCommands = async (): Promise => + /Chrome\//.test(navigator.userAgent) && markSidePanelUnusableInArc(); + +/** + * Catches Stylebot's global shortcuts in the page where the browser drops + * them, and has the background carry them out as it would a browser's. + */ +export const initPageShortcuts = (): void => { + whenDomReady(async () => { + if (!(await dropsBrowserCommands())) { + return; + } + + const getCommands: GetCommands = { name: 'GetCommands' }; + const commands: StylebotCommands = await chrome.runtime.sendMessage( + getCommands + ); + + window.addEventListener( + 'keydown', + event => { + const combo = toCombo(event); + const command = COMMAND_NAMES.find( + name => combo && commands[name] === combo + ); + if (!command) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const message: RunPageCommand = { name: 'RunPageCommand', command }; + chrome.runtime.sendMessage(message); + }, + true + ); + }); +}; diff --git a/src/core/types/BackgroundPageMessage.ts b/src/core/types/BackgroundPageMessage.ts index 3082ee50f..06753cf3f 100644 --- a/src/core/types/BackgroundPageMessage.ts +++ b/src/core/types/BackgroundPageMessage.ts @@ -1,6 +1,7 @@ import type { StyleMap } from './styles'; import type { StylebotOptions } from './options'; import type { ReadabilitySettings } from './readability'; +import type { StylebotCommandName } from './commands'; export type SetStyle = { name: 'SetStyle'; @@ -225,6 +226,13 @@ export type EditorSidePanelOpened = { tabId: number; }; +// A global shortcut the page caught itself, where the browser doesn't pass +// it on (Arc). +export type RunPageCommand = { + name: 'RunPageCommand'; + command: StylebotCommandName; +}; + export type CloseEditorSidePanel = { name: 'CloseEditorSidePanel'; tabId?: number; @@ -282,6 +290,7 @@ type BackgroundPageMessage = | OpenEditorSidePanel | CloseEditorSidePanel | EditorSidePanelOpened + | RunPageCommand | RestoreTab; export default BackgroundPageMessage; diff --git a/src/core/utils/editor-side-panel.test.ts b/src/core/utils/editor-side-panel.test.ts index 9d7cbe96c..eb860b627 100644 --- a/src/core/utils/editor-side-panel.test.ts +++ b/src/core/utils/editor-side-panel.test.ts @@ -7,7 +7,12 @@ beforeEach(() => { jest.useFakeTimers(); document.documentElement.removeAttribute('style'); global.chrome = { - storage: { local: { set: jest.fn(() => Promise.resolve()) } }, + storage: { + local: { + get: jest.fn(() => Promise.resolve({})), + set: jest.fn(() => Promise.resolve()), + }, + }, } as unknown as typeof chrome; }); @@ -26,6 +31,16 @@ describe('markSidePanelUnusableInArc', () => { }); }); + it('leaves storage alone when Arc is already known', async () => { + (chrome.storage.local.get as jest.Mock).mockResolvedValue({ + [SIDE_PANEL_IGNORED_KEY]: true, + }); + document.documentElement.style.setProperty('--arc-palette-title', '#fff'); + + await expect(markSidePanelUnusableInArc()).resolves.toBe(true); + expect(chrome.storage.local.set).not.toBeCalled(); + }); + it('leaves the side panel alone in other browsers', async () => { const marked = markSidePanelUnusableInArc(); await jest.advanceTimersByTimeAsync(3000); diff --git a/src/core/utils/editor-side-panel.ts b/src/core/utils/editor-side-panel.ts index b2e734ccb..5d250e574 100644 --- a/src/core/utils/editor-side-panel.ts +++ b/src/core/utils/editor-side-panel.ts @@ -18,7 +18,7 @@ export const markSidePanelIgnoringTabs = (): Promise => const ARC_PALETTE_VARIABLE = '--arc-palette-title'; const ARC_WAIT_MS = 2000; -const ARC_POLL_MS = 100; +const ARC_POLL_MS = 250; /** * Whether this page shows in Arc, by the palette variables Arc adds to every @@ -30,14 +30,12 @@ export const hasArcPalette = (): boolean => ); /** - * Remembers that the side panel can't host the editor when this page shows - * in Arc, which has the side panel API but never shows the panel. Arc adds - * its palette a moment after the page loads, so this waits for it a while. + * Whether this page shows in Arc. Arc adds its palette a moment after the + * page loads, so this waits for it a while. */ -export const markSidePanelUnusableInArc = async (): Promise => { +export const isArcBrowser = async (): Promise => { for (let waited = 0; waited <= ARC_WAIT_MS; waited += ARC_POLL_MS) { if (hasArcPalette()) { - await markSidePanelIgnoringTabs(); return true; } await new Promise(resolve => setTimeout(resolve, ARC_POLL_MS)); @@ -45,6 +43,21 @@ export const markSidePanelUnusableInArc = async (): Promise => { return false; }; +/** + * Remembers that the side panel can't host the editor when this page shows + * in Arc, which has the side panel API but never shows the panel. Answers + * whether it is Arc. + */ +export const markSidePanelUnusableInArc = async (): Promise => { + if (!(await isArcBrowser())) { + return false; + } + if (!(await isSidePanelIgnoringTabs())) { + await markSidePanelIgnoringTabs(); + } + return true; +}; + /** * Whether this browser can host the editor in its side panel. Content * scripts can't see chrome.sidePanel, so they can't use this. diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 969ed4cb2..8a0faa141 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -45,6 +45,7 @@ export { isSidePanelIgnoringTabs, markSidePanelIgnoringTabs, hasArcPalette, + isArcBrowser, markSidePanelUnusableInArc, configureEditorSidePanel, openEditorSidePanel, From 225e84834aa7e47dfb20926a75bae1493863505a Mon Sep 17 00:00:00 2001 From: Ankit Ahuja <61857+ankit@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:05:43 -0400 Subject: [PATCH 4/4] refactor: keep what Vivaldi and Arc get wrong in one module Co-Authored-By: Claude Opus 5.5 --- e2e/editor-sidepanel.spec.ts | 7 +- src/apps/background/options.test.ts | 34 ++++- src/apps/background/options.ts | 22 ++-- src/apps/background/side-panel-check.test.ts | 21 ++- src/apps/background/side-panel-check.ts | 42 +++--- src/apps/editor/page-shortcuts.test.ts | 4 +- src/apps/editor/page-shortcuts.ts | 20 ++- src/apps/editor/utils/side-panel.ts | 15 ++- src/apps/editor/window/index.ts | 24 ++-- .../options/components/basics/ThePosition.vue | 23 ++-- src/apps/popup/App.vue | 4 +- src/apps/popup/utils.ts | 13 +- src/core/utils/chromium-variants.test.ts | 84 ++++++++++++ src/core/utils/chromium-variants.ts | 124 ++++++++++++++++++ src/core/utils/editor-side-panel.test.ts | 51 ------- src/core/utils/editor-side-panel.ts | 60 --------- src/core/utils/index.ts | 17 ++- 17 files changed, 357 insertions(+), 208 deletions(-) create mode 100644 src/core/utils/chromium-variants.test.ts create mode 100644 src/core/utils/chromium-variants.ts delete mode 100644 src/core/utils/editor-side-panel.test.ts diff --git a/e2e/editor-sidepanel.spec.ts b/e2e/editor-sidepanel.spec.ts index bc3602a37..a1d21ab27 100644 --- a/e2e/editor-sidepanel.spec.ts +++ b/e2e/editor-sidepanel.spec.ts @@ -181,12 +181,9 @@ test('a side panel that shows the editor stays the chosen position', async ({ await extension.evaluate(async () => { const items = await chrome.storage.local.get([ 'options', - 'sidePanelIgnoresTabs', + 'chromiumVariant', ]); - return [ - items['sidePanelIgnoresTabs'], - items['options'].layout.dockLocation, - ]; + return [items['chromiumVariant'], items['options'].layout.dockLocation]; }) ).toEqual([undefined, 'sidepanel']); expect(await isSidePanelOpen(extension)).toBe(true); diff --git a/src/apps/background/options.test.ts b/src/apps/background/options.test.ts index 59f4b245a..ae9fe6159 100644 --- a/src/apps/background/options.test.ts +++ b/src/apps/background/options.test.ts @@ -18,11 +18,21 @@ beforeEach(() => { storage: { local: { get: jest.fn( - (key: string) => + (keys: string | Array) => new Promise(resolve => { // Simulate the read taking a tick, so a second set() call can // start before the first one's read-modify-write finishes. - setTimeout(() => resolve({ [key]: store[key] }), 10); + setTimeout( + () => + resolve( + Object.fromEntries( + ([] as Array) + .concat(keys) + .map(key => [key, store[key]]) + ) + ), + 10 + ); }) ), set: jest.fn( @@ -81,7 +91,25 @@ describe('getAll', () => { expect((await getAll()).layout.dockLocation).toBe('right'); }); - it('docks right where the browser ignores per-tab side panels', async () => { + it('docks right where the browser won’t show the side panel', async () => { + store = { + chromiumVariant: 'arc', + options: { + layout: { + width: 360, + adjustPageLayout: false, + dockLocation: 'sidepanel', + }, + }, + }; + (chrome as unknown as { sidePanel: object }).sidePanel = { + open: jest.fn(), + }; + + expect((await getAll()).layout.dockLocation).toBe('right'); + }); + + it('docks right in Vivaldi, as 4.0 recorded it', async () => { store = { sidePanelIgnoresTabs: true, options: { diff --git a/src/apps/background/options.ts b/src/apps/background/options.ts index fb143a624..e1afd65cd 100644 --- a/src/apps/background/options.ts +++ b/src/apps/background/options.ts @@ -1,7 +1,8 @@ import type { StylebotOptions } from '@stylebot/types'; import { defaultOptions } from '@stylebot/settings'; import { - isSidePanelIgnoringTabs, + getChromiumVariant, + doesSidePanelWorkInChromiumVariant, supportsEditorSidePanel, } from '@stylebot/utils'; @@ -13,15 +14,20 @@ export const getAll = async (): Promise => { const items = await chrome.storage.local.get('options'); const options: StylebotOptions = { ...defaultOptions, ...items['options'] }; - // Without a side panel the editor docks in the page, where it used to. - if ( - options.layout.dockLocation === 'sidepanel' && - (!supportsEditorSidePanel() || (await isSidePanelIgnoringTabs())) - ) { - return { ...options, layout: { ...options.layout, dockLocation: 'right' } }; + if (options.layout.dockLocation !== 'sidepanel') { + return options; } - return options; + const variant = await getChromiumVariant(); + const hasSidePanel = + supportsEditorSidePanel() && doesSidePanelWorkInChromiumVariant(variant); + + if (hasSidePanel) { + return options; + } + + // Without a side panel the editor docks in the page, where it used to. + return { ...options, layout: { ...options.layout, dockLocation: 'right' } }; }; export const get = async ( diff --git a/src/apps/background/side-panel-check.test.ts b/src/apps/background/side-panel-check.test.ts index 818f00f3c..a4b8bd6e6 100644 --- a/src/apps/background/side-panel-check.test.ts +++ b/src/apps/background/side-panel-check.test.ts @@ -1,7 +1,4 @@ -import { - isEditorSidePanelOpen, - isSidePanelIgnoringTabs, -} from '@stylebot/utils'; +import { isEditorSidePanelOpen, getChromiumVariant } from '@stylebot/utils'; import { get as getOption, set as setOption } from './options'; import { @@ -13,7 +10,7 @@ import { jest.mock('@stylebot/utils', () => ({ ...jest.requireActual('@stylebot/utils'), isEditorSidePanelOpen: jest.fn(), - isSidePanelIgnoringTabs: jest.fn(), + getChromiumVariant: jest.fn(), supportsEditorSidePanel: () => true, })); jest.mock('./options', () => ({ @@ -45,7 +42,7 @@ beforeEach(() => { jest.clearAllMocks(); (getOption as jest.Mock).mockResolvedValue(layout); (isEditorSidePanelOpen as jest.Mock).mockResolvedValue(false); - (isSidePanelIgnoringTabs as jest.Mock).mockResolvedValue(false); + (getChromiumVariant as jest.Mock).mockResolvedValue(undefined); global.chrome = { tabs: { sendMessage: jest.fn() }, runtime: {}, @@ -74,9 +71,9 @@ describe('onEditorSidePanelOpened', () => { }); it('docks the editor in the page once the panel reports it is hosted as a tab', async () => { - (isSidePanelIgnoringTabs as jest.Mock) - .mockResolvedValueOnce(false) - .mockResolvedValue(true); + (getChromiumVariant as jest.Mock) + .mockResolvedValueOnce(undefined) + .mockResolvedValue('vivaldi'); await check(); @@ -88,7 +85,7 @@ describe('onEditorSidePanelOpened', () => { }); it('opens the editor in the page at once when the browser is already known', async () => { - (isSidePanelIgnoringTabs as jest.Mock).mockResolvedValue(true); + (getChromiumVariant as jest.Mock).mockResolvedValue('arc'); (getOption as jest.Mock).mockResolvedValue({ ...layout, dockLocation: 'right', @@ -142,11 +139,11 @@ describe('opensEditorInSidePanel', () => { expect(opensEditorInSidePanel()).toBe(false); }); - it('stays in the page once the browser is found unable to host it', async () => { + it('stays in the page once the browser is found not to show it', async () => { initSidePanelCheck(); await jest.runAllTimersAsync(); - onChanged({ sidePanelIgnoresTabs: { newValue: true } }, 'local'); + onChanged({ chromiumVariant: { newValue: 'arc' } }, 'local'); expect(opensEditorInSidePanel()).toBe(false); }); }); diff --git a/src/apps/background/side-panel-check.ts b/src/apps/background/side-panel-check.ts index a3b11be85..b2127789a 100644 --- a/src/apps/background/side-panel-check.ts +++ b/src/apps/background/side-panel-check.ts @@ -1,27 +1,30 @@ import type { OpenStylebot, StylebotLayout } from '@stylebot/types'; import { - SIDE_PANEL_IGNORED_KEY, + CHROMIUM_VARIANT_KEY, + getChromiumVariant, isEditorSidePanelOpen, - isSidePanelIgnoringTabs, openEditorSidePanel, + doesSidePanelWorkInChromiumVariant, supportsEditorSidePanel, } from '@stylebot/utils'; +import type { ChromiumVariant } from '@stylebot/utils'; import { get as getOption, set as setOption } from './options'; const LOAD_TIMEOUT_MS = 2000; const POLL_MS = 250; -let ignoresTabs = false; +let chromiumVariant: ChromiumVariant | undefined; let isDock: boolean | undefined; /** * Whether the editor can open in the browser's side panel: it has one, and - * hasn't been found to ignore per-tab panels. Answers at once, so callers - * can still open the panel during the user's gesture. + * shows it. Answers at once, so callers can still open the panel during the + * user's gesture. */ export const canUseEditorSidePanel = (): boolean => - supportsEditorSidePanel() && !ignoresTabs; + supportsEditorSidePanel() && + doesSidePanelWorkInChromiumVariant(chromiumVariant); /** * Whether the editor opens in the side panel. Says yes until the dock is @@ -35,9 +38,10 @@ export const opensEditorInSidePanel = (): boolean => * with storage. */ export const initSidePanelCheck = (): void => { - isSidePanelIgnoringTabs().then(value => { - ignoresTabs = value; + getChromiumVariant().then(variant => { + chromiumVariant = variant; }); + getOption('layout').then(layout => { isDock = (layout as StylebotLayout).dockLocation === 'sidepanel'; }); @@ -46,9 +50,11 @@ export const initSidePanelCheck = (): void => { if (area !== 'local') { return; } - if (changes[SIDE_PANEL_IGNORED_KEY]) { - ignoresTabs = !!changes[SIDE_PANEL_IGNORED_KEY].newValue; + + if (changes[CHROMIUM_VARIANT_KEY]) { + chromiumVariant = changes[CHROMIUM_VARIANT_KEY].newValue; } + if (changes['options']) { isDock = changes['options'].newValue?.layout?.dockLocation === 'sidepanel'; @@ -57,12 +63,12 @@ export const initSidePanelCheck = (): void => { }; /** - * Waits for the side panel to report that the browser hosts it as a tab, - * which it does as it loads (Vivaldi), or for the editor to show up in it. + * Waits for the side panel to find, as it loads, that the browser won't show + * it (Vivaldi, Arc), or for the editor to show up in it. */ -const waitForPanelHostedAsTab = async (tabId: number): Promise => { +const waitForHiddenPanel = async (tabId: number): Promise => { for (let waited = 0; waited < LOAD_TIMEOUT_MS; waited += POLL_MS) { - if (await isSidePanelIgnoringTabs()) { + if (!doesSidePanelWorkInChromiumVariant(await getChromiumVariant())) { return true; } if (await isEditorSidePanelOpen(tabId)) { @@ -74,12 +80,12 @@ const waitForPanelHostedAsTab = async (tabId: number): Promise => { }; /** - * Follows up a side panel just opened on the tab. In a browser that ignores - * per-tab panels (Vivaldi) the panel closes itself, so the editor docks in - * the page from now on, and opens there. + * Follows up a side panel just opened on the tab. In a browser that won't + * show it (Vivaldi, Arc) the panel closes itself, so the editor docks in the + * page from now on, and opens there. */ export const onEditorSidePanelOpened = async (tabId: number): Promise => { - if (!(await waitForPanelHostedAsTab(tabId))) { + if (!(await waitForHiddenPanel(tabId))) { return; } diff --git a/src/apps/editor/page-shortcuts.test.ts b/src/apps/editor/page-shortcuts.test.ts index 80861e088..79e3b85ae 100644 --- a/src/apps/editor/page-shortcuts.test.ts +++ b/src/apps/editor/page-shortcuts.test.ts @@ -1,4 +1,4 @@ -import { SIDE_PANEL_IGNORED_KEY } from '@stylebot/utils'; +import { CHROMIUM_VARIANT_KEY } from '@stylebot/utils'; import { initPageShortcuts, toCombo } from './page-shortcuts'; @@ -105,7 +105,7 @@ describe('initPageShortcuts', () => { command: 'style', }); expect(chrome.storage.local.set).toBeCalledWith({ - [SIDE_PANEL_IGNORED_KEY]: true, + [CHROMIUM_VARIANT_KEY]: 'arc', }); }); diff --git a/src/apps/editor/page-shortcuts.ts b/src/apps/editor/page-shortcuts.ts index 8023d936e..1476b49f8 100644 --- a/src/apps/editor/page-shortcuts.ts +++ b/src/apps/editor/page-shortcuts.ts @@ -3,7 +3,11 @@ import type { RunPageCommand, StylebotCommands, } from '@stylebot/types'; -import { COMMAND_NAMES, markSidePanelUnusableInArc } from '@stylebot/utils'; +import { + COMMAND_NAMES, + detectChromiumVariant, + doShortcutsWorkInChromiumVariant, +} from '@stylebot/utils'; import { whenDomReady } from './utils/dom-ready'; @@ -67,19 +71,13 @@ export const toCombo = (event: KeyboardEvent): string => { }; /** - * Whether the browser registers Stylebot's shortcuts but never passes them - * on. Only Arc does so far, which also can't show the side panel. - */ -const dropsBrowserCommands = async (): Promise => - /Chrome\//.test(navigator.userAgent) && markSidePanelUnusableInArc(); - -/** - * Catches Stylebot's global shortcuts in the page where the browser drops - * them, and has the background carry them out as it would a browser's. + * Catches Stylebot's global shortcuts in the page where the browser doesn't + * pass them on, and has the background carry them out as it would a + * browser's. */ export const initPageShortcuts = (): void => { whenDomReady(async () => { - if (!(await dropsBrowserCommands())) { + if (doShortcutsWorkInChromiumVariant(await detectChromiumVariant())) { return; } diff --git a/src/apps/editor/utils/side-panel.ts b/src/apps/editor/utils/side-panel.ts index 54886e341..0aff48a24 100644 --- a/src/apps/editor/utils/side-panel.ts +++ b/src/apps/editor/utils/side-panel.ts @@ -1,13 +1,17 @@ -import { isSidePanelIgnoringTabs } from '@stylebot/utils'; +import { + getChromiumVariant, + doesSidePanelWorkInChromiumVariant, +} from '@stylebot/utils'; +import type { ChromiumVariant } from '@stylebot/utils'; -let ignoresTabs = false; +let chromiumVariant: ChromiumVariant | undefined; /** - * Reads whether this browser was found to ignore per-tab side panels, for + * Reads whether this is a browser that won't show the side panel, for * hasSidePanel. The store calls this as it initializes. */ export const loadSidePanelSupport = async (): Promise => { - ignoresTabs = await isSidePanelIgnoringTabs().catch(() => false); + chromiumVariant = await getChromiumVariant().catch(() => undefined); }; /** @@ -16,4 +20,5 @@ export const loadSidePanelSupport = async (): Promise => { * and content scripts can't see chrome.sidePanel, so this goes by the browser. */ export const hasSidePanel = (): boolean => - /Chrome\//.test(navigator.userAgent) && !ignoresTabs; + /Chrome\//.test(navigator.userAgent) && + doesSidePanelWorkInChromiumVariant(chromiumVariant); diff --git a/src/apps/editor/window/index.ts b/src/apps/editor/window/index.ts index a2e4509ea..16e08a35d 100644 --- a/src/apps/editor/window/index.ts +++ b/src/apps/editor/window/index.ts @@ -4,9 +4,10 @@ import { setPageBridge, RemotePageBridge } from '@stylebot/page-bridge'; import { createStore, mountEditor } from '@stylebot/editor'; import type { EditorSidePanelOpened } from '@stylebot/types'; import { + detectChromiumVariant, + detectVivaldiSidePanel, getPageSupport, - markSidePanelIgnoringTabs, - markSidePanelUnusableInArc, + doesSidePanelWorkInChromiumVariant, } from '@stylebot/utils'; import { openEditorWindow } from '../utils/chrome'; @@ -135,28 +136,27 @@ const init = async (): Promise => { return; } - const [tab, hostTab] = await Promise.all([ + const [tab, inVivaldi] = await Promise.all([ chrome.tabs.get(tabId).catch(() => undefined), - host === 'sidepanel' ? chrome.tabs.getCurrent() : undefined, + host === 'sidepanel' && detectVivaldiSidePanel(), ]); - // Vivaldi hosts side panels as tabs and shares one panel across them all, - // which the editor can't work in; the background then docks it in the page. - if (hostTab) { - await markSidePanelIgnoringTabs(); + // The background docks the editor in the page instead. + if (inVivaldi) { window.close(); return; } - // Arc loads the side panel but never shows it. Told so, the background - // docks the editor in the page and opens it there. + // A browser this page can tell (Arc) loads the panel without showing it, + // so the background has to be told to dock the editor in the page. if (host === 'sidepanel') { - markSidePanelUnusableInArc().then(isArc => { - if (isArc) { + detectChromiumVariant().then(variant => { + if (!doesSidePanelWorkInChromiumVariant(variant)) { const message: EditorSidePanelOpened = { name: 'EditorSidePanelOpened', tabId, }; + chrome.runtime.sendMessage(message); window.close(); } diff --git a/src/apps/options/components/basics/ThePosition.vue b/src/apps/options/components/basics/ThePosition.vue index c20aad9f4..e8adef136 100644 --- a/src/apps/options/components/basics/ThePosition.vue +++ b/src/apps/options/components/basics/ThePosition.vue @@ -40,10 +40,12 @@ import { } from '@stylebot/icons'; import type { StylebotDockLocation, StylebotLayout } from '@stylebot/types'; import { - isSidePanelIgnoringTabs, - markSidePanelUnusableInArc, + detectChromiumVariant, + getChromiumVariant, + doesSidePanelWorkInChromiumVariant, supportsEditorSidePanel, } from '@stylebot/utils'; +import type { ChromiumVariant } from '@stylebot/utils'; type DockOption = { value: StylebotDockLocation; @@ -64,13 +66,16 @@ export default Vue.extend({ UndockIcon, }, - data(): { sidePanelUnusable: boolean } { - return { sidePanelUnusable: false }; + data(): { chromiumVariant: ChromiumVariant | undefined } { + return { chromiumVariant: undefined }; }, computed: { hasSidePanel(): boolean { - return supportsEditorSidePanel() && !this.sidePanelUnusable; + return ( + supportsEditorSidePanel() && + doesSidePanelWorkInChromiumVariant(this.chromiumVariant) + ); }, layout(): StylebotLayout { @@ -123,12 +128,12 @@ export default Vue.extend({ }, created() { - const mark = (unusable: boolean) => { - this.sidePanelUnusable = this.sidePanelUnusable || unusable; + const setChromiumVariant = (variant: ChromiumVariant | undefined) => { + this.chromiumVariant = this.chromiumVariant ?? variant; }; - isSidePanelIgnoringTabs().then(mark, () => undefined); - markSidePanelUnusableInArc().then(mark, () => undefined); + getChromiumVariant().then(setChromiumVariant, () => undefined); + detectChromiumVariant().then(setChromiumVariant, () => undefined); }, }); diff --git a/src/apps/popup/App.vue b/src/apps/popup/App.vue index 6a539f09d..4a9c9c6d7 100644 --- a/src/apps/popup/App.vue +++ b/src/apps/popup/App.vue @@ -134,7 +134,7 @@ import type { StylebotLayout, StylebotDockLocation, } from '@stylebot/types'; -import { markSidePanelUnusableInArc } from '@stylebot/utils'; +import { detectChromiumVariant } from '@stylebot/utils'; import type { PageSupport } from '@stylebot/utils'; export default Vue.extend({ @@ -262,7 +262,7 @@ export default Vue.extend({ }, created() { - markSidePanelUnusableInArc(); + detectChromiumVariant(); getCurrentTab(async tab => { this.tab = tab; diff --git a/src/apps/popup/utils.ts b/src/apps/popup/utils.ts index 1abfa7851..20c45905f 100644 --- a/src/apps/popup/utils.ts +++ b/src/apps/popup/utils.ts @@ -21,7 +21,8 @@ import { openDonatePage, supportsEditorSidePanel, openEditorSidePanel, - hasArcPalette, + detectChromiumVariantSync, + doesSidePanelWorkInChromiumVariant, getBrowserCommands, getPageSupport, getRestorePendingKey, @@ -157,11 +158,15 @@ export const toggleStylebot = (tab: chrome.tabs.Tab): void => { /** * Opens the editor in the tab's side panel straight from the popup's click, - * as the page couldn't once its gesture is gone; falls back to the page, as - * in Arc, whose side panel never shows. + * as the page couldn't once its gesture is gone; falls back to the page, + * as where the browser won't show the panel. */ export const openStylebotSidePanel = (tab: chrome.tabs.Tab): void => { - if (!tab.id || !supportsEditorSidePanel() || hasArcPalette()) { + if ( + !tab.id || + !supportsEditorSidePanel() || + !doesSidePanelWorkInChromiumVariant(detectChromiumVariantSync()) + ) { toggleStylebot(tab); return; } diff --git a/src/core/utils/chromium-variants.test.ts b/src/core/utils/chromium-variants.test.ts new file mode 100644 index 000000000..27df9e8bb --- /dev/null +++ b/src/core/utils/chromium-variants.test.ts @@ -0,0 +1,84 @@ +import { + CHROMIUM_VARIANT_KEY, + detectChromiumVariant, + getChromiumVariant, + doShortcutsWorkInChromiumVariant, + doesSidePanelWorkInChromiumVariant, +} from './chromium-variants'; + +let store: Record; + +beforeEach(() => { + jest.useFakeTimers(); + document.documentElement.removeAttribute('style'); + jest + .spyOn(navigator, 'userAgent', 'get') + .mockReturnValue('Mozilla/5.0 Chrome/141.0.0.0 Safari/537.36'); + store = {}; + global.chrome = { + storage: { + local: { + get: jest.fn(async (keys: Array) => + Object.fromEntries(keys.map(key => [key, store[key]])) + ), + set: jest.fn(async (items: Record) => { + Object.assign(store, items); + }), + }, + }, + } as unknown as typeof chrome; +}); + +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +describe('what each variant breaks', () => { + it('works the side panel everywhere but Vivaldi and Arc', () => { + expect(doesSidePanelWorkInChromiumVariant(undefined)).toBe(true); + expect(doesSidePanelWorkInChromiumVariant('vivaldi')).toBe(false); + expect(doesSidePanelWorkInChromiumVariant('arc')).toBe(false); + }); + + it('passes shortcuts on everywhere but Arc', () => { + expect(doShortcutsWorkInChromiumVariant(undefined)).toBe(true); + expect(doShortcutsWorkInChromiumVariant('vivaldi')).toBe(true); + expect(doShortcutsWorkInChromiumVariant('arc')).toBe(false); + }); +}); + +describe('getChromiumVariant', () => { + it('reads Vivaldi as 4.0 recorded it', async () => { + store = { sidePanelIgnoresTabs: true }; + await expect(getChromiumVariant()).resolves.toBe('vivaldi'); + }); +}); + +describe('detectChromiumVariant', () => { + it('finds and remembers Arc once it adds its palette', async () => { + const found = detectChromiumVariant(); + await jest.advanceTimersByTimeAsync(500); + document.documentElement.style.setProperty('--arc-palette-title', '#fff'); + await jest.advanceTimersByTimeAsync(500); + + await expect(found).resolves.toBe('arc'); + expect(store[CHROMIUM_VARIANT_KEY]).toBe('arc'); + }); + + it('leaves storage alone when Arc is already known', async () => { + store = { [CHROMIUM_VARIANT_KEY]: 'arc' }; + document.documentElement.style.setProperty('--arc-palette-title', '#fff'); + + await expect(detectChromiumVariant()).resolves.toBe('arc'); + expect(chrome.storage.local.set).not.toBeCalled(); + }); + + it('finds nothing in other browsers', async () => { + const found = detectChromiumVariant(); + await jest.advanceTimersByTimeAsync(3000); + + await expect(found).resolves.toBeUndefined(); + expect(chrome.storage.local.set).not.toBeCalled(); + }); +}); diff --git a/src/core/utils/chromium-variants.ts b/src/core/utils/chromium-variants.ts new file mode 100644 index 000000000..1d6459662 --- /dev/null +++ b/src/core/utils/chromium-variants.ts @@ -0,0 +1,124 @@ +/* + * Chromium variants that have Chrome's extension APIs but don't honour them, + * and the only place Stylebot names them. Each is found once, from a page, + * and remembered for every other context. + */ + +export type ChromiumVariant = 'vivaldi' | 'arc'; + +type BrokenFeatures = { + sidePanel: boolean; + shortcuts: boolean; +}; + +const BROKEN_FEATURES: Record = { + // Hosts side panels as tabs, one shared across all tabs. + vivaldi: { sidePanel: true, shortcuts: false }, + // Loads side panels but never shows them, and registers extension + // shortcuts but never passes them on. + arc: { sidePanel: true, shortcuts: true }, +}; + +export const CHROMIUM_VARIANT_KEY = 'chromiumVariant'; + +// What 4.0.0-4.0.3 stored on finding Vivaldi. +const LEGACY_VIVALDI_KEY = 'sidePanelIgnoresTabs'; + +/** + * Whether the editor's side panel works in the variant, or in Chrome and + * the rest when there is none. + */ +export const doesSidePanelWorkInChromiumVariant = ( + variant: ChromiumVariant | undefined +): boolean => !variant || !BROKEN_FEATURES[variant].sidePanel; + +/** + * Whether Stylebot's shortcuts work in the variant, or in Chrome and the + * rest when there is none. + */ +export const doShortcutsWorkInChromiumVariant = ( + variant: ChromiumVariant | undefined +): boolean => !variant || !BROKEN_FEATURES[variant].shortcuts; + +/** + * The Chromium variant Stylebot found itself in, if any. + */ +export const getChromiumVariant = async (): Promise< + ChromiumVariant | undefined +> => { + const items = await chrome.storage.local.get([ + CHROMIUM_VARIANT_KEY, + LEGACY_VIVALDI_KEY, + ]); + + return ( + items[CHROMIUM_VARIANT_KEY] ?? + (items[LEGACY_VIVALDI_KEY] ? 'vivaldi' : undefined) + ); +}; + +/** + * Remembers the Chromium variant Stylebot found itself in. + */ +export const rememberChromiumVariant = async ( + variant: ChromiumVariant +): Promise => { + if ((await getChromiumVariant()) !== variant) { + await chrome.storage.local.set({ [CHROMIUM_VARIANT_KEY]: variant }); + } +}; + +/** + * The Chromium variant showing this page, as far as the page can tell at + * once: only Arc, by the palette variables it adds to every page, since its + * user agent matches Chrome's. Arc adds them a moment after the page loads. + */ +export const detectChromiumVariantSync = (): ChromiumVariant | undefined => { + const style = getComputedStyle(document.documentElement); + + if (style.getPropertyValue('--arc-palette-title')) { + return 'arc'; + } + + return undefined; +}; + +/** + * Finds the Chromium variant showing this page and remembers it, waiting a + * while for Arc to add its palette. + */ +export const detectChromiumVariant = async (): Promise< + ChromiumVariant | undefined +> => { + if (!/Chrome\//.test(navigator.userAgent)) { + return undefined; + } + + const waitMs = 2000; + const pollMs = 250; + + for (let waited = 0; waited <= waitMs; waited += pollMs) { + const variant = detectChromiumVariantSync(); + if (variant) { + await rememberChromiumVariant(variant); + return variant; + } + + await new Promise(resolve => setTimeout(resolve, pollMs)); + } + + return undefined; +}; + +/** + * Finds whether the side panel page was opened as a tab, which is how + * Vivaldi hosts side panels, and remembers it. + */ +export const detectVivaldiSidePanel = async (): Promise => { + if (!(await chrome.tabs.getCurrent())) { + return false; + } + + await rememberChromiumVariant('vivaldi'); + return true; +}; diff --git a/src/core/utils/editor-side-panel.test.ts b/src/core/utils/editor-side-panel.test.ts deleted file mode 100644 index eb860b627..000000000 --- a/src/core/utils/editor-side-panel.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - SIDE_PANEL_IGNORED_KEY, - markSidePanelUnusableInArc, -} from './editor-side-panel'; - -beforeEach(() => { - jest.useFakeTimers(); - document.documentElement.removeAttribute('style'); - global.chrome = { - storage: { - local: { - get: jest.fn(() => Promise.resolve({})), - set: jest.fn(() => Promise.resolve()), - }, - }, - } as unknown as typeof chrome; -}); - -afterEach(() => jest.useRealTimers()); - -describe('markSidePanelUnusableInArc', () => { - it('marks the side panel unusable once Arc adds its palette', async () => { - const marked = markSidePanelUnusableInArc(); - await jest.advanceTimersByTimeAsync(500); - document.documentElement.style.setProperty('--arc-palette-title', '#fff'); - await jest.advanceTimersByTimeAsync(500); - - await expect(marked).resolves.toBe(true); - expect(chrome.storage.local.set).toBeCalledWith({ - [SIDE_PANEL_IGNORED_KEY]: true, - }); - }); - - it('leaves storage alone when Arc is already known', async () => { - (chrome.storage.local.get as jest.Mock).mockResolvedValue({ - [SIDE_PANEL_IGNORED_KEY]: true, - }); - document.documentElement.style.setProperty('--arc-palette-title', '#fff'); - - await expect(markSidePanelUnusableInArc()).resolves.toBe(true); - expect(chrome.storage.local.set).not.toBeCalled(); - }); - - it('leaves the side panel alone in other browsers', async () => { - const marked = markSidePanelUnusableInArc(); - await jest.advanceTimersByTimeAsync(3000); - - await expect(marked).resolves.toBe(false); - expect(chrome.storage.local.set).not.toBeCalled(); - }); -}); diff --git a/src/core/utils/editor-side-panel.ts b/src/core/utils/editor-side-panel.ts index 5d250e574..ca0e42cfd 100644 --- a/src/core/utils/editor-side-panel.ts +++ b/src/core/utils/editor-side-panel.ts @@ -1,63 +1,3 @@ -export const SIDE_PANEL_IGNORED_KEY = 'sidePanelIgnoresTabs'; - -/** - * Whether this browser was found unable to host the editor in its side - * panel (Vivaldi, Arc). Readable from content scripts too. - */ -export const isSidePanelIgnoringTabs = async (): Promise => - !!(await chrome.storage.local.get(SIDE_PANEL_IGNORED_KEY))[ - SIDE_PANEL_IGNORED_KEY - ]; - -/** - * Remembers that this browser ignores per-tab side panels, so the editor - * stays in the page from now on. - */ -export const markSidePanelIgnoringTabs = (): Promise => - chrome.storage.local.set({ [SIDE_PANEL_IGNORED_KEY]: true }); - -const ARC_PALETTE_VARIABLE = '--arc-palette-title'; -const ARC_WAIT_MS = 2000; -const ARC_POLL_MS = 250; - -/** - * Whether this page shows in Arc, by the palette variables Arc adds to every - * page; its user agent matches Chrome's. - */ -export const hasArcPalette = (): boolean => - !!getComputedStyle(document.documentElement).getPropertyValue( - ARC_PALETTE_VARIABLE - ); - -/** - * Whether this page shows in Arc. Arc adds its palette a moment after the - * page loads, so this waits for it a while. - */ -export const isArcBrowser = async (): Promise => { - for (let waited = 0; waited <= ARC_WAIT_MS; waited += ARC_POLL_MS) { - if (hasArcPalette()) { - return true; - } - await new Promise(resolve => setTimeout(resolve, ARC_POLL_MS)); - } - return false; -}; - -/** - * Remembers that the side panel can't host the editor when this page shows - * in Arc, which has the side panel API but never shows the panel. Answers - * whether it is Arc. - */ -export const markSidePanelUnusableInArc = async (): Promise => { - if (!(await isArcBrowser())) { - return false; - } - if (!(await isSidePanelIgnoringTabs())) { - await markSidePanelIgnoringTabs(); - } - return true; -}; - /** * Whether this browser can host the editor in its side panel. Content * scripts can't see chrome.sidePanel, so they can't use this. diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 8a0faa141..5b409da75 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -41,17 +41,22 @@ export { } from './open-page'; export { supportsEditorSidePanel, - SIDE_PANEL_IGNORED_KEY, - isSidePanelIgnoringTabs, - markSidePanelIgnoringTabs, - hasArcPalette, - isArcBrowser, - markSidePanelUnusableInArc, configureEditorSidePanel, openEditorSidePanel, closeEditorSidePanel, isEditorSidePanelOpen, } from './editor-side-panel'; +export { + CHROMIUM_VARIANT_KEY, + doesSidePanelWorkInChromiumVariant, + doShortcutsWorkInChromiumVariant, + getChromiumVariant, + rememberChromiumVariant, + detectChromiumVariantSync, + detectChromiumVariant, + detectVivaldiSidePanel, +} from './chromium-variants'; +export type { ChromiumVariant } from './chromium-variants'; export { fromBrowserShortcut, toBrowserShortcut } from './browser-shortcut'; export { COMMAND_NAMES,