[Astro] #151 ウィンドウマネージャーの大規模リファクタリング — 7ファイルのコピペ地獄から4ファイル統一設計へ

[Astro] #151 ウィンドウマネージャーの大規模リファクタリング — 7ファイルのコピペ地獄から4ファイル統一設計へ

はじめに

前回の記事でスタートメニューからアプリを起動できるようにしたが、肝心の「ウィンドウとして表示する」部分の設計が破綻していた。SSH ウィンドウを作ったのを皮切りに、Web ブラウザ、ファイルマネージャー、アーケードゲーム、動的アプリ起動と場当たり的に実装を重ねた結果、同じドラッグ/リサイズ/設定パネルのコードが 5 ファイルにコピペされている状態だった。

今回、ウィンドウ管理システムを全面的に作り直した。

BeforeAfter
7 ファイル(各ウィンドウが独自にドラッグ/リサイズ/設定を実装)4 ファイル(context.ts / index.tsx / BaseWindow.tsx / contents.tsx)
開閉管理が 2 系統(Context + 各自の isOpen)openWindows 配列で全ウィンドウ一元管理
カスタムイベント 4 種類wired-remote-command 1 本に統一
アプリ起動 → ページ遷移アプリ起動 → ?embed 付き iframe ウィンドウ

スクリーンショット

複数のアプリをWindow化

[Astro] #151 ウィンドウマネージャーの大規模リファクタリング — 7ファイルのコピペ地獄から4ファイル統一設計へ

スタートメニュー

[Astro] #151 ウィンドウマネージャーの大規模リファクタリング — 7ファイルのコピペ地獄から4ファイル統一設計へ

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 → 各コンポーネントが自前の isOpen state + 独自のカスタムイベント(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 への iframe
  • FileManagerContent — 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. まとめ

観点BeforeAfter
ファイル数74
ドラッグ/リサイズ実装箇所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 に定義を追加するだけで済む。