MMotif· VectoJS gallery
WebGPUcore 1.9.2GitHub

Context Menu

A right-click file grid — separators, disabled rows, and a nested Sort-by submenu, with the browser's own menu suppressed.

import { Scene, Rect } from "@vectojs/core";
import { Flow, Text, Card, ContextMenu } from "@vectojs/ui";

const app = document.querySelector("#app");
const canvas = document.querySelector("#canvas");

// Static, click-driven UI: repaint only when a menu opens/closes/selects, never
// per frame. Same onDemand contract as the Settings Form demo — the engine
// idles at ~0 CPU while the menu sits open, since ContextMenu's own open/close
// spring animates through the transition system (markDirty per frame it's
// still moving), not a manual render loop.
// disableWindowResize: a ResizeObserver on #app drives sizing (the VectoJS
// idiom for an embedded scene) so the demo tracks its iframe container, not
// the top window — see fit() below.
const scene = new Scene(canvas, { disableWindowResize: true });
scene.renderMode = "onDemand";

// The browser's own right-click menu would otherwise appear ON TOP of ours —
// suppress it globally. This has to be a real DOM listener because "right
// click" isn't a VectoEvent the engine dispatches; entities only ever see
// pointerdown/up (see below), which fires in parallel with the browser's
// native contextmenu event, not instead of it.
window.addEventListener("contextmenu", (e) => e.preventDefault());

const PAD = 20;
const CARD_W = 148;
const CARD_H = 108;

const FILES = [
  { name: "Design Notes.md", icon: "📝", color: "#e0a458" },
  { name: "Budget.xlsx", icon: "📊", color: "#3ba55d" },
  { name: "hero-shot.png", icon: "🖼️", color: "#7c5cff" },
  { name: "roadmap.pdf", icon: "📄", color: "#e05252" },
  { name: "invoice-04.csv", icon: "📈", color: "#22d3ee" },
  { name: "archive.zip", icon: "🗜️", color: "#9aa3b0" },
];

// One shared clipboard slot: which file (if any) was last "Copy"'d. Feeds the
// background menu's "Paste" disabled state below.
let clipboard = null;

// A full-bleed invisible hit target for "right-click empty space." `Scene`
// dispatches no pointer events of its own (only entities with an a11y shadow
// node do — see `card.on('pointerdown', ...)` below), so an entity has to own
// the background click. Added BEFORE the grid so tree order puts it
// underneath every card (later siblings are hit-tested first / drawn on
// top); resized alongside the canvas in fit().
const backgroundHit = new Card({
  width: 1,
  height: 1,
  bg: "transparent",
  label: "File browser background",
});
backgroundHit.interactive = true;
scene.add(backgroundHit);
backgroundHit.on("pointerdown", (e) => {
  const native = e.nativeEvent;
  if (native?.button !== 2) return;
  if (e.sceneX === undefined || e.sceneY === undefined) return;
  // Rebuilt fresh each time so its "Paste" row's disabled state reflects the
  // live clipboard var. Pass `backgroundHit` as the third arg so the fresh
  // instance resolves the scene on its first showAtPoint call (see the
  // per-file menu note above).
  buildBackgroundMenu().showAtPoint(e.sceneX, e.sceneY, backgroundHit);
});

const grid = new Flow({ gap: 16, maxWidth: 3 * (CARD_W + 16) - 16 });
grid.setPosition(PAD, PAD);
scene.add(grid);

let toastTimer = null;
const toast = new Text("", {
  font: "13px Inter, sans-serif",
  color: "#f4f6f8",
});
toast.opacity = 0;
scene.add(toast);

function flashToast(message) {
  toast.setText(message);
  toast.opacity = 1;
  scene.markDirty();
  if (toastTimer) clearTimeout(toastTimer);
  toastTimer = setTimeout(() => {
    toast.opacity = 0;
    scene.markDirty();
  }, 1400);
}

function buildFileMenu(file) {
  return new ContextMenu({
    width: 200,
    items: [
      { label: "Open", icon: "⏵", shortcut: "Enter" },
      { label: "Rename", icon: "✎", shortcut: "F2" },
      { separator: true },
      {
        label: "Cut",
        icon: "✂",
        shortcut: "Ctrl+X",
        onClick: () => flashToast(`Cut "${file.name}"`),
      },
      {
        label: "Copy",
        icon: "⧉",
        shortcut: "Ctrl+C",
        onClick: () => {
          clipboard = file;
          flashToast(`Copied "${file.name}"`);
        },
      },
      { separator: true },
      {
        label: "Sort by",
        icon: "↕",
        children: [
          { label: "Name", onClick: () => sortFiles("name") },
          { label: "Type", onClick: () => sortFiles("type") },
          { label: "Date modified", disabled: true },
        ],
      },
      { separator: true },
      {
        label: "Delete",
        icon: "🗑",
        shortcut: "Del",
        // Every demo file is "protected" here on purpose: it demonstrates the
        // disabled-row rendering (greyed label, no hover state, no onClick)
        // without needing per-card removal state to make the point.
        disabled: true,
      },
    ],
  });
}

function buildBackgroundMenu() {
  return new ContextMenu({
    width: 190,
    items: [
      {
        label: "Paste",
        icon: "📋",
        shortcut: "Ctrl+V",
        disabled: !clipboard,
        onClick: clipboard
          ? () => flashToast(`Pasted "${clipboard.name}"`)
          : undefined,
      },
      { separator: true },
      {
        label: "Sort by",
        icon: "↕",
        children: [
          { label: "Name", onClick: () => sortFiles("name") },
          { label: "Type", onClick: () => sortFiles("type") },
          { label: "Date modified", disabled: true },
        ],
      },
      { separator: true },
      { label: "New Folder", icon: "📁", shortcut: "Ctrl+Shift+N" },
    ],
  });
}

function sortFiles(by) {
  const sorted = [...FILES].sort((a, b) =>
    by === "name"
      ? a.name.localeCompare(b.name)
      : a.name.split(".").pop().localeCompare(b.name.split(".").pop()),
  );
  FILES.length = 0;
  FILES.push(...sorted);
  renderCards();
  flashToast(`Sorted by ${by}`);
}

function renderCards() {
  for (const child of grid.children.slice()) grid.remove(child);

  for (const file of FILES) {
    const card = new Card({
      width: CARD_W,
      height: CARD_H,
      bg: "#14171f",
      border: "#1b1f29",
      radius: 12,
      label: file.name,
    });

    const swatch = new Rect({
      width: CARD_W - 24,
      height: 44,
      radius: 8,
      fill: file.color,
    });
    swatch.opacity = 0.18;
    swatch.setPosition(12, 14);
    card.add(swatch);

    const icon = new Text(file.icon, { font: "22px sans-serif" });
    icon.setPosition(20, 20);
    card.add(icon);

    const label = new Text(file.name, {
      font: "12.5px Inter, sans-serif",
      color: "#e2e8f0",
      maxWidth: CARD_W - 24,
      lineHeight: 16,
    });
    label.setPosition(12, 68);
    card.add(label);

    const menu = buildFileMenu(file);

    // Right-click isn't a VectoEvent (only pointerdown/up are dispatched into
    // the tree — see the window-level contextmenu suppression above). Filter
    // pointerdown down to the native right button (2), same pattern as the
    // ContextMenu class's own JSDoc example. Pass `card` as the third arg so
    // `showAtPoint` can resolve the scene even on the very first call (before
    // any manual `scene.add(menu)` — Overlay has no parent yet, so its
    // `.scene` walks to null without a source; @vectojs/ui@1.10.0 added this
    // arg exactly to fix that silent no-op).
    card.on("pointerdown", (e) => {
      const native = e.nativeEvent;
      if (native?.button !== 2) return;
      if (e.sceneX === undefined || e.sceneY === undefined) return;
      menu.showAtPoint(e.sceneX, e.sceneY, card);
    });

    grid.add(card);
  }

  toast.setPosition(PAD, grid.height + PAD + 24);
  scene.markDirty();
}

renderCards();

function fit() {
  const w = app.clientWidth;
  const h = app.clientHeight;
  if (w === 0 || h === 0) return;
  scene.resize(w, h);
  backgroundHit.width = w;
  backgroundHit.height = h;
  scene.markDirty();
}

new ResizeObserver(fit).observe(app);
scene.start();
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src="/no-ff-webgpu.js"></script>
    <title>Context Menu — Motif</title>
    <style>
      * {
        margin: 0;
        box-sizing: border-box;
      }
      html,
      body {
        width: 100%;
        height: 100%;
        overflow: hidden;
        background: #06070a;
      }
      #app {
        position: relative;
        width: 100%;
        height: 100%;
      }
      canvas {
        display: block;
        width: 100%;
        height: 100%;
      }
    </style>
    <script type="importmap">
      {
        "imports": {
          "@vectojs/core": "https://esm.sh/@vectojs/core@1.9.2",
          "@vectojs/ui": "https://esm.sh/@vectojs/ui@1.10.0?bundle"
        }
      }
    </script>
  </head>
  <body>
    <div id="app"><canvas id="canvas"></canvas></div>
    <script type="module" src="./demo.js"></script>
  </body>
</html>
liveesm.sh@vectojs/core · @vectojs/uiMIT — copy freely