Demos

Multi-Element Composition

2D Intermediate

Multiple draggable canvas children drawn at different positions with transforms, demonstrating z-ordering, changedElements paint data, and multi-element management.

Source Code

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Multi-Element Composition — HTML-in-Canvas</title>
    <style>
      * {
        box-sizing: border-box;
      }

      html {
        height: 100%;
      }

      body,
      :host {
        display: block;
        position: relative;
        margin: 0;
        width: 100%;
        min-height: min(78vh, 820px);
        height: 100%;
        background:
          radial-gradient(
            ellipse at top left,
            rgba(108, 65, 240, 0.16),
            transparent 55%
          ),
          radial-gradient(
            ellipse at bottom right,
            rgba(0, 229, 185, 0.13),
            transparent 60%
          ),
          #0a0a0f;
        color: #f0f0f0;
        font-family: system-ui, -apple-system, sans-serif;
        overflow: hidden;
      }

      #canvas {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        display: block;
        cursor: grab;
      }

      #canvas:active {
        cursor: grabbing;
      }

      /* ── Card elements drawn into the canvas ─────────── */
      .card {
        padding: 0.95rem 1.1rem;
        border-radius: 12px;
        font-family: system-ui, -apple-system, sans-serif;
        font-size: 0.85rem;
        line-height: 1.4;
        color: #fff;
        user-select: none;
        box-shadow:
          0 18px 38px -18px rgba(0, 0, 0, 0.55),
          0 0 0 1px rgba(255, 255, 255, 0.06);
      }

      .card h3 {
        font-size: 0.78rem;
        font-weight: 700;
        margin: 0 0 0.5rem;
        text-transform: uppercase;
        letter-spacing: 0.05em;
        opacity: 0.85;
        cursor: grab;
      }

      .card p {
        font-size: 0.75rem;
        opacity: 0.85;
        margin: 0;
      }

      /* Form controls inside the cards — real DOM, fully interactive */
      .card input[type="text"] {
        width: 100%;
        padding: 0.45rem 0.7rem;
        font-family: inherit;
        font-size: 0.85rem;
        color: #fff;
        background: rgba(0, 0, 0, 0.25);
        border: 1px solid rgba(255, 255, 255, 0.18);
        border-radius: 6px;
        outline: none;
      }

      .card input[type="text"]:focus {
        border-color: rgba(255, 255, 255, 0.6);
        background: rgba(0, 0, 0, 0.35);
      }

      .card input[type="range"] {
        width: 100%;
        accent-color: #fff;
      }

      .card .slider-label {
        display: flex;
        justify-content: space-between;
        font-size: 0.7rem;
        margin-bottom: 0.35rem;
        opacity: 0.85;
      }

      .card select {
        width: 100%;
        padding: 0.45rem 0.7rem;
        font-family: inherit;
        font-size: 0.85rem;
        color: #fff;
        background: rgba(0, 0, 0, 0.25);
        border: 1px solid rgba(255, 255, 255, 0.18);
        border-radius: 6px;
        outline: none;
        appearance: none;
        cursor: pointer;
      }

      .card input[type="color"] {
        width: 100%;
        height: 36px;
        padding: 2px;
        background: rgba(0, 0, 0, 0.25);
        border: 1px solid rgba(255, 255, 255, 0.18);
        border-radius: 6px;
        cursor: pointer;
      }

      .card-image {
        padding: 0.75rem;
        border-radius: 10px;
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 2.5rem;
        user-select: none;
        box-shadow:
          0 18px 38px -18px rgba(0, 0, 0, 0.55),
          0 0 0 1px rgba(255, 255, 255, 0.06);
      }

      .card-button {
        padding: 0.6rem 1.25rem;
        border-radius: 8px;
        border: 2px solid rgba(255, 255, 255, 0.3);
        background: rgba(255, 255, 255, 0.1);
        color: #fff;
        font-family: inherit;
        font-size: 0.8rem;
        font-weight: 600;
        cursor: pointer;
        user-select: none;
        transition: background 0.15s;
        box-shadow: 0 18px 38px -18px rgba(0, 0, 0, 0.55);
      }

      .card-button:hover {
        background: rgba(255, 255, 255, 0.2);
      }

      .card-button:focus-visible {
        outline: 2px solid #fff;
        outline-offset: 2px;
      }

      /* Individual card themes */
      #card-a {
        background: linear-gradient(135deg, #6c41f0, #9b6dff);
        width: 230px;
      }

      #card-b {
        background: linear-gradient(135deg, #00b894, #00e5b9);
        width: 220px;
      }

      #card-c {
        background: linear-gradient(135deg, #e84393, #fd79a8);
        width: 110px;
        height: 110px;
      }

      #card-d {
        background: linear-gradient(135deg, #0984e3, #74b9ff);
        width: 200px;
      }

      #card-e {
        background: linear-gradient(135deg, #fdcb6e, #e17055);
        width: auto;
      }

      /* Floating info badge — top-left of stage */
      .info-badge {
        position: absolute;
        top: 1.25rem;
        left: 1.25rem;
        z-index: 2;
        display: flex;
        flex-direction: column;
        gap: 0.5rem;
        padding: 0.7rem 0.9rem;
        background: rgba(15, 15, 22, 0.78);
        border: 1px solid rgba(255, 255, 255, 0.08);
        border-radius: 10px;
        backdrop-filter: blur(10px);
        font-family: system-ui, -apple-system, sans-serif;
        max-width: 18rem;
      }

      .info-row {
        display: flex;
        flex-direction: column;
        gap: 0.15rem;
      }

      .info-row .label {
        font-size: 0.62rem;
        font-weight: 600;
        text-transform: uppercase;
        letter-spacing: 0.06em;
        color: #6a6a80;
      }

      .info-row .value {
        font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
        font-size: 0.75rem;
        color: #00e5b9;
        line-height: 1.4;
        word-break: break-all;
      }

      .info-row .hint {
        font-size: 0.62rem;
        color: #555570;
      }
    </style>
  </head>
  <body>
    <canvas id="canvas" layoutsubtree>
      <div id="card-a" class="card">
        <h3>✏️ Text input</h3>
        <input type="text" placeholder="Type something..." value="Real DOM!" />
      </div>

      <div id="card-b" class="card">
        <h3>🎚️ Range</h3>
        <div class="slider-label">
          <span>Drag the slider</span>
          <span id="slider-val">50</span>
        </div>
        <input id="slider" type="range" min="0" max="100" value="50" />
      </div>

      <div id="card-c" class="card-image">&#x1F3A8;</div>

      <div id="card-d" class="card">
        <h3>📋 Select</h3>
        <select>
          <option>Crossfade</option>
          <option>Dissolve</option>
          <option>Wipe</option>
          <option>Pixel sort</option>
        </select>
      </div>

      <button id="card-e" class="card-button">Shuffle z-order</button>
    </canvas>

    <div class="info-badge">
      <div class="info-row">
        <span class="label">Z-order (back → front)</span>
        <span class="value" id="z-order-display">A → B → C → D → E</span>
        <span class="hint">Drag any element to bring it to the front</span>
      </div>
      <div class="info-row">
        <span class="label">Last paint</span>
        <span class="value" id="paint-info">waiting…</span>
        <span class="hint">Reports event.changedElements</span>
      </div>
    </div>

    <script>
      // Resolve the script's root: a ShadowRoot when mounted via shadow
      // DOM, or `document` when this file is served standalone.
      const root = window.__demoRoot ?? document;
      const $ = (id) => root.getElementById(id);

      const canvas = $("canvas");
      const ctx = canvas.getContext("2d");
      const zOrderDisplay = $("z-order-display");
      const paintInfo = $("paint-info");

      const cardA = $("card-a");
      const cardB = $("card-b");
      const cardC = $("card-c");
      const cardD = $("card-d");
      const cardE = $("card-e");

      // Each element has its own position, rotation, and scale.
      // Array order = z-order (first = back).
      const elements = [
        { el: cardA, label: "A", x: 60,  y: 60,  rotate: 0,  scale: 1   },
        { el: cardB, label: "B", x: 320, y: 90,  rotate: 0,  scale: 0.9 },
        { el: cardC, label: "C", x: 200, y: 220, rotate: 0,  scale: 1   },
        { el: cardD, label: "D", x: 80,  y: 290, rotate: -5, scale: 1   },
        { el: cardE, label: "E", x: 360, y: 360, rotate: 0,  scale: 1   },
      ];

      let cssW = 0;
      let cssH = 0;
      let paintCount = 0;

      // Subtle dot-grid background
      function drawGrid(context, w, h) {
        context.fillStyle = "rgba(108, 65, 240, 0.08)";
        const step = 32;
        for (let gx = step; gx < w; gx += step) {
          for (let gy = step; gy < h; gy += step) {
            context.beginPath();
            context.arc(gx, gy, 1.2, 0, Math.PI * 2);
            context.fill();
          }
        }
      }

      function updateZDisplay() {
        zOrderDisplay.textContent = elements
          .map((item) => item.label)
          .join(" → ");
      }

      // Paint callback — fires when child rendering changes.
      // Each element is drawn at its own position with its own
      // transform. The array order determines z-stacking.
      canvas.onpaint = (event) => {
        // Bitmap is 1:1 with CSS pixels — see memory
        // project_layoutsubtree_bitmap_layout. No ctx.scale(dpr).
        ctx.reset();

        drawGrid(ctx, cssW, cssH);

        for (const item of elements) {
          ctx.save();
          ctx.translate(item.x, item.y);
          ctx.rotate((item.rotate * Math.PI) / 180);
          ctx.scale(item.scale, item.scale);
          const t = ctx.drawElementImage(item.el, 0, 0);
          if (t) item.el.style.transform = t.toString();
          ctx.restore();
        }

        paintCount++;

        const changed = event && event.changedElements;
        if (changed && changed.length > 0) {
          const names = changed
            .map((el) => {
              const match = elements.find((item) => item.el === el);
              return match ? match.label : el.id;
            })
            .join(", ");
          paintInfo.textContent =
            "#" + paintCount + " changed: [" + names + "]";
        } else {
          paintInfo.textContent = "#" + paintCount + " (full repaint)";
        }

        updateZDisplay();
      };

      // Hit testing — walk the elements array in reverse (topmost first)
      // and check if the point falls within the element's transformed
      // bounding box.
      function hitTest(px, py) {
        for (let i = elements.length - 1; i >= 0; i--) {
          const item = elements[i];
          const el = item.el;
          const w = el.offsetWidth * item.scale;
          const h = el.offsetHeight * item.scale;
          const rad = (-item.rotate * Math.PI) / 180;
          const dx = px - item.x;
          const dy = py - item.y;
          const lx = dx * Math.cos(rad) - dy * Math.sin(rad);
          const ly = dx * Math.sin(rad) + dy * Math.cos(rad);
          if (lx >= 0 && lx <= w && ly >= 0 && ly <= h) return item;
        }
        return null;
      }

      // Drag handling. We compute coordinates from clientX/Y minus the
      // canvas bounding rect because e.offsetX/Y is relative to the
      // event TARGET (which can be one of the canvas children if the
      // user clicks on a card), not the canvas itself.
      let dragItem = null;
      let dragOffsetX = 0;
      let dragOffsetY = 0;

      function canvasCoords(e) {
        const rect = canvas.getBoundingClientRect();
        return {
          x: e.clientX - rect.left,
          y: e.clientY - rect.top,
        };
      }

      // Tags whose clicks should NOT initiate a drag — they're real
      // form controls and the user expects clicks/focus/typing to
      // work normally.
      const interactiveTags = new Set([
        "INPUT",
        "SELECT",
        "TEXTAREA",
        "OPTION",
      ]);

      canvas.addEventListener("pointerdown", (e) => {
        // Skip drag if the click landed on a form control or button
        const targetTag = (e.target).tagName;
        if (interactiveTags.has(targetTag)) return;

        const { x, y } = canvasCoords(e);
        const hit = hitTest(x, y);
        if (!hit) return;

        dragItem = hit;
        dragOffsetX = x - hit.x;
        dragOffsetY = y - hit.y;

        // Move dragged element to front (end of array)
        const idx = elements.indexOf(hit);
        if (idx !== -1 && idx < elements.length - 1) {
          elements.splice(idx, 1);
          elements.push(hit);
        }

        canvas.setPointerCapture(e.pointerId);
        canvas.requestPaint?.();
      });

      canvas.addEventListener("pointermove", (e) => {
        if (!dragItem) return;
        const { x, y } = canvasCoords(e);
        dragItem.x = x - dragOffsetX;
        dragItem.y = y - dragOffsetY;
        canvas.requestPaint?.();
      });

      canvas.addEventListener("pointerup", () => {
        dragItem = null;
      });

      canvas.addEventListener("pointercancel", () => {
        dragItem = null;
      });

      // Wire up the slider value display so the demo proves the
      // form controls inside the canvas are real DOM and respond to
      // input events normally.
      const sliderEl = $("slider");
      const sliderVal = $("slider-val");
      if (sliderEl && sliderVal) {
        sliderEl.addEventListener("input", () => {
          sliderVal.textContent = sliderEl.value;
          canvas.requestPaint?.();
        });
      }

      // Shuffle button — Fisher-Yates on the z-order array. This shows
      // that z-ordering is entirely controlled by draw order, not by
      // DOM order.
      cardE.addEventListener("click", () => {
        for (let i = elements.length - 1; i > 0; i--) {
          const j = Math.floor(Math.random() * (i + 1));
          const tmp = elements[i];
          elements[i] = elements[j];
          elements[j] = tmp;
        }
        canvas.requestPaint?.();
      });

      // ResizeObserver: keep the bitmap 1:1 with CSS pixels. Chrome's
      // layoutsubtree uses canvas.width as the layout viewport for
      // child elements — DPR scaling would break card layout.
      // requestPaint() so the browser's paint pipeline generates the
      // cached paint records that drawElementImage depends on.
      new ResizeObserver(([entry]) => {
        const { width, height } = entry.contentRect;
        cssW = width;
        cssH = height;
        canvas.width = Math.round(width);
        canvas.height = Math.round(height);
        canvas.requestPaint?.();
      }).observe(canvas);
    </script>
  </body>
</html>