Chrome拡張(MV3)でDocument Picture-in-Pictureの「常に手前」ウィンドウを作る
導入
Chrome拡張で「他のアプリより手前に残り続ける小さなウィンドウ」を出したい、という要件に当たりました。ポップアップはフォーカスを失った瞬間に閉じますし、タブの中にDOMを足す方式だと、そのタブを離れた途端に消えてしまいます。
残る選択肢は二つでした。ネイティブアプリを別に用意してNative Messagingで値を渡すか、Chromeの Document Picture-in-Picture API(documentPictureInPicture.requestWindow())で常時手前のウィンドウを開くか。後者を選びました。インストールが拡張ひとつで済み、コード署名も要らないからです。
ただ、やってみると素直には進みませんでした。大きく二つで、ひとつはポップアップのクリックではPiPを開けないこと(ユーザー操作の制約)、もうひとつはウィンドウのサイズを思ったとおりに指定できないことです。特に後者は、resizeTo() に渡すのが「外側のサイズ」だという点を片方の軸でしか考慮していなかったために、横幅が意図より狭くなっていました。この記事では、その二つをどう回避・修正したかを、実際のコードを引きながら書きます。
計測はWindows 11・Chrome 156・表示倍率100%と150%で行っています。環境依存の数値がいくつも出てくるので、未確認の部分は未確認として書きます。
なぜコンテンツスクリプトではなく拡張ページから開くのか
PiPウィンドウは「それを開いたドキュメント」に紐づきます。開いたドキュメントが消えればPiPも閉じます。つまり、どのページから開くかは寿命の設計そのものです。
最初はアクティブタブにコンテンツスクリプトを注入して、そこから requestWindow() を呼んでいました。これは動きはするのですが、副作用が多いです。
- そのタブを閉じればPiPも閉じる
-
chrome://ページやChrome ウェブストア上では注入できないので、そこがアクティブだと何も起きない - 注入のために
activeTabもしくはホスト権限が必要になる - PiPのタイトルバーに、注入先サイトのアドレスが出る
そこで、PiPを開く主体を拡張自身のページに移しました。chrome.windows.create() で type: 'popup' の小さなウィンドウを開き、その中の拡張ページから requestWindow() を呼びます。
async function createOverlayWindow() {
const page = {url: chrome.runtime.getURL(OVERLAY_WINDOW_PAGE), type: 'popup', focused: true};
let bounds = null;
try { bounds = savedBounds((await chrome.storage.local.get(OVERLAY_BOUNDS_KEY))[OVERLAY_BOUNDS_KEY]); }
catch (_) {}
let win;
try { win = await chrome.windows.create({...page, ...(bounds || OVERLAY_WINDOW_SIZE)}); }
catch (error) {
if (!bounds) throw error;
// Chrome 102+ refuses a window less than half on screen (left on a
// monitor that is gone since): the default size, placed by Chrome.
win = await chrome.windows.create({...page, ...OVERLAY_WINDOW_SIZE});
}
await rememberOverlayWindow(win?.id ?? null);
}
この一段を挟んだ結果、どのタブが前面にあっても同じ動作になり、どのタブにも注入せずに済み、タイトルバーには拡張名と chrome-extension://<ID> が出るようになりました。マニフェストから activeTab を外せたのも、この変更の後です。
"permissions": ["storage", "alarms", "scripting"],
"optional_permissions": ["nativeMessaging"],
"optional_host_permissions": [
"https://claude.ai/*",
"https://chatgpt.com/*",
"https://gemini.google.com/*"
],
保存された位置で開き直すために、ウィンドウ側は自分の位置を覚えます。最小化や最大化の状態を保存すると次に妙な場所・妙な大きさで開くので、state === 'normal' のときだけ保存しています。
chrome.windows.onBoundsChanged.addListener(async (win) => {
const own = await ownWindow;
if (!own || win?.id !== own.id || win.type !== 'popup' || win.state !== 'normal') return;
const bounds = { left: win.left, top: win.top, width: win.width, height: win.height };
if (!Object.values(bounds).every(Number.isInteger)) return;
try { await chrome.storage.local.set({ overlay_window_bounds: bounds }); }
catch (_) {}
});
ポップアップのクリックではPiPを開けない
ここが最初の壁でした。やりたかったのは「ポップアップのボタンを一度押したらPiPが出る」です。できませんでした。理由は二つあります。
-
requestWindow()はそれを呼ぶページ自身のユーザー操作を要求します。ポップアップでのクリックは、別のページ(拡張ウィンドウ)には引き継がれません。メッセージで「開いて」と伝えても、受け取った側にはuser activationがないので弾かれます。 - ポップアップはフォーカスを失うと閉じます。閉じたページはPiPのopenerになれないので、そもそもポップアップ自身がPiPを開く主体にはなれません。
結果として、操作は二段になります。ポップアップの「オーバーレイを開く」で拡張ウィンドウが開き、そのウィンドウの中のボタンを押すとPiPが開く、という形です。
二段になるぶん、二度目の操作をできるだけ軽くしました。ウィンドウが開いた時・前面に来た時・復元された時は、毎回このボタンにフォーカスを当てます。つまりウィンドウが出たらEnter(またはSpace)でそのままPiPが開きます。
// "Open overlay", then Enter (or Space): the button has the focus whenever this
// window does — opened, brought forward or restored. The popup's click can't
// open the PiP from here (requestWindow wants the user's action in this page);
// a key press here is one, as a click is. A key held down since the popup
// repeats into this window: only a fresh press counts.
const focusPin = () => { if (!pinButton.hidden) pinButton.focus(); };
window.addEventListener('focus', () => { focusPin(); askForFresh(); });
pinButton.addEventListener('keydown', (event) => { if (event.repeat) event.preventDefault(); });
最後の1行は、実際にテストして気づいた点です。ポップアップでEnterを押したまま指を離さないと、新しく開いたウィンドウにキーのオートリピートが流れ込みます。リピートだけで開いてしまうと「押していないのに開いた」ように見えるので、event.repeat は無視しています(リピートのみでは開かないことはCDPのキーイベントで確認しました。物理キーボードでは未確認です)。
PiPを開く側は次のようになっています。すでに開いていれば二つ目を作らず、ウィンドウだけもう一度退きます。
// Already open (this window was brought back from the popup): no second one,
// this window just steps aside again.
pinButton.addEventListener('click', async () => {
if (!documentPictureInPicture.window) {
const opened = await openPip(documentPictureInPicture, lastData, document);
if (!opened) return;
pip = opened;
// Closed with its X or its "back to tab" button — or by Chrome: this
// window comes back, so the values stay in sight.
opened.window.addEventListener('pagehide', () => {
if (pip === opened) pip = null;
setOwnState('normal');
});
}
await setOwnState('minimized');
});
// Chrome before 116 has no Document Picture-in-Picture: this window only.
if (!('documentPictureInPicture' in window)) pinButton.hidden = true;
PiPが開いたら、元のウィンドウは最小化して退かせます。PiPが閉じたら(×ボタン、「タブに戻る」ボタン、Chromeが閉じた場合のいずれでも)pagehide で元のウィンドウを戻します。最小化の対象を自分のウィンドウに限るため、type が popup のときだけ動かしています。拡張ページを普通のタブで開いていた場合に、そのブラウザウィンドウを最小化してしまわないためです。
const ownWindow = chrome.windows.getCurrent().catch(() => null);
async function setOwnState(state) {
const win = await ownWindow;
if (win?.type !== 'popup') return;
try { await chrome.windows.update(win.id, state === 'minimized' ? { state } : { state, focused: true }); }
catch (_) { /* the window is closing */ }
}
開いた瞬間のサイズは「開く前」に決めるしかない
次がサイズの話です。requestWindow({width, height}) は内側(コンテンツ)のサイズを取ります。ここまでは素直です。
問題は、中身の高さがデータ次第で変わることでした。表示するサービスが1件なら低く、3件なら高くなります。開いてから測って resizeTo() で直せばいい——と思ったのですが、できませんでした。requestWindow() がそのクリックのuser activationを消費してしまうため、開いた直後の resizeTo() は NotAllowedError で拒否されます(実測)。
そこで、開く前に、開く側のページで中身の高さを測ることにしました。開く側のページはPiPと同じスタイルを持っているので、同じ幅で描いた非表示のコピーを作って scrollHeight を読み、それを requestWindow() の高さに渡します。測り終えたコピーは消します。
// How tall the expanded overlay is for `data` at the width the PiP opens
// with, measured in `doc` — the opener's own page, which has the same style
// (CSS) — in a hidden copy that is removed again (0 when it can't be). The
// PiP then opens at that height: once requestWindow() has spent the click's
// activation, resizeTo() is refused, so the window can't be fitted after it opens.
function openingHeight(doc, data) {
if (!doc?.body) return 0;
const probe = doc.createElement('div');
probe.id = 'ov';
probe.style.cssText = `position:absolute;left:-10000px;top:0;visibility:hidden;width:${EXPANDED_W}px;box-sizing:border-box`;
doc.body.appendChild(probe);
try {
render(probe, data);
return probe.scrollHeight;
} finally {
probe.remove();
}
}
呼び出し側はこうです。preferInitialWindowPlacement: true も重要でした。これを付けないと、Chromeは「前回そのPiPが置かれていた大きさと位置」で開き直し、渡した width/height を無視します。ユーザーが一度大きく引き伸ばしていると、次からずっとその大きさで開くことになります(Chrome 130以降のオプションです)。
// preferInitialWindowPlacement: open at this size every time — without it
// Chrome reopens at the size and place the window last had (e.g. one the
// user dragged bigger) and ignores width/height (measured; Chrome 130+).
pip = await documentPictureInPicture.requestWindow({ ...pipSize('expanded', openingHeight(measureIn, data)), preferInitialWindowPlacement: true });
サイズの決め方自体は、chrome.* もDOMも触らない純粋関数に切り出しています。こうするとNodeのテストから実ファイルをそのまま読み込めます。
// The inner size the window is given on open and on a click. `content` is the
// overlay's own size in the layout asked for, as measured: expanded, the
// height it has drawn EXPANDED_W wide; collapsed, the width of the strip for
// the services that are on. Expanded has no upper limit (Chrome keeps the
// window on screen and the drawing fits what it allows); the strip is never
// asked for less than Chrome's smallest PiP, which it would not get anyway.
export function pipSize(mode, content) {
const own = size(content);
if (mode === 'compact') return {width: own ? Math.max(COMPACT_MIN_W, Math.ceil(own)) : COMPACT_W, height: COMPACT_H};
return {width: EXPANDED_W, height: own ? Math.max(EXPANDED_MIN_H, Math.ceil(own)) : OPEN_H};
}
resizeTo() は外側のサイズを取る — 両軸で測る
ここが今回いちばん直したかった点です。
PiPを開いたあとでサイズを合わせられるタイミングは一つだけあります。PiPの中をクリックしたときです。そのクリックのuser activationで resizeTo() が通ります。この拡張では、PiPをクリックすると展開/折りたたみが切り替わるので、その切り替えのタイミングでウィンドウを合わせています。
そして resizeTo() が取るのは外側(outer)のサイズです。requestWindow() がinnerを取るのと違います。差分は環境ごとに違うので、呼ぶたびに outerWidth - innerWidth / outerHeight - innerHeight で測ります。
// resizeTo() takes the outer size: the inner one plus what Chrome puts around
// it, measured as outer − inner. On Windows that is the title bar and an
// invisible border left and right (measured: 16×42 at 100%; at 150% 15×41,
// or 14×42 emulated — it rounds, so it is measured each time, not assumed).
export function pipOuterSize(mode, content, frame = {}) {
const inner = pipSize(mode, content);
return {width: inner.width + size(frame?.width), height: inner.height + size(frame?.height)};
}
function fitWindow() {
try {
const frame = { width: pip.outerWidth - pip.innerWidth, height: pip.outerHeight - pip.innerHeight };
// The overlay's own size in the layout the click left it in: the
// expanded height, or the strip's width for the services that are on.
const own = mode === 'compact' ? sizes?.compact?.width : sizes?.expanded?.height;
const { width, height } = pipOuterSize(mode, own, frame);
pip.resizeTo(width, height);
} catch (_) { /* refused: the drawing still fits the window as it is */ }
}
以前のコードは、高さの差(タイトルバー分)だけを足して、幅はinnerのまま渡していました。Windowsでは左右に見えない枠があり、100%で16px、150%で15px(倍率エミュレーションでは14px)という実測値でした。そのぶんコンテンツが削られます。展開時の幅は280のつもりが、計算上はinner 264になっていたはずです。ここは計算で追っただけで、旧コードの実機再現はしていません。修正後は、切り替えクリックで展開280×250・折りたたみ360×58にぴったり合うことを実機で確認しています。
倍率150%では丸めの都合で1pxずれます(開いたとき281×250、折りたたみ361×59)。だから差分を定数に持たず毎回測る、という方針にしています。
折りたたみ時の幅を、オンになっているサービス数に合わせる
折りたたみ表示は横一列のストリップです。以前は幅360固定でしたが、サービスを1件だけ有効にしていると左右が大きく空いて間が抜けた見た目になっていました。
そこで、折りたたみの幅も中身に合わせます。PiPの中で、そのとき有効なサービスだけを描いた非表示コピーを作り、scrollWidth を読みます。
// The overlay's own size in each layout for `data` (fitOverlay's `content`),
// drawn in hidden copies in the PiP's document, which has FIT_CSS, and
// removed again. Where nothing can be measured, the layout's fixed size.
function contentSizes(doc, data) {
const measure = (paint, bare, least) => {
const probe = doc.createElement('div');
probe.id = 'ov';
probe.style.cssText = 'position:absolute;left:-10000px;top:0;visibility:hidden';
doc.body.appendChild(probe);
try {
paint(probe, data);
if (bare) probe.classList.add('bare');
return { width: Math.max(least.width, probe.scrollWidth || 0), height: Math.max(least.height, probe.scrollHeight || 0) };
} finally {
probe.remove();
}
};
return {
expanded: measure(render, false, { width: EXPANDED_W, height: 0 }),
compact: measure(renderCompact, false, { width: 0, height: COMPACT_H }),
bare: measure(renderCompact, true, { width: 0, height: COMPACT_H }),
};
}
ただし下限があります。Chromeは240pxより狭いPiPを作ってくれません(inner、実測)。高さも、resizeTo() では58より低くできませんでした(枠をドラッグすると52まで下がります)。この二つが定数になっています。
// The strip is as wide as the services that are on, between these two:
// Chrome opens no PiP narrower than 240 of content (measured, Chrome 156), and
// COMPACT_W stands in when the strip could not be measured (three services).
export const COMPACT_MIN_W = 240;
export const COMPACT_W = 360;
// resizeTo() does not make a PiP lower than 58 of content (measured, Chrome
// 156 at 100%; a border drag goes down to 52), so the strip is 58.
export const COMPACT_H = 58;
実測したストリップの幅は、名前つきで1件123px、2件250px、3件342pxでした。1件のときは123pxですがChromeの下限に当たるので、ウィンドウは240pxになります。2件なら250px、3件なら342px。従来の固定360pxと比べると、1件のときに120px狭くなりました。
下限に当たるケースがあるので、ストリップはウィンドウの中央に置いています。ユーザーが横に広げたときも、1件で240pxに張り付いたときも、左右の余白が同じになります。
const FIT_CSS = `
html,body{height:100%;overflow:hidden}
body{display:flex;flex-direction:column;align-items:flex-start;cursor:default}
body.strip{justify-content:center;align-items:center}
#ov{flex:none;width:${EXPANDED_W}px;box-sizing:border-box;transform-origin:0 0;cursor:pointer}
#ov.compact{width:max-content;height:${COMPACT_H}px;min-height:0;transform-origin:50% 50%}
#ov.bare .ci > span{display:none}`;
枠のドラッグは戻せないので、描画側で合わせる
ユーザーがPiPの枠を引っ張ってサイズを変えるのは止められません。そして枠のドラッグにはuser activationが付きません。だからその直後に resizeTo() を呼んでも NotAllowedError です。即座に呼んでも、300ms後に呼んでも同じでした(実測)。
ここで一度「PiPの中をクリックしてもらってから戻す」案も考えましたが、勝手にサイズが戻る挙動は気持ち悪いので、自動復元は入れないことにしました。代わりに、ウィンドウの大きさに関係なく中身は決めたサイズで描き、小さければ縮める方針にしています。拡大はしません。
その判断をしているのが fitOverlay() です。
export function fitOverlay(win, mode, content, previous = null) {
const width = size(win?.width);
const height = size(win?.height);
const ratio = (room, need) => (need ? room / need : Infinity);
const scaleOf = (box, spare = 0) => Math.min(1, ratio(width - spare, size(box?.width)), ratio(height - spare, size(box?.height)));
if (!width || !height) return {mode: mode === 'compact' ? 'compact' : 'expanded', scale: 1, names: true};
const fits = (box, least, showing) => scaleOf(box) >= 1 || scaleOf(box, showing ? 0 : FIT_MARGIN) >= least;
if (mode !== 'compact') {
const showing = !previous || previous.mode === 'expanded';
if (fits(content?.expanded, EXPANDED_MIN_SCALE, showing)) return {mode: 'expanded', scale: scaleOf(content?.expanded), names: true};
}
const named = !previous || previous.mode !== 'compact' || previous.names;
if (fits(content?.compact, COMPACT_MIN_SCALE, named)) return {mode: 'compact', scale: scaleOf(content?.compact), names: true};
return {mode: 'compact', scale: scaleOf(content?.bare), names: false};
}
縮小の下限は「いちばん小さい文字が10pxを下回らない倍率」にしました。CSSの実際のフォントサイズから逆算しています。
// Shrinking stops where the smallest text would go under 10px
// (tests/test_overlay_fit.mjs checks these against overlay_view.js CSS).
// Expanded: the small lines (.muted, .plan, .sub) are 10.5px; the status chip
// is 9.5px, under 10 even at full size, so it is left out. Collapsed: 12px.
export const MIN_TEXT_PX = 10;
export const EXPANDED_TEXT_PX = 10.5;
export const COMPACT_TEXT_PX = 12;
export const EXPANDED_MIN_SCALE = MIN_TEXT_PX / EXPANDED_TEXT_PX;
export const COMPACT_MIN_SCALE = MIN_TEXT_PX / COMPACT_TEXT_PX;
// A layout that gave way comes back only with this much room to spare, or at
// its full size, so a border held at the edge does not flicker between two.
export const FIT_MARGIN = 8;
段階は三つです。展開がその下限でも入らなければストリップで描く、ストリップも幅が足りなければサービス名を落としてリングと数値だけにする、そのうえで必要なだけ縮める。どの段階でも切り落としはせず、縦スクロールも出しません。
FIT_MARGIN はヒステリシスです。これを入れていないと、ユーザーが境界のあたりで枠を保持したときに二つのレイアウトが行ったり来たりしてちらつきます。一度退いたレイアウトは、8px分の余裕ができるか、ウィンドウがちょうど規定サイズに戻るまで復帰しません。
ユーザーの「展開/折りたたみ」の選択自体は変えないのがポイントです。変えるのは描き方だけです。クリックで折りたたみを選んだ人が、ウィンドウを広げた拍子に展開に戻ってしまう、ということは起きません。
描画と再フィットの接続はこうなっています。
function layout() {
const next = fitOverlay({ width: pip.innerWidth, height: pip.innerHeight }, mode, sizes, clicked ? null : drawn);
(next.mode === 'compact' ? renderCompact : render)(root, lastData);
root.classList.toggle('bare', !next.names);
root.style.transform = next.scale < 1 ? `scale(${next.scale})` : '';
doc.body.classList.toggle('strip', next.mode === 'compact');
drawn = next;
}
const paint = () => { sizes = contentSizes(doc, lastData); layout(); };
root.addEventListener('click', () => { mode = mode === 'compact' ? 'expanded' : 'compact'; clicked = true; fitWindow(); layout(); });
// The user dragging its border, or the click's resize landing.
pip.addEventListener('resize', () => { if (root.isConnected) layout(); clicked = false; });
clicked フラグは、クリック由来のリサイズのときだけヒステリシス(previous)を外すためのものです。規定サイズに合わせ直すリサイズが、直前の「退いた」状態に引っ張られないようにしています。枠のドラッグ中は resize がマウス移動ごと(約60ms間隔)に来て、離したあとに追加では来ません。
テストで数値を固定する
ここまでの数値はほぼ全部が実測由来で、しかも環境依存です。放っておくと誰か(主に将来の自分)が「この280って何だっけ」と言いながら壊します。
サイズ決定を純粋関数に切り出しているので、Nodeのテストから実ファイルをそのまま読み込んで境界を固定できます。
check('expanded: 280 wide, as tall as its content', pipSize('expanded', 202), { width: 280, height: 202 });
check('expanded: not lower than 140', pipSize('expanded', 90), { width: 280, height: 140 });
check('expanded: a fraction of a pixel is rounded up, not cut', pipSize('expanded', 201.2).height, 202);
check('collapsed, one service (139 of strip): the window follows it down to the smallest PiP Chrome gives', [pipSize('compact', 139), COMPACT_MIN_W], [{ width: 240, height: 58 }, 240]);
check('collapsed, two services: in between, the width of the strip itself', pipSize('compact', 262), { width: 262, height: 58 });
check('resizeTo gets the outer size: 16×42 of frame at 100% (measured)', pipOuterSize('compact', 360, { width: 16, height: 42 }), { width: 376, height: 100 });
check('… 14×42 at 150%', pipOuterSize('expanded', 202, { width: 14, height: 42 }), { width: 294, height: 244 });
check('no frame measured (or a negative one): the inner size', pipOuterSize('expanded', 202, { width: -3, height: NaN }), { width: 280, height: 202 });
気に入っているのは、縮小下限のテストです。下限はCSSのフォントサイズから逆算した値なので、CSS側を触って定数側を忘れると意味がなくなります。そこでテストがCSS文字列を実際にパースして、最小フォントサイズと定数が一致するかを見ています。
{
const { CSS } = await import('../extension/overlay_view.js');
// font-size per rule of the overlay's CSS, e.g. "#ov .muted" -> 10.5
const sizes = Object.fromEntries([...CSS.matchAll(/([^{}]+)\{([^}]*)\}/g)]
.map(([, selector, body]) => [selector.trim(), body.match(/font-size:([\d.]+)px/)?.[1]])
.filter(([, px]) => px).map(([selector, px]) => [selector, Number(px)]));
const expandedText = Object.entries(sizes).filter(([s]) => !/\.chip|\.ci|\.empty/.test(s)).map(([, px]) => px);
check('expanded: smallest text 10.5px (the 9.5px chip left out)', [Math.min(...expandedText), fit.EXPANDED_TEXT_PX, sizes['#ov .chip']], [10.5, 10.5, 9.5]);
check('collapsed: the strip\'s text is 12px', [sizes['#ov .ci'], fit.COMPACT_TEXT_PX], [12, 12]);
check('limits: 10/10.5 and 10/12', [EXPANDED_MIN_SCALE, COMPACT_MIN_SCALE], [10 / 10.5, 10 / 12]);
}
ヒステリシスもテストで固定しています。枠を境界付近で往復させたときに、レイアウトが保持されることを確認する形です。
// Expanded gave way below 267; it comes back from 267 + 8 = 275.
let drawn = at(280, 202);
const path = [270, 268, 272, 267, 270];
const seen = path.map(w => (drawn = at(w, 202, 'expanded', drawn)).mode);
check('shown expanded, the border moved back and forth inside the band: stays expanded', seen, ['expanded', 'expanded', 'expanded', 'expanded', 'expanded']);
ハマりどころ / 注意点
実測で分かったことをまとめます。数値はWindows 11・Chrome 156・100%倍率のものです。
user activation
-
requestWindow()はそれを呼ぶページ自身のuser activationを要求する。別ページのクリックは引き継がれない。 -
requestWindow()はそのactivationを消費する。だから開いた直後のresizeTo()はNotAllowedError。 - 枠のドラッグにはactivationが付かない。直後でも300ms後でも
resizeTo()は拒否される。 - PiPの中をクリックしてから数秒(Chromeのactivation保持は5秒)なら、そのクリックで1回通る。openerのウィンドウをクリックしてもPiPにactivationが生まれる(Chrome 127以降で共有される挙動)。
サイズ
-
requestWindow()はinner、resizeTo()はouter。混同しやすい。 - outer − innerはタイトルバーだけではない。Windowsは左右に見えない枠がある(100%で16px、150%で15px)。倍率で丸めが変わるので毎回測るのが安全。
- PiPのinner最小は240×52。
resizeTo()で縮められる高さの下限は58(枠ドラッグなら52まで)。 - 最大はディスプレイの0.8(1920×1080で1536×864、タイトルバー込み)。
-
preferInitialWindowPlacementはChrome 130以降。それ以前では、開き直したときに前回の大きさ・位置になりうる。
そのほか
- ブラウザ全体でPiPは1つだけ。別ページの動画PiPが開くとこちらは閉じるし、こちらを開くとそれが閉じる。
- タイトルバーは消せない。拡張ページから開けば拡張名と
chrome-extension://<ID>になるが、狭いと省略される。 - 透過は効かなかった。
html/bodyをtransparentやrgba(0,0,0,0.5)にしても背後は透けず、白または灰色で描かれる。この拡張では実装を見送っています。 - 最小化されたページのタイマーはChromeが遅らせることがある。だから時刻に依存する表示(「3分前」など)は、描画のたびに時計を読み直す作りにしています。遅れるのは更新のタイミングだけで、値はずれません。
// Staleness and the reset countdowns move with the clock, not only with
// storage. While the PiP is up this page is minimized, and Chrome may run
// a hidden page's timers late (up to a minute); each paint reads the clock anew.
setInterval(refresh, 30 * 1000);
(コメント1行目にあった表示文字列の例は、読みやすさのため省いています。)
未確認のこと
- マルチモニタ構成、特にPiPを別ディスプレイに置いた場合。
- 中身が画面より高いときにChromeがどう切るか。
- ストリップ幅の計測はヘッドレスのChromeで実際の
overlay_view.jsを読み込んで行いました。実PiPウィンドウでのresizeTo()はヘッドレスでPiPを開けなかったため、その経路だけ別途(通常起動のChromeで)確認しています。倍率150%でのストリップ幅は未確認です。 - 物理キーボードでのEnter操作、および他アプリが前面にあるときにWindowsが新しいウィンドウへキーボードフォーカスを渡すか。
まとめ
Document Picture-in-Pictureは、拡張から「常に手前のウィンドウ」を出す手段としてはかなり素直に使えます。ただ、二つの前提を押さえておく必要がありました。
ひとつは、PiPを開く主体をどこに置くかが寿命と権限の両方を決めるということです。コンテンツスクリプトから開くと、そのタブに縛られ、注入のための権限が要り、chrome:// 系では動きません。拡張ページを一段挟むと、操作が二段になる代わりにそれらが全部消えます。操作が二段になるぶんは、ボタンへフォーカスを当ててEnterで済むようにして補いました。
もうひとつは、サイズは「開く前に決める」か「クリックのタイミングで合わせる」しかないということです。そして resizeTo() はouterサイズを取るので、タイトルバーだけでなく左右の見えない枠も両軸で測って足す必要があります。環境ごとに丸めが変わるので、定数で持たずに毎回測るほうが安全でした。枠のドラッグは止められないので、戻そうとせず、描画側で縮めて合わせる設計に落ち着きました。
この記事の内容は、Claude・ChatGPT・Geminiの利用量をポップアップとオーバーレイで表示する Leeway というChrome拡張の実装から出てきたものです。MITライセンスの無料のオープンソースで、ソースは https://github.com/donasman/leeway にあります。この記事で引用したコードは extension/overlay_window.js・extension/overlay_view.js・extension/overlay_fit.js・tests/test_overlay_fit.mjs にそのまま入っています。