109
70

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

ふと、「OSっぽいものって、TypeScriptだけで作れたりしないかな?」と思って、実際に作ってみました。

本物のOSとは違いますし、あくまでブラウザ上で動く なんちゃってOS です。
実行環境はあくまでブラウザですし、「OSって呼ぶには甘すぎる!」というツッコミもあるかもしれません。
でもそこはひとつ、温かい目で見ていただけると嬉しいです (っ´ω`)っ

動作イメージ

本プロジェクトのソースコードは GitHubにて公開しています。
興味がある方は、ぜひそちらもご覧ください!

ローカルにクローンして index.html をブラウザで開くだけで、すぐに動作確認できます。
めんどうな環境構築は不要なので、「ちょっと遊んでみたい」くらいのテンションでぜひどうぞ!

コード解説

① HTMLファイル

ボタンが3つ並んでいるだけのファイルとなります!
TSから出力されたmain.jsを最後の方で読み込んでいます。
こちらで、DOMの生成だったり削除だったりを行っているイメージです。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>なんちゃってOS</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <div id="desktop">
    <button id="clockBtn">🕒 時計アプリ</button>
    <button id="editorBtn">📝 テキストエディタ</button>
    <button id="terminalBtn">💻 ターミナル</button>
  </div>
  <script type="module" src="main.js"></script>
</body>
</html>

② ウィンドウのドラッグ・アンド・ドロップ処理

CleanShot 2025-07-07 at 01.47.33.gif

こちらでは、

  • ウィンドウを掴んだ時(mousedown
  • 掴んだまま移動している時(mousemove
  • ウィンドウを離す時(mouseup

のイベントリスナーを設定しています。
このあたりは画面の座標を基に、DOM自身の座標を変更することで移動させています。
offsetだったり、clientだったり、頭がこんがらがりましたが、なんとか実装できました。

また、ドラッグ中の判定は isDragging という変数に格納していたのですが、
「Reactの useState やVueの ref のようにステート管理してくれるものってやっぱり便利なんだなー」と、つくづく感じました。

const makeDraggable = (win: HTMLElement, titleBar: HTMLElement) => {
  let offsetX = 0
  let offsetY = 0
  let isDragging = false

  titleBar.addEventListener('mousedown', (e) => {
    isDragging = true
    offsetX = e.clientX - win.offsetLeft
    offsetY = e.clientY - win.offsetTop
    win.style.zIndex = `${++zIndexCounter}`
  })

  document.addEventListener('mousemove', (e) => {
    if (!isDragging) return
    win.style.left = `${e.clientX - offsetX}px`
    win.style.top = `${e.clientY - offsetY}px`
  })

  document.addEventListener('mouseup', () => {
    isDragging = false
  })
}

③ 各種アプリのウィンドウの生成

こちらでは各種アプリの大枠となるウィンドウを生成しているだけです。
「タイトルバー」だったり、「削除ボタン」だったりですね。

なお、引数contentHTML にアプリのUIがテキストとして入るイメージです。

const createWindow = (title: string, contentHTML: string): HTMLElement => {
  const win = document.createElement('div')
  win.className = 'window'
  Object.assign(win.style, {
    top: '100px',
    left: '100px'
  })

  const titleBar = document.createElement('div')
  titleBar.className = 'titleBar'
  titleBar.innerHTML = `<p>${title}</p>`

  const closeBtn = document.createElement('button')
  closeBtn.className = 'close-btn'
  closeBtn.textContent = '×'
  closeBtn.onclick = () => win.remove()

  titleBar.appendChild(closeBtn)

  const content = document.createElement('div')
  content.className = 'content'
  content.innerHTML = contentHTML

  win.append(titleBar, content)
  document.getElementById('desktop')?.appendChild(win)

  makeDraggable(win, titleBar)
  return content
}

④ 時計アプリ

CleanShot 2025-07-07 at 01.18.18.png

③で解説したウィンドウ生成関数 createWindow で大枠を作成しています。

clockEl.textContent に現在時刻を入れることで時計としてのUIになっています!

const launchClock = () => {
  const content = createWindow(
    '時計アプリ',
    `<div class="clock">🕒 00:00:00</div>`
  )
  const clockEl = content.querySelector('.clock') as HTMLElement
  clockEl.textContent = '🕒 ' + new Date().toLocaleTimeString()
  setInterval(() => {
    clockEl.textContent = '🕒 ' + new Date().toLocaleTimeString()
  }, 1000)
}

⑤ テキストエディタアプリ

CleanShot 2025-07-07 at 01.18.50.png

③で解説したウィンドウ生成関数 createWindow で大枠を作成しています。

ここは横着してローカルストレージに登録するようにしています。
やったことないですが、Electronとか使えば、実際にOS上にあるファイルを使って運用することができるんですかね。。。?

const launchEditor = () => {
  const content = createWindow(
    'テキストエディタ',
    `
    <textarea id="editorArea" placeholder="ここにメモを入力..."></textarea>
    <button id="saveBtn">保存する</button>
  `
  )

  const editor = content.querySelector('#editorArea') as HTMLTextAreaElement
  editor.value = localStorage.getItem('editorContent') || ''

  const saveBtn = content.querySelector('#saveBtn') as HTMLButtonElement
  saveBtn.onclick = () => {
    localStorage.setItem('editorContent', editor.value)
    alert('保存したよー!')
  }
}

⑥ ターミナルアプリ

CleanShot 2025-07-07 at 01.25.53.png

③で解説したウィンドウ生成関数 createWindow で大枠を作成しています。

terminalOutput が出力結果、terminalInputが入力フォームになります。

コマンドは4種類だけあり、以下出力結果が表示されるようになっています。

  • help
    • よくある使い方ガイドが表示されます
  • echo
    • echo 何らかの文字列 で、そのまま入力した文字列が表示されます
  • date
    • 現在時刻が表示されます
  • clear
    • これまで出力した結果が消えます

ターミナルっぽくするために、黒緑のいわゆるなデザインにして、enter キーでコマンド実行できるようになっています。

なお、筆者はこんな感じでターミナルを使ってます。
なにかで詰まっていても、海の生き物がいつも寄り添ってくれています萌
(右側にgitのブランチ名を表示させているのですが、これ結構便利です。作り方は調べてみてください!)

CleanShot 2025-07-07 at 01.38.18.png

const launchTerminal = () => {
  const content = createWindow(
    'ターミナル',
    `
    <div class="terminalOutput"></div>
    <input class="terminalInput" placeholder="help, echo, date, clear" />
  `
  )

  const output = content.querySelector('.terminalOutput') as HTMLElement
  const input = content.querySelector('.terminalInput') as HTMLInputElement

  const displayOutput = (text: string) => {
    const line = document.createElement('div')
    line.textContent = `> ${text}`
    output.appendChild(line)
    output.scrollTop = output.scrollHeight
  }

  const handleCommand = (cmd: string) => {
    const [command, ...args] = cmd.trim().split(' ')
    switch (command) {
      case 'help':
        displayOutput('使えるコマンド一覧: help, echo, clear, date')
        break
      case 'echo':
        displayOutput(args.join(' '))
        break
      case 'date':
        displayOutput(new Date().toLocaleString())
        break
      case 'clear':
        output.innerHTML = ''
        break
      default:
        displayOutput(`こんなコマンド知らん!: ${command}`)
    }
  }

  input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') {
      const command = input.value
      displayOutput(command)
      handleCommand(command)
      input.value = ''
    }
  })
}

⑦ イベントリスナーを追加

各種イベントを登録しています。

document.getElementById('clockBtn')?.addEventListener('click', launchClock)
document.getElementById('editorBtn')?.addEventListener('click', launchEditor)
document
  .getElementById('terminalBtn')
  ?.addEventListener('click', launchTerminal)

⑧ 以上!!!

終わりに

本記事はふと「OSっぽいことをTypeScriptだけでやってみたい!」と思って始めただけです。
でもその中に、UI設計・アーキテクチャ・UXなど多くの要素が詰まっていて、振り返ってみると、意外と深い学びがあったなと感じています。

OSって銘打ってみましたが、結局のところ DOM を生成したり、消したりしているだけです。
しかし、その「だけ」をどこまで広げられるかを考えたのは、結構楽しかったです。

例えば、ウィンドウの z-index管理

ドラッグ処理ひとつ取っても、座標の計算、クリック位置の記憶、重なり順の制御、etc...
普段OSが勝手にやってくれている「地味だけど重要な処理」に、真正面から向き合うことができました。

加えて、React の再レンダリングや状態管理がないので、自分で「いつ」「どこを」更新するかを全部決める必要があります。
実務では DOM をそのまま JS で生成することはあまりないので、このあたりを再認識できたのは良かったです。

P.S. ターミナルに echo と打ち込んで、画面に文字が出るだけでちょっと嬉しかった。。。


最後まで読んでくださりありがとうございます。

もし記事が参考になったら、「いいね」してもらえるとすごく励みになります!
また、内容に誤りや気になる点があれば、遠慮なくご指摘していただけると嬉しいです!

他にもいろいろな記事を投稿しているので、もしよかったら見てみてください!

ではでは!

109
70
12

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
109
70

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?