[Astro] #151 ウィンドウマネージャーの大規模リファクタリング — 7ファイルのコピペ地獄から4ファイル統一設計へ
はじめに
前回の記事でスタートメニューからアプリを起動できるようにしたが、肝心の「ウィンドウとして表示する」部分の設計が破綻していた。SSH ウィンドウを作ったのを皮切りに、Web ブラウザ、ファイルマネージャー、アーケードゲーム、動的アプリ起動と場当たり的に実装を重ねた結果、同じドラッグ/リサイズ/設定パネルのコードが 5 ファイルにコピペされている状態だった。
[Astro] #150 Copland OS風スタートメニューの実装 — JSON駆動型多段カスケードメニューとカスタムイベント連携 // PROTOCOL.LAIN
Astroコンポーネントにおける JSON データ駆動のカスケードメニュー実装記録。多段階のネスト構造表示、画面外はみ出し防止のホバー位置補正、data-action 属性を介したカスタムイベント発火(ターミナル起動やファイルマネージャー連携)、href='#' 指定時のページハッシュ化・リロードループ回避テクニックを解説。
lain-lab.com今回、ウィンドウ管理システムを全面的に作り直した。
| Before | After |
|---|---|
| 7 ファイル(各ウィンドウが独自にドラッグ/リサイズ/設定を実装) | 4 ファイル(context.ts / index.tsx / BaseWindow.tsx / contents.tsx) |
開閉管理が 2 系統(Context + 各自の isOpen) | openWindows 配列で全ウィンドウ一元管理 |
| カスタムイベント 4 種類 | wired-remote-command 1 本に統一 |
| アプリ起動 → ページ遷移 | アプリ起動 → ?embed 付き iframe ウィンドウ |
スクリーンショット
複数のアプリをWindow化
スタートメニュー
1. 旧設計の問題点
リファクタリング前のファイル構成:
src/context/WindowManagerContext.tsx
src/components/PROJECT_LAIN/
WiredWindowManager.tsx
WiredAppWindow.tsx
WiredSshWindow.tsx
WiredFrameWindow.tsx
WiredArcadeWindow.tsx
WiredFileManager.tsx
コード重複
WiredSshWindow、WiredFrameWindow、WiredAppWindow の 3 つが、ドラッグ・リサイズ・最小化のロジックをほぼ同一のコードで持っていた。設定パネル(不透明度・文字色・背景色・壁紙)も SSH と Frame で丸ごと重複。変更が入るたびに全ファイルを修正する必要があった。
二重の開閉管理
- SSH / Frame / Arcade → 各コンポーネントが自前の
isOpenstate + 独自のカスタムイベント(wired-toggle-ssh-window等)で開閉 - 動的アプリ → Context の
openWindows配列で管理
同じウィンドウマネージャー内で 2 つの生存管理が走っていた。
スタートメニューとの断絶
Footer.astro(Astro 静的 HTML)から React の WindowManagerProvider にアクセスする手段が CustomEvent しかなく、アプリ項目の「Launch App」は通常の <a href> だったためクリックするとページ遷移してしまい、ウィンドウとして開かなかった。
2. 新設計 — 4 ファイル構成
src/components/WiredWindow/
context.ts ← 型 + Context + Hook(35 行、依存なし)
index.tsx ← Provider + EventBridge + WiredWindowManager
BaseWindow.tsx ← 共通ウィンドウ殻(ドラッグ/リサイズ/設定パネル/壁紙)
contents.tsx ← SSH / Arcade / FM の中身だけ
依存関係:
context.ts ← 何にも依存しない
↑ ↑
index.tsx BaseWindow.tsx
↑
contents.tsx
context.ts — 循環参照を断つ
型定義と Context、Hook を独立ファイルに切り出した。index.tsx と BaseWindow.tsx が互いに import し合う循環参照を防ぐため。
// context.ts
import { createContext, useContext } from 'react';
export interface WindowInstance {
id: string;
title: string;
url: string;
icon: string;
type: 'iframe' | 'ssh' | 'arcade' | 'fm';
configurable: boolean;
defaultPos?: { x: number; y: number };
defaultSize?: { width: number; height: number };
}
export interface WindowManagerContextType {
activeWindowId: string | null;
openWindows: WindowInstance[];
getZIndex: (id: string) => number;
focusWindow: (id: string) => void;
openApp: (win: WindowInstance) => void;
closeApp: (id: string) => void;
toggleApp: (win: WindowInstance) => void;
}
export const WindowManagerContext =
createContext<WindowManagerContextType | null>(null);
export function useWindowManager(): WindowManagerContextType {
const ctx = useContext(WindowManagerContext);
if (!ctx)
throw new Error(
'useWindowManager must be used within WiredWindowManager'
);
return ctx;
}
index.tsx — イベントブリッジと統一管理
ビルトインウィンドウの定義、wired-remote-command イベントの受信、type によるコンテンツのルーティングを担当。
// ビルトイン定義
const BUILTINS: Record<string, WindowInstance> = {
ssh: {
id: 'ssh',
title: 'TERMINAL SESSION // SSH',
url: 'https://ssh.lain-lab.com/',
icon: '💻',
type: 'ssh',
configurable: true,
defaultPos: { x: 100, y: 100 },
defaultSize: { width: 700, height: 450 },
},
fm: { /* ... */ },
arcade: { /* ... */ },
};
// コンテンツルーター
const WindowContent: React.FC<{ instance: WindowInstance }> = ({ instance }) => {
switch (instance.type) {
case 'ssh': return <SshContent />;
case 'arcade': return <ArcadeContent />;
case 'fm': return <FileManagerContent />;
default: return null; // iframe型はBaseWindow内で処理
}
};
イベント処理は 1 本のリスナーに統一:
// 文字列 "ssh" / "fm" / "game" → ビルトイン toggle
// 文字列 "wired https://..." → ブラウザウィンドウ
// 文字列 "/gear-generator" → ?embed 付き iframe
// オブジェクト { app, title, icon } → ?embed 付き iframe
BaseWindow.tsx — 全機能統合ウィンドウ殻
旧 5 ファイルに散在していた以下の処理を 1 コンポーネントに統合:
- ドラッグ移動
- リサイズ(右下ハンドル)
- 最小化 / 閉じる
- 設定パネル(不透明度・文字色・背景色・フォントサイズ・壁紙)
- IndexedDB への壁紙保存
- z-index 管理(Context 連動)
pointerEvents制御(ドラッグ中に iframe がマウスをキャプチャするのを防止)
<BaseWindow instance={win}>
<WindowContent instance={win} />
</BaseWindow>
instance.type === 'iframe' の場合は BaseWindow 内で直接 <iframe> をレンダリング。それ以外は children として SSH / Arcade / FM のコンテンツコンポーネントを受け取る。
flex 溢れ対策
flex コンテナの子要素はデフォルトで min-height: auto なので、中身のサイズ分だけウィンドウが際限なく伸びる。minHeight: 0 と overflow: hidden を各階層に設定して固定サイズ内でスクロールさせる。
<div style={{
flex: 1,
minHeight: 0, // ← これがないとウィンドウが2500pxに伸びる
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}>
contents.tsx — 中身だけのコンポーネント群
ドラッグ/リサイズ/ヘッダー/設定パネルのコードは一切ない。各ウィンドウの「表示ロジック」だけを定義する。
SshContent— localhost ではモック、本番ではssh.lain-lab.comへの iframeFileManagerContent— IndexedDB キャッシュ管理 UI + VRM/GLB 3D プレビュー(React Three Fiber)ArcadeContent— Canvas2D レトロゲーム 4 本(PONG / 平安京エイリアン / スペースインベーダー / パックマン)
スタイルは親の BaseWindow から color / fontSize / fontFamily を継承。Canvas2D のゲーム色は getComputedStyle で取得するため、設定パネルで色を変えると連動する。
3. ?embed パラメータによる iframe 内レイアウト制御
各 Astro ページ(/vrm-to-mmd 等)をウィンドウ内にiframeで表示するとき、そのままではヘッダーとフッターも一緒に表示される。
解決策:クライアントサイドの embed 検出
Astro SSG(静的サイト生成)では Astro.url.searchParams はビルド時に評価されるため、ランタイムの ?embed パラメータを検出できない。クライアントサイド JS で対応した。
BaseLayout.astro の <head> 先頭に挿入:
<script is:inline>
if (new URLSearchParams(window.location.search).has('embed')) {
document.documentElement.classList.add('is-embed');
}
</script>
CSS で html.is-embed 時にヘッダー/フッターを非表示:
html.is-embed body > *:not(main):not(script):not(style) {
display: none !important;
}
html.is-embed body > main,
html.is-embed .content-wrapper {
margin: 0 !important;
padding: 0 !important;
}
html.is-embed .app {
margin-top: 0 !important;
margin-bottom: 0 !important;
height: 100vh !important;
}
body > *:not(main) にすることで、Navigation や Footer がどんなタグ名で実装されていてもセレクタが合致する。各 .astro ページは一切変更不要。
ウィンドウマネージャー側は iframe の src に ?embed を自動付与:
const embedUrl = appPath.startsWith('http')
? appPath // 外部URLはそのまま
: `${appPath}${appPath.includes('?') ? '&' : '?'}embed`;
4. Footer.astro の修正 — ページ遷移からウィンドウ起動へ
「Launch App」リンクが <a href="/path"> だとページ遷移してしまう問題。さらに Astro の ClientRouter が <a href="#"> を通常のナビゲーションとして処理し、リロードが走る問題もあった。
解決策
<a href="#"> を <span> に変更して ClientRouter の干渉を回避し、data-app 属性でクリックハンドラからイベントを発火:
{gc.app ? (
<span class="menu-launch-btn"
data-app={gc.app}
data-title={gc.appTitle || ''}
data-icon={gc.appIcon || '⚙'}>
{gc.title}
</span>
) : (
<a href={gc.url || '#'}>{gc.title}</a>
)}
document.addEventListener('click', (e) => {
const target = e.target.closest('[data-app]');
if (!target) return;
e.preventDefault();
window.dispatchEvent(
new CustomEvent('wired-remote-command', {
detail: {
app: target.dataset.app,
title: target.dataset.title,
icon: target.dataset.icon
}
})
);
});
5. まとめ
| 観点 | Before | After |
|---|---|---|
| ファイル数 | 7 | 4 |
| ドラッグ/リサイズ実装箇所 | 5 箇所(コピペ) | BaseWindow 1 箇所 |
| 開閉管理 | 2 系統(Context + 各自 isOpen) | Context 1 系統 |
| カスタムイベント | 4 種類 | 1 種類(wired-remote-command) |
| アプリ起動 | ページ遷移 | iframe ウィンドウ |
| 新ウィンドウ追加手順 | ファイル新規作成 + ドラッグ/リサイズ全実装 | contents.tsx に中身追加 or ?embed iframe |
今後ツールを追加するときは、Astro ページとして実装すれば ?embed 付き iframe で自動的にウィンドウ化できる。ビルトインウィンドウが必要な場合も contents.tsx にコンテンツを書いて index.tsx の BUILTINS に定義を追加するだけで済む。