Build an editor
Reading state
Re-render your UI when the project changes.
project.subscribe(selector, listener) calls listener only when the selected slice changes. Subscribe to the slices a component renders, not the whole state.
import { project } from "../shared/project";
const status = document.querySelector("#status")!;
function render() {
const { playheadUs, selection } = project.getState();
status.textContent = `${(playheadUs / 1e6).toFixed(2)}s · ${selection.length} selected`;
}
render();
const stops = [
project.subscribe((s) => s.playheadUs, render),
project.subscribe((s) => s.selection, render),
];
document.querySelector("#undo")!.addEventListener("click", () => project.undo());
// When the view goes away:
// stops.forEach((stop) => stop());import { useCallback, useSyncExternalStore } from "react";
import type { Project, ProjectState } from "@miraiclip/core";
function useProjectValue<T>(project: Project, select: (s: ProjectState) => T): T {
const subscribe = useCallback(
(onChange: () => void) => project.subscribe(select, onChange),
[project, select],
);
const read = () => select(project.getState());
return useSyncExternalStore(subscribe, read, read);
}
// Selectors live outside the component so their identity is stable.
const selectPlayhead = (s: ProjectState) => s.playheadUs;
const selectSelection = (s: ProjectState) => s.selection;
export function StatusBar({ project }: { project: Project }) {
const playheadUs = useProjectValue(project, selectPlayhead);
const selection = useProjectValue(project, selectSelection);
return (
<div>
{(playheadUs / 1e6).toFixed(2)}s · {selection.length} selected
<button onClick={() => project.undo()}>Undo</button>
</div>
);
}<script setup lang="ts">
import { onScopeDispose, shallowRef } from "vue";
import type { Project } from "@miraiclip/core";
const { project } = defineProps<{ project: Project }>();
const playheadUs = shallowRef(project.getState().playheadUs);
const selection = shallowRef(project.getState().selection);
const stops = [
project.subscribe((s) => s.playheadUs, (v) => (playheadUs.value = v)),
project.subscribe((s) => s.selection, (v) => (selection.value = v)),
];
onScopeDispose(() => stops.forEach((stop) => stop()));
</script>
<template>
<div>
{{ (playheadUs / 1e6).toFixed(2) }}s · {{ selection.length }} selected
<button @click="project.undo()">Undo</button>
</div>
</template><script lang="ts">
import type { Project } from "@miraiclip/core";
let { project }: { project: Project } = $props();
let playheadUs = $state(0);
let selection = $state.raw<string[]>([]);
$effect(() => {
({ playheadUs, selection } = project.getState());
const stops = [
project.subscribe((s) => s.playheadUs, (v) => (playheadUs = v)),
project.subscribe((s) => s.selection, (v) => (selection = v)),
];
return () => stops.forEach((stop) => stop());
});
</script>
<div>
{(playheadUs / 1e6).toFixed(2)}s · {selection.length} selected
<button onclick={() => project.undo()}>Undo</button>
</div>| Slice | Path | Changes on |
|---|---|---|
| Playhead | s.playheadUs | Every played frame (60×/s during playback) |
| Selection | s.selection | project.setSelection(ids) |
| Document | s.doc | Any command, undo or redo |
| Clips, tracks | s.doc.clips, s.doc.tracks, s.doc.trackOrder | Clip and track commands |
The playhead updates every frame
Keep components that read playheadUs small, or update the DOM directly in the listener without re-rendering.