207 lines
5.4 KiB
Markdown
207 lines
5.4 KiB
Markdown
|
|
# @zseven-w/pen-engine
|
||
|
|
|
||
|
|
Headless design engine for [OpenPencil](https://github.com/ZSeven-W/openpencil) — framework-free document management, selection, history, viewport, and spatial queries. Build your own design tool UI on top of this engine.
|
||
|
|
|
||
|
|
## Install
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npm install @zseven-w/pen-engine
|
||
|
|
# or
|
||
|
|
bun add @zseven-w/pen-engine
|
||
|
|
```
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
`pen-engine` is the core runtime that powers OpenPencil's editor. It manages the entire document lifecycle without any framework dependency — React, Vue, Svelte, or vanilla JS all work. The optional `browser.ts` entry adds GPU-accelerated canvas rendering via CanvasKit/Skia.
|
||
|
|
|
||
|
|
```
|
||
|
|
DesignEngine
|
||
|
|
|- DocumentManager Node CRUD, tree operations
|
||
|
|
|- SelectionManager Multi-select, hover tracking
|
||
|
|
|- HistoryManager Undo/redo with debounce + batch
|
||
|
|
|- PageManager Multi-page support
|
||
|
|
|- VariableManager Design variables ($refs)
|
||
|
|
|- ViewportController Zoom, pan, coordinate transforms
|
||
|
|
|- SpatialIndex R-tree for hit testing & spatial queries
|
||
|
|
|- EventEmitter Typed event system
|
||
|
|
```
|
||
|
|
|
||
|
|
## Quick Start
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { DesignEngine } from '@zseven-w/pen-engine';
|
||
|
|
|
||
|
|
const engine = new DesignEngine();
|
||
|
|
|
||
|
|
// Load or create a document
|
||
|
|
engine.loadDocument(myDocument);
|
||
|
|
|
||
|
|
// Add a node
|
||
|
|
engine.addNode(null, {
|
||
|
|
id: 'frame-1',
|
||
|
|
type: 'frame',
|
||
|
|
name: 'Header',
|
||
|
|
width: 1200,
|
||
|
|
height: 80,
|
||
|
|
layout: 'horizontal',
|
||
|
|
});
|
||
|
|
|
||
|
|
// Select, undo, inspect
|
||
|
|
engine.select(['frame-1']);
|
||
|
|
engine.undo();
|
||
|
|
console.log(engine.getDocument());
|
||
|
|
```
|
||
|
|
|
||
|
|
## Features
|
||
|
|
|
||
|
|
### Document Operations
|
||
|
|
|
||
|
|
Create, query, and mutate the node tree:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
engine.addNode(parentId, node, index?);
|
||
|
|
engine.updateNode(id, { fill: [{ type: 'solid', color: '#FF0000' }] });
|
||
|
|
engine.removeNode(id);
|
||
|
|
engine.moveNode(id, newParentId, index);
|
||
|
|
engine.duplicateNode(id);
|
||
|
|
engine.groupNodes(['node-1', 'node-2']);
|
||
|
|
engine.ungroupNode(groupId);
|
||
|
|
engine.getNodeById(id);
|
||
|
|
```
|
||
|
|
|
||
|
|
### Selection & Hover
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
engine.select(['node-1', 'node-2']);
|
||
|
|
engine.clearSelection();
|
||
|
|
engine.getSelection(); // string[]
|
||
|
|
engine.setHoveredId('node-3');
|
||
|
|
engine.getHoveredId(); // string | null
|
||
|
|
```
|
||
|
|
|
||
|
|
### History (Undo / Redo)
|
||
|
|
|
||
|
|
Structural history with debouncing and batch support:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
engine.undo();
|
||
|
|
engine.redo();
|
||
|
|
engine.canUndo; // boolean
|
||
|
|
engine.canRedo; // boolean
|
||
|
|
|
||
|
|
// Batch multiple mutations into a single history entry
|
||
|
|
engine.batch(() => {
|
||
|
|
engine.updateNode('a', { x: 100 });
|
||
|
|
engine.updateNode('b', { x: 200 });
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Viewport
|
||
|
|
|
||
|
|
Pan, zoom, and coordinate conversion:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
engine.setViewport(zoom, panX, panY);
|
||
|
|
engine.zoomToRect(x, y, w, h, containerW, containerH);
|
||
|
|
engine.getContentBounds(); // { x, y, w, h } | null
|
||
|
|
engine.screenToScene(screenX, screenY); // { x, y }
|
||
|
|
engine.sceneToScreen(sceneX, sceneY); // { x, y }
|
||
|
|
```
|
||
|
|
|
||
|
|
### Hit Testing (Spatial Index)
|
||
|
|
|
||
|
|
R-tree backed queries for click and marquee selection:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
engine.hitTest(x, y); // PenNode | null
|
||
|
|
engine.searchRect(x, y, w, h); // PenNode[]
|
||
|
|
```
|
||
|
|
|
||
|
|
### Multi-Page
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
engine.addPage(); // returns pageId
|
||
|
|
engine.removePage(pageId);
|
||
|
|
engine.setActivePage(pageId);
|
||
|
|
engine.getActivePage();
|
||
|
|
```
|
||
|
|
|
||
|
|
### Design Variables
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
engine.setVariable('primary', { type: 'color', value: '#2563EB' });
|
||
|
|
engine.removeVariable('primary');
|
||
|
|
engine.renameVariable('primary', 'brand');
|
||
|
|
engine.resolveVariable('$primary'); // '#2563EB'
|
||
|
|
```
|
||
|
|
|
||
|
|
### SVG Import
|
||
|
|
|
||
|
|
Isomorphic SVG parser (DOM in browser, regex fallback in Node.js):
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { parseSvgToNodes } from '@zseven-w/pen-engine';
|
||
|
|
|
||
|
|
const nodes = parseSvgToNodes(svgString, 400);
|
||
|
|
engine.addNode(null, nodes[0]);
|
||
|
|
```
|
||
|
|
|
||
|
|
### Events
|
||
|
|
|
||
|
|
Typed event system for reactive UI binding:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const unsub = engine.on('document:change', (doc) => {
|
||
|
|
/* re-render */
|
||
|
|
});
|
||
|
|
engine.on('selection:change', (ids) => {
|
||
|
|
/* update UI */
|
||
|
|
});
|
||
|
|
engine.on('viewport:change', (viewport) => {
|
||
|
|
/* update zoom indicator */
|
||
|
|
});
|
||
|
|
unsub(); // unsubscribe
|
||
|
|
```
|
||
|
|
|
||
|
|
### Browser Canvas (Optional)
|
||
|
|
|
||
|
|
GPU-accelerated rendering via CanvasKit/Skia — import from `@zseven-w/pen-engine/browser`:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { attachCanvas, attachInteraction } from '@zseven-w/pen-engine/browser';
|
||
|
|
|
||
|
|
const binding = await attachCanvas(engine, canvasElement);
|
||
|
|
const detach = attachInteraction(engine, canvasElement);
|
||
|
|
|
||
|
|
// Later
|
||
|
|
binding.dispose();
|
||
|
|
detach();
|
||
|
|
```
|
||
|
|
|
||
|
|
## API Reference
|
||
|
|
|
||
|
|
| Method | Description |
|
||
|
|
| ------------------------------- | ---------------------- |
|
||
|
|
| `loadDocument(doc)` | Load a PenDocument |
|
||
|
|
| `getDocument()` | Get current document |
|
||
|
|
| `createDocument()` | Create empty document |
|
||
|
|
| `addNode(parent, node, index?)` | Insert node |
|
||
|
|
| `updateNode(id, updates)` | Partial update |
|
||
|
|
| `removeNode(id)` | Delete node + children |
|
||
|
|
| `moveNode(id, parent, index)` | Reparent node |
|
||
|
|
| `duplicateNode(id)` | Deep clone |
|
||
|
|
| `groupNodes(ids)` | Group into frame |
|
||
|
|
| `ungroupNode(id)` | Dissolve group |
|
||
|
|
| `select(ids)` | Set selection |
|
||
|
|
| `undo()` / `redo()` | History navigation |
|
||
|
|
| `batch(fn)` | Batch mutations |
|
||
|
|
| `setViewport(z, x, y)` | Set viewport |
|
||
|
|
| `hitTest(x, y)` | Point query |
|
||
|
|
| `searchRect(x, y, w, h)` | Area query |
|
||
|
|
| `importSVG(svg, parent?)` | Parse and insert SVG |
|
||
|
|
| `dispose()` | Clean up resources |
|
||
|
|
|
||
|
|
## License
|
||
|
|
|
||
|
|
[MIT](./LICENSE)
|