docs: document event bus and wire app consumers

- Update CHANGELOG.md with event bus, test restructuring, and lint hardening
- Document EditorEvents table and conventions in AGENTS.md
- Wire collab graph sync to editor event bus instead of SceneGraph.onNodeEvents
- Wire collab awareness zoom to viewport:changed instead of Vue watcher
- Wire collab selection broadcasting to selection:changed instead of Vue watcher
This commit is contained in:
Danila Poyarkov 2026-05-06 15:38:45 +03:00
parent 1835903e56
commit 7dce333200
5 changed files with 54 additions and 29 deletions

View file

@ -49,8 +49,8 @@ Runtime `canvaskit-wasm` import exists only in `canvaskit.ts` — all other file
| Module | What | | Module | What |
|---|---| |---|---|
| `types.ts` | EditorState, EditorOptions, Tool, EditorToolDef, EditorContext | | `types.ts` | EditorState, EditorOptions, EditorEvents, Tool, EditorToolDef, EditorContext |
| `create.ts` | `createEditor()` assembler — wires context + all modules | | `create.ts` | `createEditor()` assembler — wires context, event bus + all modules |
| `viewport.ts` | screenToCanvas, applyZoom, pan, zoomToFit/100/Selection | | `viewport.ts` | screenToCanvas, applyZoom, pan, zoomToFit/100/Selection |
| `selection.ts` | select, clearSelection, marquee, snap, hover, entered container | | `selection.ts` | select, clearSelection, marquee, snap, hover, entered container |
| `pages.ts` | switchPage, addPage, deletePage, renamePage | | `pages.ts` | switchPage, addPage, deletePage, renamePage |
@ -66,6 +66,29 @@ Each module exports a factory: `createXxxActions(ctx: EditorContext) => { ... }`
`create.ts` assembles context + all modules, spreads into a flat return object. `create.ts` assembles context + all modules, spreads into a flat return object.
`Editor` type = `ReturnType<typeof createEditor>`. `Editor` type = `ReturnType<typeof createEditor>`.
#### Editor event bus
The editor exposes a typed nanoevents emitter for lifecycle events. Defined in `EditorEvents` (`types.ts`), emitted via `emitEditorEvent()` on the context, subscribed via `editor.onEditorEvent(event, handler)` which returns an unbind function.
| Event | Payload | Emitted by |
|---|---|---|
| `render:requested` | `{ renderVersion, sceneVersion }` | `requestRender()` |
| `repaint:requested` | `{ renderVersion, sceneVersion }` | `requestRepaint()` |
| `graph:replaced` | `SceneGraph` | `replaceGraph()` |
| `node:created` | `SceneNode` | SceneGraph emitter → `graph-events.ts` |
| `node:updated` | `id, changes` | SceneGraph emitter → `graph-events.ts` |
| `node:deleted` | `id` | SceneGraph emitter → `graph-events.ts` |
| `node:reparented` | `nodeId, oldParentId, newParentId` | SceneGraph emitter → `graph-events.ts` |
| `node:reordered` | `nodeId, parentId, index` | SceneGraph emitter → `graph-events.ts` |
| `selection:changed` | `selectedIds[], previousIds[]` | `setSelectedIds()` |
| `tool:changed` | `tool, previousTool` | `setActiveTool()` |
| `page:changed` | `pageId, previousPageId` | `switchPage()`, `replaceGraph()` |
| `viewport:changed` | `{ panX, panY, zoom }, previous` | viewport actions |
All selection mutations in core use `ctx.setSelectedIds()` and all tool changes use `ctx.setActiveTool()` so the event bus fires consistently. App-layer code uses `editor.clearSelection()`, `editor.select()`, or `editor.setTool()` — never direct `state.selectedIds =` or `state.activeTool =` assignments.
Vue SDK provides `useEditorEvent(event, handler)` composable (`packages/vue/src/editor/events/use.ts`) that auto-disposes on scope cleanup.
The app editor session (`src/app/editor/session/create.ts`) is a thin Vue wrapper: creates `shallowReactive` state, calls `createEditor()`, and assembles app-specific modules for document I/O, autosave, export, vector edit, pen resume, flashes, profiler, and mobile clipboard. Tabs live in `src/app/tabs/`; active editor access lives in `src/app/editor/active-store/`. The app editor session (`src/app/editor/session/create.ts`) is a thin Vue wrapper: creates `shallowReactive` state, calls `createEditor()`, and assembles app-specific modules for document I/O, autosave, export, vector edit, pen resume, flashes, profiler, and mobile clipboard. Tabs live in `src/app/tabs/`; active editor access lives in `src/app/editor/active-store/`.
## Commands ## Commands

View file

@ -2,8 +2,16 @@
## Unreleased ## Unreleased
### Added
- Add editor event bus with typed lifecycle events — `selection:changed`, `tool:changed`, `page:changed`, `viewport:changed`, `graph:replaced`, `render:requested`, `repaint:requested`, and all scene graph node events. Subscribe via `editor.onEditorEvent()` in core or `useEditorEvent()` composable in the Vue SDK.
### Changed ### Changed
- Route all selection and tool mutations through `setSelectedIds()` / `setActiveTool()` in the editor core, replacing direct `state.selectedIds` / `state.activeTool` assignments. This ensures event bus subscribers receive consistent change notifications.
- Split large test files into focused domain folders and remove all `max-lines`, `complexity`, and `no-empty-function` test lint exceptions.
- Enforce `no-sibling-domain-prefixed-files` lint rule project-wide — files with prefixes matching an existing sibling folder must be moved into that folder.
- Refactor the editor architecture across core, app, Vue SDK, CLI, MCP, docs, and desktop into smaller domain modules with structural lint rules to keep package boundaries explicit. - Refactor the editor architecture across core, app, Vue SDK, CLI, MCP, docs, and desktop into smaller domain modules with structural lint rules to keep package boundaries explicit.
- Add targeted core subpath exports and package-local import aliases for cleaner app, Vue SDK, CLI, and MCP imports. - Add targeted core subpath exports and package-local import aliases for cleaner app, Vue SDK, CLI, and MCP imports.
- Split the canvas into separate scene and overlay render layers so rulers, labels, selections, and input overlays are isolated from scene rendering. - Split the canvas into separate scene and overlay render layers so rulers, labels, selections, and input overlays are isolated from scene rendering.

View file

@ -1,5 +1,4 @@
import type { Room } from 'trystero' import type { Room } from 'trystero'
import { watch } from 'vue'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { IndexeddbPersistence } from 'y-indexeddb' import { IndexeddbPersistence } from 'y-indexeddb'
import * as awarenessProtocol from 'y-protocols/awareness' import * as awarenessProtocol from 'y-protocols/awareness'
@ -136,19 +135,16 @@ export function createCollabConnectionActions({
} }
export function watchAwarenessZoom(store: EditorStore, getAwareness: () => Awareness | null) { export function watchAwarenessZoom(store: EditorStore, getAwareness: () => Awareness | null) {
return watch( return store.onEditorEvent('viewport:changed', (viewport) => {
() => store.state.zoom, const awareness = getAwareness()
(zoom) => { if (!awareness) return
const awareness = getAwareness() const prev = awareness.getLocalState()?.cursor as
if (!awareness) return | { x: number; y: number; pageId: string; zoom: number }
const prev = awareness.getLocalState()?.cursor as | undefined
| { x: number; y: number; pageId: string; zoom: number } if (prev) {
| undefined awareness.setLocalStateField('cursor', { ...prev, zoom: viewport.zoom })
if (prev) {
awareness.setLocalStateField('cursor', { ...prev, zoom })
}
} }
) })
} }
export function connectCollabSession({ export function connectCollabSession({

View file

@ -76,12 +76,12 @@ export function bindCollabGraphEvents({
} }
} }
return store.graph.onNodeEvents({ const unbinds = [
updated: (id) => onGraphMutation(id), store.onEditorEvent('node:updated', (id) => onGraphMutation(id)),
created: (node) => onGraphMutation(node.id), store.onEditorEvent('node:created', (node) => onGraphMutation(node.id)),
reparented: (nodeId) => onGraphMutation(nodeId), store.onEditorEvent('node:reparented', (nodeId) => onGraphMutation(nodeId)),
reordered: (nodeId) => onGraphMutation(nodeId), store.onEditorEvent('node:reordered', (nodeId) => onGraphMutation(nodeId)),
deleted: (id) => { store.onEditorEvent('node:deleted', (id) => {
const ydoc = getYdoc() const ydoc = getYdoc()
const ynodes = getYnodes() const ynodes = getYnodes()
if (!getSuppressGraphSync() && ydoc && ynodes) { if (!getSuppressGraphSync() && ydoc && ynodes) {
@ -91,8 +91,11 @@ export function bindCollabGraphEvents({
}) })
setSuppressYjsEvents(false) setSuppressYjsEvents(false)
} }
} })
}) ]
return () => {
for (const unbind of unbinds) unbind()
}
} }
export function registerYjsObservers({ export function registerYjsObservers({

View file

@ -1,5 +1,3 @@
import { watch } from 'vue'
import type { useCollabInjected } from '@/app/collab/use' import type { useCollabInjected } from '@/app/collab/use'
import type { EditorStore } from '@/app/editor/active-store' import type { EditorStore } from '@/app/editor/active-store'
@ -12,10 +10,7 @@ export function useCanvasCollaborationAwareness(store: EditorStore, collab: Coll
collab?.updateCursor(cx, cy, store.state.currentPageId) collab?.updateCursor(cx, cy, store.state.currentPageId)
} }
watch( store.onEditorEvent('selection:changed', (ids) => collab?.updateSelection(ids))
() => [...store.state.selectedIds],
(ids) => collab?.updateSelection(ids)
)
return { updateCursor } return { updateCursor }
} }