@mulmocast/beat-editor

Vue 3 components for editing MulmoScript beats live in the browser — every beat type renders as a div, and every editor is swappable
14,546
Install
npm install @mulmocast/beat-editor
Latest Version:1.1.1
Vue:3
License:MIT
Last Updated:Oct 18, 2026
Links: GitHub  ·  npm
Maintainer: snakajima

@mulmocast/beat-editor

npm version npm downloads License: MIT CI GitHub stars

Vue 3 components for editing @mulmocast/deck slide decks live in the browser.

A list editor over a MulmoScript's beats. Every beat type renders as a div, and the pane on the right is whichever editor is registered for that beat's image.type — that registry is the extension point: replace an editor, or add a second one for a type.

A controlled component with no backend, no persistence and no AI — just data ↔ preview.

Highlights

  • Swappable beat editors — one registry keyed by image.type, several editors allowed per type, and your own can replace or join the shipped ones.
  • Every beat type previews — textSlide / markdown / chart / mermaid / image / movie / slide / html_tailwind, each rendered into a div. One shared copy of chart.js and mermaid serves the whole list, and the stylesheet ships prebuilt — no per-slide CDN load.
  • Inspector for structure — add / remove / swap layout type / nest content blocks / edit non-text fields. It is the slide beat's editor.

Install

yarn add @mulmocast/beat-editor @mulmocast/deck vue

vue ^3.5 and @mulmocast/deck ^2.0.0 are peer dependencies. 2.x is the only line this package is developed and tested against.

Usage

Edit a MulmoScript's beats

BeatListEditor edits the beat array itself, so add / remove / reorder are array operations — the deck editor extracts slide beats and zips them back, which is why it cannot insert mid-deck. Every beat type beatToHtml renders previews in the list; the pane on the right edits the selected one.

<script setup lang="ts">
import { ref } from "vue";
import { BeatListEditor, makeBeat, type EditableBeat } from "@mulmocast/beat-editor";
import "@mulmocast/beat-editor/style.css"; // required — see The stylesheet is not optional

const beats = ref<EditableBeat[]>([makeBeat("textSlide"), makeBeat("chart")]);
</script>

<template>
  <BeatListEditor v-model:beats="beats" />
</template>

A beat is edited as Record<string, unknown>, not as the cli's MulmoBeat: a half-typed url or a chartData mid-keystroke is routinely invalid, and beatToHtml returns undefined for anything it cannot render, so the preview degrades instead of breaking.

When your host owns a whole MulmoScript

BeatListEditor takes and emits the beat array. If what you hold is a whole script, beatsOf and withBeats are the two lines in between:

<script setup lang="ts">
import { beatsOf, withBeats, BeatListEditor, type EditableBeat } from "@mulmocast/beat-editor";

const props = defineProps<{ script: Record<string, unknown> }>();
const emit = defineEmits<{ "update:script": [script: Record<string, unknown>] }>();
</script>

<template>
  <BeatListEditor :beats="beatsOf(props.script)" @update:beats="(beats: EditableBeat[]) => emit('update:script', withBeats(props.script, beats))" />
</template>

Write withBeats(script, beats) rather than { beats } — the second drops presentationStyle, slideParams, and everything else the script carried, and nothing tells you it happened.

beatsOf answers with an empty array for anything without a usable beats array, so it is safe to call before the script has loaded. It hands through every element, including one the editor cannot render: dropping one would round-trip as deletion the next time the host wrote the array back.

Embedding in a narrow host

BeatListEditor reflows on its own width, not the window's, so a host that puts it in a card or a split pane declares nothing. Below 48rem the editing pane moves under the beat list and takes the full width; above it, the two sit side by side.

Above the breakpoint the pane's width is a token:

.my-host { --beat-editor-pane-width: 20rem; } /* default: 24rem */

Measured in a browser at a 600px-wide host: the beat preview went from 156px to 540px.

The token is a raw CSS escape hatch, so give it a positive length. Measured, in a 1000px host: 0 leaves a 33px strip of padding, 100% starves the list to 32px and overflows, and an invalid value (-5rem, banana) drops the declaration so the pane falls back to width: auto and sizes to its content — not to the stacked w-full.

Register your own beat editor

Which editor opens for a beat is decided by one list. Pass your own and you can replace what ships, or add a second way into a type that already has one — which is how chart gets both a form and a raw-JSON view.

<script setup lang="ts">
import { ref } from "vue";
import { BeatListEditor, defaultBeatEditors, makeBeat, type BeatEditorDefinition, type EditableBeat } from "@mulmocast/beat-editor";
import MyChartEditor from "./MyChartEditor.vue";

const beats = ref<EditableBeat[]>([makeBeat("chart")]);

const editors: BeatEditorDefinition[] = [
  // Replace what ships for one type...
  ...defaultBeatEditors.filter((editor) => editor.beatType !== "chart"),
  // ...or drop the filter to leave the built-ins in place and add yours as a second tab.
  { id: "chart.mine", label: "Mine", beatType: "chart", component: MyChartEditor },
];
</script>

<template>
  <BeatListEditor v-model:beats="beats" :editors="editors" />
</template>

The component's contract is the whole extension point:

props: { beat: EditableBeat }
emits: update(beat: EditableBeat)

It edits beat.image and nothing else — the fields every beat has (the spoken text) are drawn by the pane, so an editor stays about the one shape it knows. editorsFor(beatType, editors) returns every editor registered for a type in registration order, and the first one is what opens by default. A type with no editor registered is not an error: the pane says so and the beat still previews.

The demo registers one (textSlide.outline) on top of the defaults, so the tab switcher is visible in yarn dev under Beat editor.

What ships
image.type Editors Notes
textSlide Form title / subtitle / bullets
markdown Markdown the string form; the named-slot layout form is read-only
chart Form, JSON the form covers the shape a Chart.js config almost always has; JSON is there for the rest
mermaid Diagram title + diagram source
image / movie Form media source (url / path / base64)
slide Form the full Inspector — every layout and content block
html_tailwind HTML author markup, emitted as written

Components

Component Purpose
<BeatListEditor> List editor over a MulmoScript's beats. v-model:beats, optional editors.
<BeatView> / <BeatEditorPane> One beat's preview and one beat's editing pane — drop into your own layout.
<Inspector> Structural editor for one SlideLayout. The slide beat's editor is built on it.
defaultBeatEditors / editorsFor The shipped editor registry, and the lookup a host uses.
sanitizeFragment The sanitizer BeatView runs a fragment through before inserting it.
defaultTheme / sampleDeck Data helpers for quick starts.
makeBeat / BEAT_TYPES / beatType / beatImage Beat helpers, for building the array a host edits.
beatsOf / withBeats Read and write a script's beats when the host owns a whole MulmoScript.

The Inspector covers every layout (title / bigQuote / columns / comparison / grid / stats / timeline / split / matrix / table / funnel / waterfall / manifesto) and every content block type (text / bullets / callout / tag / code / metric / divider / image / imageRef / chart / mermaid / section / table), with full CRUD + reorder on every array.

How preview works

BeatView calls beatToHtml(beat) from mulmocast/browser, which returns a body-only fragment with no <script> in it. The fragment is sanitized and inserted into a div. chart.js and mermaid are loaded once per page and shared by every beat, and the stylesheet is prebuilt (dist/lib/style.css) rather than fetched — so a list of thirty beats loads the runtimes once, not thirty times.

Architecture

BeatListEditor.vue       (controlled — props: beats, editors; emits: update:beats)
├── BeatView.vue         -- one per beat: its own fragment and its own chart / mermaid runtime,
│                           so editing one beat leaves the others alone
└── BeatEditorPane.vue   -- right (the fields every beat has, plus the registered editor)
    └── editors/registry.ts -- image.type -> component, replaceable by the host
        └── editors/SlideEditor.vue -> Inspector.vue (the `slide` beat's editor)

State is held by the parent (Vue v-model pattern). All mutations are immutable copies, so a preview re-renders reactively.

Scripts

yarn dev          # Vite dev server with the SPA demo
yarn build        # build the demo SPA → dist/
yarn build:lib    # build the publishable library → dist/lib/ (used by prepublishOnly)
yarn lint
yarn format
yarn typecheck    # vue-tsc over src and test
yarn test         # node:test over the pure helpers
yarn knip         # dead-code scan

License

MIT

Using the beat components

yarn add @mulmocast/beat-editor @mulmocast/deck mulmocast vue
import { BeatListEditor, BeatView, defaultBeatEditors } from "@mulmocast/beat-editor";
import "@mulmocast/beat-editor/style.css";   // required — see below
<BeatListEditor :beats="beats" @update:beats="beats = $event" />

BeatView renders one beat, and with editable it also takes inline text edits, the formatting toolbar, and drag-to-reorder of the slide's list items; BeatListEditor is the list plus the editing pane.

The stylesheet is not optional

beatToHtml and generateSlideFragment emit Tailwind class names as strings inside compiled JavaScript. Tailwind honours .gitignore, so it never scans node_modules and your build never generates text-[60px] — a beat renders unstyled with no error to say so. @mulmocast/beat-editor/style.css carries those utilities, generated at publish time.

It contains no preflight: a component library should not reset your page. The typography a markdown beat expects is restored inside .beat-fragment at runtime, so a beat looks the same whether or not your app runs preflight.

Your page does need box-sizing: border-box, which Tailwind's own preflight provides — and if you are already running Tailwind, you have it. Without it every utility that pairs a width with padding overflows by the padding: measured against the built library in a 600px host with no reset, ten elements ran past the edge and the editing pane came out 632px wide.

Swapping an editor

Every editor is a component with one contract — props: { beat }, emits: update(beat) — registered as a BeatEditorDefinition:

import { defaultBeatEditors, type BeatEditorDefinition } from "@mulmocast/beat-editor";
import MyChartEditor from "./MyChartEditor.vue";

const editors: BeatEditorDefinition[] = [
  ...defaultBeatEditors.filter((e) => e.id !== "chart.form"),
  { id: "chart.mine", label: "Mine", beatType: "chart", component: MyChartEditor },
];
<BeatListEditor :beats="beats" :editors="editors" @update:beats="beats = $event" />

A beat type may have several editors; the pane offers them as tabs. chart ships two — a form for the shape a Chart.js config almost always has, and a raw-JSON editor for everything else. Neither erases the other's work: the form carries options, plugin config and per-dataset styling through untouched.

Related Packages

@vue-interface/slide-deck

A Vue slide deck component.

3,202 3
@vue-interface/slide-panel

A Vue slide panel component.

2,652 3
@noirmd/previewer

Framework-agnostic markdown renderer: one vanilla DOM engine with thin React/Vue...

4,457 0
@codemirror/lang-vue

Vue template support for the CodeMirror code editor

71,371,417 29
tailwind-config-viewer

View your Tailwind CSS config file...visually!

19,524,150 2,211

Version History

Version Released License
1.1.1 MIT
1.1.0 MIT
1.0.0 MIT