# @moraya/board

Teaching whiteboard core for the Moraya ecosystem — scene-graph model, KaTeX + mhchem formula elements, freehand strokes, and self-contained `*.mboard.svg` serialization.

- **Spec**: `docs/specs/teaching-whiteboard.md` in the moraya repository.
- **License**: GPL-3.0-only (aligned with [@moraya/core](https://www.npmjs.com/package/@moraya/core)).
- **Status**: pre-release (`private: true` blocks accidental publish; consume via `pnpm pack` vendor tarball until first public release).

## What it is

`@moraya/board` is the framework-free core package. Hosts (standalone web app, Moraya editor plugin, Chrome extension) wrap their own UI on top:

| Module | Purpose |
|---|---|
| `./types` | Scene model v1: `stroke` / `shape` / `text` / `formula` / `image` elements. Structured elements keep their source of truth (`formula` stores LaTeX, incl. `\ce{}` chemistry). |
| `./scene` | `createEmptyScene`, structural validation, `extractMarkdown` (board → `$$…$$` / paragraphs; strokes stay in the board file by design). |
| `./formula` | KaTeX rendering with mhchem registered and @moraya/core-aligned strictness policy. Host must load `katex/dist/katex.css`. |
| `./payload` | `.mboard.svg` codec: scene JSON → deflate → base64 in an SVG `<metadata>` comment (Excalidraw embed-scene mechanism). A board file is simultaneously a viewable SVG and the editable source. |

In Markdown, a board is referenced as a plain image — no custom syntax:

```markdown
![Lesson 3 board](./assets/lesson-3.mboard.svg)
```

## Read-only rendering (viewers)

Editing and rendering are cleanly separated. Consumers that only display or
inspect a board — never edit it — should import from `@moraya/board/viewer`,
which exposes parsing + rendering + geometry + types and **none** of the
editing modules (`BoardStore`, history/ops, style mutations, element
authoring). This keeps those modules out of your bundle and blocks accidental
use of an editing API at the type level.

Leanest paths, cheapest first:

1. **Display only, zero JS** — a `.mboard.svg` is a self-contained SVG image
   (formulas are pre-rendered to SVG at export). Just reference it; you don't
   need this package at all:
   ```html
   <img src="lesson-3.mboard.svg" alt="Lesson 3" />
   ```
2. **Have a scene object, render it** — `renderElement` per element pulls no
   compression code, or render the whole scene:
   ```ts
   import { renderSceneToSvg } from '@moraya/board/viewer'
   const svg = renderSceneToSvg(scene, { embedScene: false }) // display-only
   ```
3. **Have a `.mboard.svg` file, parse then render** — recover the scene, then
   render or inspect it (this path pulls the deflate codec to decompress the
   embedded scene):
   ```ts
   import { extractSceneFromSvg, renderSceneToSvg } from '@moraya/board/viewer'
   const scene = extractSceneFromSvg(svgText)
   if (scene) render(renderSceneToSvg(scene, { embedScene: false }))
   ```

Rendering formulas requires `katex` (+ `katex/dist/katex.css` in the host);
strokes require `perfect-freehand`. These are rendering dependencies, not
editing ones. The full read-write surface stays on the main entry
(`@moraya/board`) and the `./store` / `./history` / `./markdown` subpaths.

### Video / audio elements (since 0.8.0)

`video` and `audio` elements reference a hosted media URL via `src` (never a
`data:` URI — inlining media bytes would bloat the scene JSON). They render as
an interactive `<video>`/`<audio>` inside `<foreignObject>` **only in a live,
scripting-capable host**. In every static context they degrade to a poster/
cover fallback drawn inside a `<switch>`:

- a statically-viewed `.mboard.svg` (e.g. `<img src="…svg">`),
- a rasterized PNG/thumbnail (canvas paints the SVG via `<img>`, where media is blank),
- viewers/sanitizers that strip `<foreignObject>`.

So **exported boards show the poster, not a playable clip** — set a `poster`
(video) / cover `poster` (audio) so shared boards aren't blank. HLS `src`
(`.m3u8`) plays only where the host attaches an HLS loader (e.g. `hls.js`) to
the `<video>`; the core emits a plain `<video>` and stays format-agnostic.

Forward-compat note: media types are additive and keep `SCENE_VERSION = 1`, so
non-media boards remain readable by older (<0.8.0) readers; a board that
*contains* a media element is rejected wholesale by pre-0.8.0 readers
(`isBoardScene` hard-rejects unknown element types).

### Custom elements (since 0.17.0)

A `custom` element lets a host persist a domain object the core does **not**
interpret, so products built on the shared board format can attach their own
data without the core growing product-specific concepts:

```ts
const cell: CustomElement = {
  id, type: 'custom', x, y, width, height,
  kind: 'poem.tianzige.cell',            // host-namespaced discriminator
  data: { char: '春', pinyin: 'chūn' },  // opaque JSON; the core never reads it
}
```

- **Round-trip**: `data` is opaque JSON, persisted losslessly through the
  `.mboard.svg` payload. The core never reads into it.
- **Geometry / selection**: bbox, hit-test, snap and layout use `width`/`height`
  like any boxed element.
- **Rendering**: in a static `.mboard.svg` the core draws a neutral dashed
  placeholder labeled with `kind`, so unknown custom nodes stay visible in any
  viewer. A host can supply richer static output per kind via
  `renderSceneToSvg(scene, { customRenderers: { 'poem.tianzige.cell': el => … } })`
  — the host owns escaping the returned markup. Live editors render custom
  nodes in their own canvas layer and never need a renderer here.
- **Namespacing**: `kind` is host-namespaced (e.g. `poem.*`) so custom nodes
  from different products never collide on a shared canvas.

Forward-compat note: `custom` is additive and keeps `SCENE_VERSION = 1`, so
boards without custom nodes remain readable by older (<0.17.0) readers; a board
that *contains* one is rejected wholesale by pre-0.17.0 readers (`isBoardScene`
hard-rejects unknown element types), same trade-off as media types.

## Development

```bash
pnpm install
pnpm test        # vitest
pnpm typecheck   # tsc --noEmit
pnpm build       # tsup → dist/ (pure ESM, ES2022)
```

## Releasing

Bump the version, then tag it. The **tag name must equal `v<package.json
version>`** — the publish workflow fails otherwise (the tag only triggers CI;
the published version comes from `package.json`).

```bash
# 1. Bump the version (semver keyword or an explicit x.y.z)
pnpm version:bump patch     # 0.12.0 → 0.12.1
pnpm version:bump minor     # 0.12.0 → 0.13.0
pnpm version:bump major     # 0.12.0 → 1.0.0
pnpm version:bump 0.13.0    # set an exact version

# 2. Commit + tag (name must match) + push
git add . && git commit -m "chore: release v0.13.0"
git tag v0.13.0
git push origin main --tags   # → CI publishes to npm via OIDC (provenance)

#    Local fallback if OIDC trusted publishing isn't set up (prompts for OTP):
#    npm publish --access public
```

Then bump any consumer that installs the published package
(`"@moraya/board": "^0.13.0"` + `pnpm install`). `moraya-boardraw` uses a
vendored tarball instead — refresh its `vendor/*.tgz`.

## Peer dependencies

- `katex` `^0.16.28` — formula rendering (mhchem ships inside katex).
- `@moraya/core` `^0.8.1` — optional; required only by the upcoming markdown-conversion module.
