Miraiclip SDK
Build an editor

Selection & shortcuts

Select clips on the timeline and the preview, and bind keyboard shortcuts.

Selection is a list of clip ids in s.selection. It's ephemeral: not part of the document, not saved, not undoable.

project.setSelection(["intro"]);
project.setSelection([]); // clear

project.subscribe(
  (s) => s.selection,
  (ids) => console.log("selected", ids),
);
FactDetail
Storages.selection: string[]; setSelection replaces the whole list
Eventsselection event with { ids }; no patches, no history
UndoUndo and redo don't change the selection
ValidationNone. Any string is accepted, and removed clips stay selected until you prune them

Click to select on the preview

player.hitTest(x, y) returns the topmost clip drawn at a point, in composition pixels. Convert from the pointer position first. This assumes the canvas shows the whole composition at its own aspect ratio.

import type { Player } from "@miraiclip/renderer";

declare const player: Player;
const canvas = document.querySelector<HTMLCanvasElement>("#preview")!;

function toComposition(e: PointerEvent) {
  const rect = canvas.getBoundingClientRect();
  const { width, height } = project.getState().doc.settings;
  return {
    x: ((e.clientX - rect.left) / rect.width) * width,
    y: ((e.clientY - rect.top) / rect.height) * height,
  };
}

canvas.addEventListener("pointerdown", (e) => {
  const { x, y } = toComposition(e);
  const { doc, selection } = project.getState();
  const hit = player.hitTest(x, y, { filter: (clip) => !doc.tracks[clip.trackId]?.locked });
  const additive = e.shiftKey || e.metaKey || e.ctrlKey;

  if (!hit) {
    if (!additive) project.setSelection([]);
  } else if (additive) {
    project.setSelection(selection.includes(hit) ? selection.filter((id) => id !== hit) : [...selection, hit]);
  } else {
    project.setSelection([hit]);
  }
});
hitTest(x, y, options?)
ReturnsThe id of the topmost clip at the point, or null
options.timeUsTest at another time (default: the frame on screen)
options.filter(clip)Skip clips, e.g. on locked tracks
SkipsClips not on screen at that time, clips on hidden tracks, clips with opacity 0 and no keyframes

Drawing the selection

player.getClipBounds(clipId) gives the clip's drawn corners in composition pixels, after scale, rotation and keyframes. Draw them in an SVG laid over the canvas with viewBox set to the composition size.

import type { Player } from "@miraiclip/renderer";

declare const player: Player;
const overlay = document.querySelector<SVGSVGElement>("#overlay")!; // same box as the canvas

const { width, height } = project.getState().doc.settings;
overlay.setAttribute("viewBox", `0 0 ${width} ${height}`);

function drawSelection() {
  const outlines = project.getState().selection.flatMap((id) => {
    const bounds = player.getClipBounds(id);
    if (!bounds) return []; // not on screen now
    const outline = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
    outline.setAttribute("points", bounds.corners.map((p) => `${p.xPx},${p.yPx}`).join(" "));
    outline.setAttribute("fill", "none");
    outline.setAttribute("stroke", "#3b82f6");
    outline.setAttribute("vector-effect", "non-scaling-stroke");
    return [outline];
  });
  overlay.replaceChildren(...outlines);
}

project.subscribe((s) => s.selection, drawSelection);
project.subscribe((s) => s.doc, drawSelection);
project.subscribe((s) => s.playheadUs, drawSelection); // keyframed clips move

getClipBounds returns null for audio clips, clips on hidden tracks, and clips that aren't on screen at that time. bounds.originXPx / originYPx is the transform's pivot; use it to map a drag back to transform.x / transform.y (normalized: divide by the composition width and height).

Removing stale ids

Removing a clip, or undoing the command that added it, leaves its id in selection. Prune when the clips change:

project.subscribe(
  (s) => s.doc.clips,
  (clips) => {
    const { selection } = project.getState();
    const kept = selection.filter((id) => id in clips);
    if (kept.length !== selection.length) project.setSelection(kept);
  },
);

Keyboard shortcuts

import { frameToUs, projectFrameRate, usToFrame, type Clip } from "@miraiclip/core";
import type { Player } from "@miraiclip/renderer";

declare const player: Player;
declare function splitAtPlayhead(): void; // see Timeline → Splitting at the playhead

/** Selected clips on unlocked tracks. */
function editableSelection(): Clip[] {
  const { doc, selection } = project.getState();
  return selection
    .map((id) => doc.clips[id])
    .filter((clip): clip is Clip => clip !== undefined && !doc.tracks[clip.trackId]?.locked);
}

function deleteSelection() {
  const clips = editableSelection();
  if (clips.length === 0) return;
  project.transaction(() => {
    for (const clip of clips) project.dispatch({ type: "clip/remove", payload: { clipId: clip.id } });
  }, "Delete");
  const removed = new Set(clips.map((c) => c.id));
  project.setSelection(project.getState().selection.filter((id) => !removed.has(id)));
}

/** Move the selected clips, or the playhead when nothing is selected, by whole frames. */
function step(frames: number) {
  const { doc, playheadUs } = project.getState();
  const rate = projectFrameRate(doc.settings);
  const shift = (us: number) => Math.max(0, frameToUs(usToFrame(us, rate) + frames, rate));
  const clips = editableSelection();
  if (clips.length === 0) {
    player.seek(shift(playheadUs));
    return;
  }
  project.transaction(() => {
    for (const clip of clips) {
      project.dispatch({ type: "clip/move", payload: { clipId: clip.id, startUs: shift(clip.startUs) } });
    }
  }, "Nudge");
}

function isTyping(target: EventTarget | null): boolean {
  return (
    target instanceof HTMLElement &&
    (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))
  );
}

let rate = 1;

window.addEventListener("keydown", (e) => {
  if (isTyping(e.target)) return;
  const mod = e.metaKey || e.ctrlKey;
  const key = e.key.toLowerCase();

  if (mod) {
    if (key === "z") project[e.shiftKey ? "redo" : "undo"]();
    else if (key === "y") project.redo();
    else if (key === "a") project.setSelection(Object.keys(project.getState().doc.clips));
    else return;
    e.preventDefault();
    return;
  }

  switch (key) {
    case " ":
      if (player.playing) player.pause();
      else player.play();
      break;
    case "delete":
    case "backspace":
      deleteSelection();
      break;
    case "s":
      splitAtPlayhead();
      break;
    case "arrowleft":
      step(e.shiftKey ? -10 : -1);
      break;
    case "arrowright":
      step(e.shiftKey ? 10 : 1);
      break;
    case "escape":
      project.setSelection([]);
      break;
    case "k":
      player.pause();
      rate = 1;
      player.setRate(rate);
      break;
    case "l":
      rate = player.playing ? Math.min(rate * 2, 4) : 1;
      player.setRate(rate);
      player.play();
      break;
    default:
      return;
  }
  e.preventDefault();
});
KeyActionUndo steps
SpacePlay / pauseNone
Delete, BackspaceRemove selected clipsOne (transaction)
⌘/Ctrl+ZUndo
⌘/Ctrl+Shift+Z, Ctrl+YRedo
⌘/Ctrl+ASelect every clipNone
SSplit at the playheadOne (transaction)
← / →Nudge selection one frame, or step the playheadOne per key press
Shift+← / →Ten framesOne per key press
EscClear selectionNone
K / LPause / play, faster on each L (1×, 2×, 4×)None

Notes:

  • frameToUs(usToFrame(us, rate) + n, rate) steps by whole frames and stays exact at 29.97 and 23.976. Adding frameDurationUs repeatedly drifts.
  • Ignore shortcuts while the user types in an input, textarea, select or contenteditable element, or Backspace deletes clips.
  • J (reverse) is left out: the player's published API documents no reverse playback.
  • Undo and redo don't restore the selection. Prune it after undo (see above).

On this page