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),
);| Fact | Detail |
|---|---|
| Storage | s.selection: string[]; setSelection replaces the whole list |
| Events | selection event with { ids }; no patches, no history |
| Undo | Undo and redo don't change the selection |
| Validation | None. 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?) | |
|---|---|
| Returns | The id of the topmost clip at the point, or null |
options.timeUs | Test at another time (default: the frame on screen) |
options.filter(clip) | Skip clips, e.g. on locked tracks |
| Skips | Clips 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 movegetClipBounds 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();
});| Key | Action | Undo steps |
|---|---|---|
| Space | Play / pause | None |
| Delete, Backspace | Remove selected clips | One (transaction) |
| ⌘/Ctrl+Z | Undo | |
| ⌘/Ctrl+Shift+Z, Ctrl+Y | Redo | |
| ⌘/Ctrl+A | Select every clip | None |
| S | Split at the playhead | One (transaction) |
| ← / → | Nudge selection one frame, or step the playhead | One per key press |
| Shift+← / → | Ten frames | One per key press |
| Esc | Clear selection | None |
| K / L | Pause / 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. AddingframeDurationUsrepeatedly 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).