Zum Inhalt

src/engine/menubar.ts

// Shared menubar render (v1.1 — Live-Test 2026-05-11).
//
// Replaces the v1.0 gap where the embed pane had no controls at all. The
// fullscreen overlay has its own much richer control bar in controller.ts;
// this helper renders a minimum-viable menubar (scene-switcher · speed ·
// close) that is also small enough to sit cleanly in an Obsidian pane.
//
// Kept on its own so the embed view can reuse it without dragging in the
// fullscreen controller's lifecycle. The fullscreen path will move onto
// the same helper in a later phase; for v1.1 the goal is parity-not-
// identity between fullscreen and embed.
//
// Standalone-Status (post 2026-05-27 rollback): the standalone Web-Host
// only ever opens fullscreen, so this file is dead code in the browser
// build. Kept here because the plugin's embed-view.ts (excluded from
// sync) imports it.

import type { Engine } from './engine/core';
import type { ScreensaverSettings, SceneId } from './data/defaults';
import { SCENES, SCENE_LABELS } from './data/defaults';

export interface MenubarOpts {
  engine: Engine;
  /** Pane settings — the screensaver subtree of plugin.settings. */
  settings: ScreensaverSettings;
  /** Persist settings change. Called debounced from the menubar. */
  save: () => void;
  /** Switch to a new scene. */
  switchScene: (id: SceneId) => void;
  /** Close the screensaver / detach the pane. */
  close: () => void;
}

const MENUBAR_CSS_INJECTED_FLAG = 'kuro-menubar-css-injected';

function ensureMenubarCss() {
  if (document.getElementById(MENUBAR_CSS_INJECTED_FLAG)) return;
  const tag = document.createElement('style');
  tag.id = MENUBAR_CSS_INJECTED_FLAG;
  tag.textContent = `
    .kuro-menubar {
      position: absolute; top: 6px; left: 50%; transform: translateX(-50%);
      display: inline-flex; align-items: center; gap: 6px;
      padding: 4px 8px;
      background: rgba(0, 0, 0, 0.55);
      border: 1px solid rgba(255, 255, 255, 0.12);
      border-radius: 6px;
      font-family: "Share Tech Mono", ui-monospace, monospace;
      font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase;
      color: rgba(255, 255, 255, 0.85);
      z-index: 9;
      backdrop-filter: blur(3px);
      opacity: 0; transition: opacity 160ms ease;
      pointer-events: none;
    }
    .kuro-menubar.is-visible { opacity: 1; pointer-events: auto; }
    .kuro-menubar .kuro-mb-lbl { color: rgba(255, 255, 255, 0.45); padding: 0 4px; }
    .kuro-menubar .kuro-mb-sep { width: 1px; height: 14px; background: rgba(255, 255, 255, 0.18); margin: 0 4px; }
    .kuro-menubar button.kuro-mb-btn {
      background: transparent;
      border: 1px solid transparent;
      color: rgba(255, 255, 255, 0.7);
      padding: 2px 8px; cursor: pointer;
      font: inherit; letter-spacing: inherit; text-transform: inherit;
      border-radius: 3px;
    }
    .kuro-menubar button.kuro-mb-btn:hover { color: #fff; border-color: rgba(255, 255, 255, 0.30); }
    .kuro-menubar button.kuro-mb-btn.is-active { color: #fff; border-color: rgba(255, 255, 255, 0.55); background: rgba(255, 255, 255, 0.08); }
    .kuro-menubar button.kuro-mb-close { color: rgba(255, 200, 200, 0.8); }
    .kuro-menubar button.kuro-mb-close:hover { color: #fff; background: rgba(255, 80, 80, 0.4); border-color: rgba(255, 120, 120, 0.6); }
  `;
  document.head.appendChild(tag);
}

/**
 * Mount a menubar onto a host element. Host must be positioned (relative /
 * absolute / fixed) for the bar's absolute positioning to land correctly.
 * Returns a small handle so the caller can show/hide/refresh on demand.
 */
export function renderMenubar(host: HTMLElement, opts: MenubarOpts) {
  ensureMenubarCss();

  const bar = host.createDiv({ cls: 'kuro-menubar' });
  bar.addEventListener('mouseenter', () => bar.classList.add('is-visible'));
  bar.addEventListener('mouseleave', () => bar.classList.remove('is-visible'));
  // Click outside the bar inside the host area: nothing. Click outside the
  // host is the caller's responsibility.

  const render = () => {
    bar.empty();

    const lbl = (text: string) => {
      const s = bar.createSpan({ cls: 'kuro-mb-lbl', text });
      return s;
    };
    const sep = () => bar.createDiv({ cls: 'kuro-mb-sep' });
    const btn = (text: string, active: boolean, fn: () => void, extraCls = '') => {
      const b = bar.createEl('button', { cls: 'kuro-mb-btn ' + (active ? 'is-active ' : '') + extraCls, text });
      b.addEventListener('click', (e) => { e.stopPropagation(); fn(); render(); });
      return b;
    };

    // SCENE
    lbl('SCENE');
    SCENES.forEach(sc => btn(SCENE_LABELS[sc], opts.engine.currentScene === sc, () => opts.switchScene(sc)));

    // SPD
    sep(); lbl('SPD');
    (['slow', 'norm', 'fast'] as const).forEach(sp => btn(
      sp === 'slow' ? '▶' : sp === 'norm' ? '▶▶' : '▶▶▶',
      opts.settings.speed === sp,
      () => {
        opts.settings.speed = sp;
        opts.engine.setSpeed(sp);
        opts.save();
      },
    ));

    // CLOSE
    sep();
    btn('×', false, () => opts.close(), 'kuro-mb-close');
  };

  render();

  // Show/hide tied to mouse activity on the host. Always show on first mount
  // for a couple seconds so the user sees the bar exists.
  bar.classList.add('is-visible');
  let hideTimer = window.setTimeout(() => bar.classList.remove('is-visible'), 2500);
  host.addEventListener('mousemove', () => {
    bar.classList.add('is-visible');
    clearTimeout(hideTimer);
    hideTimer = window.setTimeout(() => bar.classList.remove('is-visible'), 2500);
  });

  return {
    refresh: render,
    destroy: () => { clearTimeout(hideTimer); bar.remove(); },
  };
}